Renaming a column without downtime using expand and contract

Do not rename in place during a rolling deploy — add the new column, keep both in sync with a trigger, backfill in batches, switch the model to the new column, and drop the old one in a later release; or, if only Python needs the new name, map it with full_name: Mapped[str] = mapped_column("name") and skip the migration entirely. This guide belongs to zero-downtime schema migration strategies.

Quick Answer

An in-place rename is a single fast statement, and it breaks every running instance of the previous release the moment it commits.

The window where an in-place rename breaks Four steps. The migration renames customers.name to full_name, which is instant. New application pods start and query full_name successfully. Old pods, still serving traffic during the rolling deploy, select customers.name, which no longer exists, and every such request fails. The errors stop only when the last old pod is replaced. migration ALTER TABLE ... RENAME name TO full_name instant, catalog-only new pods start SELECT customers.full_name ... works old pods still serving SELECT customers.name ... UndefinedColumnError on every request last old pod replaced errors stop The rename is fast. The outage is the deploy window, and it is as long as the rollout.

Before — renaming in one migration:

# alembic/versions/a1c0_rename_customer_name.py
from alembic import op


def upgrade() -> None:
    op.alter_column("customers", "name", new_column_name="full_name")

# Old pods, still running during the rollout:
# sqlalchemy.exc.ProgrammingError: (sqlalchemy.dialects.postgresql.asyncpg.ProgrammingError)
# <class 'asyncpg.exceptions.UndefinedColumnError'>: column customers.name does not exist

After — release 1, expand: add the column and keep it in sync:

# alembic/versions/b7d2_expand_customer_full_name.py
import sqlalchemy as sa
from alembic import op


def upgrade() -> None:
    op.add_column("customers", sa.Column("full_name", sa.String(200), nullable=True))
    op.execute("""
        CREATE FUNCTION customers_sync_full_name() RETURNS trigger AS $$
        BEGIN
            IF TG_OP = 'INSERT' THEN
                NEW.full_name := COALESCE(NEW.full_name, NEW.name);
                NEW.name := COALESCE(NEW.name, NEW.full_name);
            ELSIF NEW.full_name IS DISTINCT FROM OLD.full_name THEN
                NEW.name := NEW.full_name;
            ELSIF NEW.name IS DISTINCT FROM OLD.name THEN
                NEW.full_name := NEW.name;
            END IF;
            RETURN NEW;
        END $$ LANGUAGE plpgsql;
    """)
    op.execute("""
        CREATE TRIGGER customers_sync_full_name
        BEFORE INSERT OR UPDATE ON customers
        FOR EACH ROW EXECUTE FUNCTION customers_sync_full_name();
    """)


def downgrade() -> None:
    op.execute("DROP TRIGGER customers_sync_full_name ON customers")
    op.execute("DROP FUNCTION customers_sync_full_name()")
    op.drop_column("customers", "full_name")

The model in this release is unchanged — it still maps name. Old and new pods both work, and every write from either keeps full_name current.

Execution Context & Async Workflow Integration

A rolling deploy runs two versions of the application at once for several minutes, and both talk to one database. Any schema change has to be compatible with both versions for that window. A rename is not: the old version's SQL names a column the new schema does not have, and the new version's SQL names a column the old schema does not have.

Expand, migrate, contract Four releases. Expand: add full_name as a nullable column and a trigger that copies between the two columns on write. Backfill: copy name into full_name in batches. Switch: the application reads and writes full_name only, while the trigger keeps name updated for any old code. Contract: after no code references name, drop the trigger and the old column. release 1 · expand: ADD COLUMN full_name (nullable) + sync trigger old code writes name, the trigger fills full_name; nothing reads it yet backfill: UPDATE ... SET full_name = name in batches a job between releases, not inside the migration release 2 · switch: the model maps full_name only the trigger now keeps name current for any old pod still running release 3 · contract: DROP TRIGGER, DROP COLUMN name only once no deployed code, report or service reads name

The ORM makes this sharper than it would be with hand-written SQL. SQLAlchemy never emits SELECT *; it lists every mapped column explicitly, so a select(Customer) in the old release names customers.name even in code paths that never read the attribute. Every query touching the table fails, not just the ones that use the renamed field. Under asyncpg the error arrives as ProgrammingError wrapping UndefinedColumnError, and on connections with cached prepared statements it can also surface as InvalidCachedStatementError for statements prepared before the migration.

Expand and contract splits the change so that every intermediate state is compatible with the releases on either side of it:

  1. Expand. Add full_name, nullable, with a trigger syncing the two columns. The release that ships this migration still maps name. Old and new pods are identical.
  2. Backfill. Copy name into full_name for existing rows, in batches, from a job rather than the migration.
  3. Switch. Release a model that maps full_name. During this rollout, old pods write name and new pods write full_name, and the trigger keeps both current.
  4. Contract. Once no running code references name, drop the trigger and the column.

Each migration is run before the application release that depends on it, the same ordering rule as in adding a NOT NULL column without locking in Postgres. Adding a nullable column without a default is catalog-only, creating a trigger takes a brief lock, and dropping a column is catalog-only too — PostgreSQL marks it dropped and reclaims space later — so none of the migrations holds a long lock. The slow part, the backfill, runs outside any migration.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
UndefinedColumnError: column customers.name does not exist during deployIn-place rename while the previous release was still running.Expand and contract; or roll back the rename and redeploy in stages.
UndefinedColumnError: column customers.full_name does not existThe model switched to the new column before its migration ran.Run migrations before the release that needs them.
InvalidCachedStatementError: cached statement plan is invalid due to a database schema or configuration changePrepared statements created before the column change.Transient; retries succeed. Expect a brief burst during schema changes.
Old pods' writes lost after the switchNo trigger or dual-write, so name writes never reached full_name.Install the sync trigger in the expand release.
The contract migration breaks a reportA BI query, view or another service still read name.Search views with pg_depend, and check query logs before dropping.
cannot drop column name of table customers because other objects depend on itA view or constraint references the column.Update or drop the dependent view first.
Do you need to rename the database column at all? Left: full_name: Mapped[str] = mapped_column("name") renames the attribute in Python while the column keeps its name; no migration, no deploy ordering, and every query in Python uses full_name. Right: renaming the database column needs the multi-release expand and contract sequence, and matters only when other systems read the table directly or the old name is actively misleading. rename in Python only full_name: Mapped[str] = mapped_column("name") no migration, no deploy window Customer.full_name everywhere in code rename in the database expand, backfill, switch, contract three releases and a backfill job worth it for shared tables, BI tools, or a truly wrong name Most renames are for the benefit of Python code. Start by asking who else reads the column.

The last two rows share a lesson: the application is rarely the only reader. Views, materialised views, reports in a BI tool, a data pipeline and a second service may all select the old column, and none of them are in your repository. Before the contract migration, check dependencies in the catalog and look at what actually queries the column:

from sqlalchemy import text


async def column_dependents(session, table: str, column: str) -> list[str]:
    rows = await session.execute(text("""
        SELECT DISTINCT dependent.relname
        FROM pg_depend d
        JOIN pg_rewrite r ON r.oid = d.objid
        JOIN pg_class dependent ON dependent.oid = r.ev_class
        JOIN pg_class source ON source.oid = d.refobjid
        JOIN pg_attribute a ON a.attrelid = source.oid AND a.attnum = d.refobjsubid
        WHERE source.relname = :table AND a.attname = :column
          AND dependent.oid <> source.oid
    """), {"table": table, "column": column})
    return [row[0] for row in rows]

With pg_stat_statements enabled, searching its query texts for the old column name over a few days of traffic finds readers that the catalog cannot, such as an external reporting job.

Advanced: The Backfill, the Switch and the Python-Only Alternative

The backfill copies existing values in bounded batches, each in its own transaction, so no statement holds row locks on a large share of the table and replicas keep up:

Keeping both columns in sync Three tiles. A database trigger copies whichever column was written to the other; it covers every writer, including other services and manual SQL, and is the most robust. Application dual-writes set both attributes in the ORM; they are simple but miss writers outside the application. A generated column cannot help here because it is read-only and cannot be written by old code. BEFORE trigger copies name ↔ full_name covers every writer ORM dual-write set both attributes misses other writers generated column read-only old code cannot write it The trigger lives for exactly as long as two names are in use, and is dropped in the contract release.
import asyncio

from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker

BATCH = text("""
    WITH batch AS (
        SELECT id FROM customers
        WHERE full_name IS NULL AND name IS NOT NULL
        ORDER BY id
        LIMIT :size
        FOR UPDATE SKIP LOCKED
    )
    UPDATE customers c SET full_name = c.name
    FROM batch WHERE c.id = batch.id
""")


async def backfill_full_name(Session: async_sessionmaker, size: int = 2_000) -> int:
    total = 0
    while True:
        async with Session() as session:
            result = await session.execute(BATCH, {"size": size})
            await session.commit()
        total += result.rowcount
        if result.rowcount < size:
            return total
        await asyncio.sleep(0.05)   # let replication and autovacuum breathe

Because the trigger already handles every write, the backfill only has to cover rows nobody has touched since the expand release, and it is safe to stop and restart.

The switch release changes the model to map the new column, and once the backfill is complete a follow-up migration can make it NOT NULL, using the CHECK ... NOT VALID approach for large tables:

from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Customer(Base):
    __tablename__ = "customers"
    id: Mapped[int] = mapped_column(primary_key=True)
    full_name: Mapped[str] = mapped_column(String(200))
    # `name` is no longer mapped: this release never selects it.

Removing name from the model before the contract migration is essential for the same reason renaming broke things: the ORM lists every mapped column, so a model that still mapped name would fail the moment it was dropped.

Finally, the question to ask before starting: does the database column need a new name, or only the Python attribute? SQLAlchemy separates the two. full_name: Mapped[str] = mapped_column("name", String(200)) gives Python code Customer.full_name while every query still uses the name column. No migration, no deploy ordering, no trigger. If the only people who ever see the column name are Python developers, that single line is the whole change, and using mapped_column instead of Column covers the mapping options.

Renaming a Table: One Release With an Updatable View

Tables are easier to rename safely than columns, because PostgreSQL can make the old name keep working at no cost. A simple view — one table, no aggregates, no joins — is automatically updatable: inserts, updates and deletes against the view are applied to the underlying table. Rename the table and create a view under the old name in the same migration, and old and new releases both work immediately.

Table rename: rename plus a view Three bands. Migration one renames customer to customers and creates a view named customer selecting all columns from customers; simple single-table views accept inserts, updates and deletes. During the rollout, old pods use the view and new pods use the table. A later migration drops the view once no code references the old name. migration: RENAME TABLE customer TO customers; CREATE VIEW customer AS SELECT * catalog-only; a single-table view is automatically updatable rollout: old pods use the view, new pods use the table reads and writes from both land in the same rows later: DROP VIEW customer once no code, report or service references the old name Constraint and index names keep the old table name until renamed — a naming convention will flag them.
from alembic import op


def upgrade() -> None:
    op.rename_table("customer", "customers")
    op.execute("CREATE VIEW customer AS SELECT * FROM customers")


def downgrade() -> None:
    op.execute("DROP VIEW customer")
    op.rename_table("customers", "customer")

The old release keeps selecting from and writing to customer, which is now the view; the new release's models use __tablename__ = "customers". Both migrations are catalog-only, so there is no lock of any duration worth measuring. Once no code references the old name, a later migration drops the view.

Three caveats keep this from being entirely free. SELECT * in a view is expanded when the view is created, so a column added to customers afterwards does not appear in customer — which is fine as long as only old code uses the view, and old code does not know about the new column either. INSERT ... RETURNING through the view works, but sequence-backed defaults are evaluated by the underlying table, so the ORM's primary-key fetch through RETURNING behaves normally. And foreign keys, indexes and triggers belong to the table and move with the rename, while their names do not change — customer_pkey stays customer_pkey until you rename it, which a metadata naming convention will immediately notice and propose to fix.

The same trick does not extend to columns in a useful way. A view can alias full_name AS name, but the table itself cannot have two names for one column, and the ORM of each release addresses the table, not a view. For columns, the trigger-based sequence above remains the general answer — or the Python-only rename, when the database name was never the problem.

Frequently Asked Questions

Is ALTER TABLE RENAME COLUMN slow in PostgreSQL?

No — it is a catalog change and completes almost instantly. The problem is compatibility, not duration: every running instance of the previous release fails on its next query that names the old column.

Can I rename a column in one release if I stop the application?

Yes. With a maintenance window and no running application, an in-place rename plus a model change deployed together is the simplest option. Expand and contract is for when both versions must run at once.

Why use a trigger instead of writing both columns in the application?

A trigger covers every writer — other services, scripts, manual fixes — and needs no application logic that has to be removed later. Application dual-writes miss anything that writes the table directly.

How do I rename only the Python attribute?

Map the new attribute to the old column: full_name: Mapped[str] = mapped_column("name"). Queries keep using the existing column, so no migration is needed.