◐ ai-teacher

🐍 Backend with Python · Chapter 7

Migrations, and never editing a table by hand

Your laptop, CI, staging and production each hold a copy of the schema, and the only way they stay identical is if every change is a numbered, reviewed script that runs everywhere in the same order.

Study this lesson free in the app →

The basics

A migration is a small versioned script that changes the database schema — add a column, create an index, rename a field — with an upgrade() and a downgrade(). Alembic is SQLAlchemy's migration tool.

The problem it solves: the same schema lives in many databases (laptops, CI, staging, production) and your code assumes one exact shape; hand-run ALTER TABLEs make those copies drift apart silently until a deploy breaks.

The mental model: a municipal building-plan register. Every change to the building — an extra floor, a new staircase — is filed as a numbered approval that names the approval before it. The building's legal state is "all approvals up to #17". A mason who adds a wall without filing one has made a building that no longer matches its papers.

Words you must know 5

Revision
one migration file. It has an id and a down_revision naming its parent, so files form a chain.
Head
the newest revision. alembic upgrade head brings a database up to date.
alembic_version
a one-row table in each database recording which revision it is at.
Autogenerate
compares your models with the live database and drafts a migration. A draft to read, not the truth.
Expand–contract
change the schema in backward-compatible steps so old and new code both work during a rolling deploy.

Explained

Thursday 11:40 PM, a hotfix by hand

Search on the rides admin page is timing out. An engineer opens a psql shell on production and runs:

CREATE INDEX ix_rides_city ON rides (city);

Fixed in 90 seconds. Nobody writes a migration.

Monday, a teammate adds a column, runs alembic revision --autogenerate, and the draft contains her column plus op.drop_index('ix_rides_city') — because the index exists in the database but not in the models. She doesn't read the draft closely. It ships. Search times out again on Monday night, and this time nobody remembers why it was ever fast.

Staging never had the index at all, so every load test since Thursday measured a different database from production. That is drift, and the hand edit caused all of it.

The plan register at the municipal office

A four-storey building in Pune has a file at the municipal office: approval #1, the original plan; #2, a balcony; #3, a lift shaft — each approval refers to the one before it.

When a buyer's lawyer asks "is this building legal?", nobody walks around measuring walls. They read the file and check that the building matches approval #3.

Now a contractor quietly knocks out a wall on the second floor. The file still says #3. The building no longer matches it. The next architect who draws approval #4 from the file designs a beam resting on a wall that isn't there.

Migrations are the file. alembic_version is the "currently at #3" stamp. A hand-run ALTER TABLE is the knocked-out wall — the stamp stays the same while reality changes underneath it.

The workflow for every schema change

The accented step is the one people skip. Autogenerate is a typist, not a reviewer.

The commands, with what they actually print

$ alembic init migrations
Creating directory .../migrations ...  done
Generating .../alembic.ini ...  done

Point sqlalchemy.url in alembic.ini at the database, and in migrations/env.py set target_metadata = Base.metadata (import your models). Then:

$ alembic revision --autogenerate -m "create rides"
INFO  [alembic.autogenerate.compare.tables] Detected added table 'rides'
Generating .../versions/9ac08a5ffa79_create_rides.py ...  done

$ alembic upgrade head
INFO  [alembic.runtime.migration] Running upgrade  -> 9ac08a5ffa79, create rides

$ alembic current
9ac08a5ffa79 (head)

$ alembic history
9ac08a5ffa79 -> fde39261969c (head), add city
<base> -> 9ac08a5ffa79, create rides

$ alembic downgrade -1          # step back one revision
$ alembic upgrade head --sql    # print the SQL instead of running it

--sql is how you show a DBA exactly what will run: for the city migration it printed ALTER TABLE rides ADD COLUMN city VARCHAR(40); and the UPDATE alembic_version line.

What a migration file looks like

After adding city: Mapped[str | None] = mapped_column(String(40)) to the model, autogenerate wrote:

revision: str = 'fde39261969c'
down_revision: Union[str, Sequence[str], None] = '9ac08a5ffa79'

