Skip to main content

Featured

 Distributed State Controls — Redis Redlocks

Distributed State Controls — Redis Redlocks

BACKEND SERIES Day 28: Distributed State Controls — Redis Redlocks & Split-Brain Prevention 12 min read Series: Logic & Legacy Day 28 / 50 Level: Senior / Distributed Systems ⏳ Context: Yesterday, we implemented in-memory synchronization using asyncio.Lock and asyncio.Semaphore to protect single-process event loops. But when your backend scales out across 20 Kubernetes pods behind a load balancer, single-node locks offer zero protection. When Pod A and Pod B independently attempt to process the exact same payment or mutate the same database record simultaneously, your database becomes a battleground. To enforce multi-node mutual exclusion, we must step up to Distributed Locks using Redis and the Redlock algorithm. 1. The Naive Redis Lock Trap The simplest way developers attempt distributed locking is via SET key value NX PX 30000 . While this works in ideal network conditions, it introduces catastrophic failure modes when network pauses or GC freeze...

Database Migrations — Zero-Downtime SQL, Alembic & Schema Evolution (2026)

Skip to main content

Day 12: The SQL of Evolution — Alembic & Zero-Downtime Migrations

  • Series: Logic & Legacy
  • Day 12 / 30
  • Level: Senior Architecture

Context: In Day 11, we perfected our relational models. But applications evolve. Marketing demands a new column; security demands an index. If you manually run ALTER TABLE in your production database, you are playing Russian Roulette. If the command locks the table during peak traffic, your entire API goes offline.

"It works on my machine, but production is locked..."

Your Python codebase is tracked immutably in Git, but your database schema often lives in the wild west. To fix this, we use Database Migrations. While the Python ecosystem uses Alembic to manage these migrations, a Senior Architect does not blindly trust Python ORM abstractions. You must understand the exact Raw SQL (Data Definition Language - DDL) that Alembic generates. Today, we architect schema evolution entirely through the lens of the Postgres SQL engine.

1. The Paradigm: DDL as Versioned Code

Developers often ask: "If I update my SQLAlchemy models, why doesn't the database just update itself?"

Infographic explaining zero-downtime database migrations with Alembic, including DDL versioning, 3-phase schema change strategy, concurrent indexing, ENUM handling, and SQLite batch operations in PostgreSQL.


Because defining state is easy; mutating state is dangerous. If you run SQLAlchemy's Base.metadata.create_all(), it issues CREATE TABLE IF NOT EXISTS commands. But it cannot safely issue ALTER TABLE commands. It doesn't know what to do with the millions of rows of existing data when you change a column type.

Alembic works by generating a sequential timeline of Python scripts. When you run alembic upgrade head, Alembic checks a tiny Postgres table called alembic_version, finds out it is currently on Revision 4, and sequentially executes the raw SQL for Revisions 5, 6, and 7.

2. The Architect's Zero-Downtime Protocol (The 3-Phase SQL Shift)

The Scenario: You have a users table with a full_name column. You have 10 million rows. Product wants to split it into first_name and last_name.

If you issue ALTER TABLE users DROP COLUMN full_name; and add the new columns, your live API—which is still running the old Python code—will instantly crash with a 500 Internal Server Error because it expects full_name to exist.

To safely mutate data structures in a live environment, architects execute a multi-phase deployment using explicit SQL steps.

Phase 1 & 2: The Additive Migration & Data Sync (Alembic SQL)
def upgrade():
    # PHASE 1: Add new columns as NULLABLE.
    # UNDER THE HOOD SQL: 
    # ALTER TABLE users ADD COLUMN first_name VARCHAR(100);
    # ALTER TABLE users ADD COLUMN last_name VARCHAR(100);
    op.add_column('users', sa.Column('first_name', sa.String(100), nullable=True))
    op.add_column('users', sa.Column('last_name', sa.String(100), nullable=True))

    # PHASE 2: Data ETL directly inside the database engine.
    # Executing raw SQL here is exponentially faster than pulling 10M rows into Python.
    op.execute("""
        UPDATE users 
        SET first_name = split_part(full_name, ' ', 1),
            last_name = substring(full_name from position(' ' in full_name) + 1)
        WHERE full_name IS NOT NULL AND first_name IS NULL;
    """)

    # PHASE 3: Enforcing Integrity on the disk level.
    # UNDER THE HOOD SQL:
    # ALTER TABLE users ALTER COLUMN first_name SET NOT NULL;
    op.alter_column('users', 'first_name', nullable=False)
    op.alter_column('users', 'last_name', nullable=False)

3. Concurrent Indexing: Escaping the Table Lock

If you run CREATE INDEX idx_users_email ON users(email); on a 50GB table, Postgres acquires a ShareLock. This blocks all INSERT, UPDATE, and DELETE operations until the index finishes building (which could take 20 minutes). Your API is effectively dead.

Postgres provides a savior: CONCURRENTLY. It builds the index in the background without locking writes. However, it cannot be run inside a transaction block (BEGIN ... COMMIT), which Alembic uses by default.

Zero-Downtime Indexing (Raw SQL via Alembic)
def upgrade():
    # CRITICAL: Step outside the default Alembic transaction block!
    with op.get_context().autocommit_block():
        # UNDER THE HOOD SQL:
        # CREATE INDEX CONCURRENTLY idx_users_last_name ON users (last_name);
        op.create_index(
            'idx_users_last_name',
            'users',
            ['last_name'],
            postgresql_concurrently=True
        )

4. The ENUM Dilemma: Managing Custom Postgres Types

Unlike simple VARCHAR columns, Postgres handles ENUM types as first-class database objects. You cannot simply attach an enum to a column; you must run a CREATE TYPE statement first. Alembic's --autogenerate frequently fails to deduce this properly.

Safe Postgres ENUM Creation
def upgrade():
    from sqlalchemy.dialects import postgresql
    
    # UNDER THE HOOD SQL 1:
    # CREATE TYPE user_status_enum AS ENUM ('active', 'suspended', 'banned');
    status_enum = postgresql.ENUM('active', 'suspended', 'banned', name='user_status_enum')
    status_enum.create(op.get_bind())
    
    # UNDER THE HOOD SQL 2:
    # ALTER TABLE users ADD COLUMN status user_status_enum NOT NULL DEFAULT 'active';
    op.add_column('users', sa.Column(
        'status', 
        status_enum, 
        server_default='active', 
        nullable=False
    ))

5. SQLite Batch Mode: The Secret Table Rebuild

If you are developing locally with SQLite, you will eventually hit a wall: SQLite does not fully support ALTER TABLE DROP COLUMN. How does Alembic handle drops in SQLite?

It uses Batch Operations to perform an elaborate shell game entirely via raw SQL:

  1. CREATE TABLE _alembic_tmp_users (...) (A new table without the dropped column).
  2. INSERT INTO _alembic_tmp_users SELECT id, email FROM users; (Copy all data over).
  3. DROP TABLE users;
  4. ALTER TABLE _alembic_tmp_users RENAME TO users;

This is why understanding the SQL your tools generate is mandatory. An abstraction is only safe until it leaks.

🛠️ Day 12 Project: The Advanced Alembic Arsenal

Review the alembic_migrations.py reference file in the official repository. It contains the Senior Arsenal of migration patterns.

  • Observe the exact syntax for executing raw UPDATE SQL commands during an upgrade.
  • Review the downgrade() block. Understand why rolling back a column drop is dangerous (the schema returns, but the data does not).
  • Review the op.bulk_insert() block, demonstrating how to securely seed system configurations natively during a schema deployment.
View the Alembic Senior Arsenal on GitHub → 🔥 PRO UPGRADE: HANDLING MERGE CONFLICTS

In a team of 10 developers, Dev A and Dev B might both create a migration branch from the same base revision. When merged to master, Alembic will crash, complaining of "Multiple head revisions."
The Fix: Run alembic merge heads -m "merge branching migrations". Alembic will execute a topological sort and generate a special "merge" script that ties both divergent timelines back together into a single, unified future state.

6. FAQ: Migration Architecture

Are downgrade() methods strictly necessary?

Yes and No. For simple column additions, downgrades (ALTER TABLE DROP COLUMN) are trivial. However, if your upgrade() drops a table or deletes data, a downgrade() is mathematically impossible unless you have a full database snapshot. In modern enterprise CI/CD, teams often adopt a "Fix Forward" mentality, avoiding downgrades entirely and instead writing a new upgrade() script to correct the mistake.

Why use Alembic instead of saving raw .sql files in a folder?

Alembic tracks the "State" of your database in the alembic_version table. If one developer adds a column and you don't have it, Alembic knows exactly which scripts to run sequentially to sync your local Postgres with the master schema. Raw SQL files are a nightmare to track and sequence across a distributed engineering team.

How do I test my migrations safely?

Never test alembic upgrade head blindly against production. Always pull an anonymized, sanitized staging copy of the production database, apply your migrations locally or in a CI/CD pipeline against that staging DB, and verify the application still boots and queries correctly.

Comments