def upgrade() -> None:
    """Upgrade schema."""
    # ### commands auto generated by Alembic - please adjust! ###
    op.add_column('rides', sa.Column('city', sa.String(length=40), nullable=True))
    # ### end Alembic commands ###

def downgrade() -> None:
    """Downgrade schema."""
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_column('rides', 'city')
    # ### end Alembic commands ###

Three things to see:

Autogenerate meets a rename

The rides table has 2 rows. You rename the model field fare: Mapped[int] to fare_paise: Mapped[int] and run alembic revision --autogenerate -m "rename fare".

Rename a column with zero downtime: expand, backfill, contract

The table states shown are what sqlite3 printed after alembic upgrade 0002, 0003 and 0004. Three migrations and two deploys instead of one — that's the price of never having a moment where running code and schema disagree.

What autogenerate misses, and other traps

Words to know

Migration (revision)A versioned script with upgrade() and downgrade() that moves the schema one step; linked to its parent by down_revision.
HeadThe newest revision in the chain; the target of alembic upgrade head.
alembic_versionA table in each database holding the revision id it is currently at.
AutogenerateAlembic comparing model metadata with the live database to draft a migration.
Schema driftWhen a database's real schema differs from what its migration history says it should be.
Expand–contractMaking a breaking change as backward-compatible steps: add new, migrate data and code, remove old.
BackfillPopulating a new column for rows that existed before it, usually in batches.

In two minutes

Test yourself

1. What does Alembic store in the alembic_version table?

  1. The revision id the database is currently at
  2. A copy of every migration's SQL
  3. The timestamp of every upgrade
  4. A checksum of the whole schema
Show the answer

The revision id the database is currently at — It's a single row with version_num — the 'currently at approval #3' stamp. It records history position, not the actual schema, which is exactly why hand edits go unnoticed.

2. Why should a migration be committed in the same PR as the model change?

  1. Alembic refuses to run otherwise
  2. Git can't track migration files separately
  3. So code and schema always deploy together and reviewers see both
  4. So autogenerate can run in production
Show the answer

So code and schema always deploy together and reviewers see both — Code that expects city deployed without the migration fails on its first query. One PR means one review and one deploy unit; CI can check they agree.

3. Why is renaming a column in a single migration unsafe during a rolling deploy?

  1. Alembic can't rename columns
  2. Pods still running the old code query the old name and fail until they're replaced
  3. Renames always lose data
  4. Postgres forbids renames on tables with rows
Show the answer

Pods still running the old code query the old name and fail until they're replaced — During a rollout old and new code run at once. Whichever name the schema has, one version is broken. Expand–contract keeps both names valid until no code needs the old one.

4. How does Alembic decide which migrations to run on alembic upgrade head?

  1. It runs every file in the versions folder
  2. It compares models to the database
  3. It runs files newer than the last deploy's timestamp
  4. It reads alembic_version, then follows down_revision links from there to head, in order
Show the answer

It reads alembic_version, then follows down_revision links from there to head, in order — The chain, not timestamps or file order, decides. Autogenerate is the one that compares models; upgrade only replays revisions.

5. What is the difference between alembic upgrade head and Base.metadata.create_all()?

  1. None — both apply pending changes
  2. create_all is faster for column changes
  3. upgrade head replays versioned changes on existing tables; create_all only creates missing tables and never alters existing ones
  4. create_all records the revision in alembic_version
Show the answer

upgrade head replays versioned changes on existing tables; create_all only creates missing tables and never alters existing ones — create_all skips any table that exists, so a new column on an existing table never appears. It's fine for a throwaway test DB, useless for evolving a real one.

6. What happens when you rename fare to fare_paise in the model and run alembic revision --autogenerate?

  1. It drafts op.alter_column with new_column_name
  2. It drafts add_column('fare_paise') and drop_column('fare')
  3. It asks whether this is a rename
  4. It detects nothing
Show the answer

It drafts add_column('fare_paise') and drop_column('fare') — Observed: 'Detected added column rides.fare_paise' and 'Detected removed column rides.fare'. Autogenerate only sees names; running that draft drops the data.

There are 9 questions, 2 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →