Index Ai Academy
Phase 7 · Software engineering · Building software

Changing a database safely

Numbered migrations in transactions, required columns and backfills, and expand-and-contract changes.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

A database's schema is its shape: the tables, their columns and their rules. The schema you design on day one won't survive the year. A new feature needs a column, a rule needs tightening, a name turns out to be wrong. On your laptop you'd just recreate the database, but in production it holds real customers' data and the app is reading it while you work. Every team that runs a database has a way to change it safely, called migrations, and getting them wrong is one of the classic ways to take a site down. This lesson builds a migration runner from scratch, so the tools you'll meet at work hold no mystery.

What you'll be able to do

  • Explain why a live database's shape changes through numbered, one-way migrations, not by hand
  • Write a migration runner that applies pending changes in order, each in its own transaction
  • Add a required column to a table that already has rows, and backfill it
  • Rename or remove a column without downtime by expanding first and contracting later
  • Say what migration tools automate, and what they still get wrong

What it covers

  1. A migration is a numbered, one-way step
  2. All or nothing: one transaction per migration
  3. Adding a required column to a table with rows
  4. When ALTER TABLE isn't enough: the table rebuild
  5. Why not just rename it?
  6. A migration that backfills
  7. Worked example: what migration tools do, and don't
Quick check
Your app's database needs a new column, and production already holds a year of data. How should the change be made?
  1. By editing the production database by hand, carefully
  2. By deleting the database and recreating it with the new column
  3. As a numbered migration script, tested first, then run once on production
  4. By adding the column only on your own laptop's copy
Show the answer

As a numbered migration script, tested first, then run once on production

A migration is a small, numbered, one-way step, kept in version control and run the same way on every copy of the database. That makes changes repeatable, reviewable and safe for real data.

A migration is a numbered, one-way step

Instead of changing tables by hand, you write each change as a small script with a number: 1 creates the members table, 2 adds trips, 3 adds an email column. The database remembers the number of the last one it applied. To update any copy of the database (yours, a teammate's, the test server's, production), a runner applies every script with a higher number, in order. Everyone ends up with exactly the same shape, and the scripts, kept in git, are the history of how it got there.

SQLite has a spare number in every database file for exactly this, PRAGMA user_version, starting at 0:

import sqlite3

MIGRATIONS = [
    (1, ["CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"]),
    (2, ["CREATE TABLE trips (id INTEGER PRIMARY KEY, name TEXT NOT NULL, day TEXT NOT NULL)"]),
    (3, ["ALTER TABLE members ADD COLUMN email TEXT"]),
]

def migrate(db):
    current = db.execute("PRAGMA user_version").fetchone()[0]
    for version, statements in MIGRATIONS:
        if version <= current:
            continue
        for sql in statements:
            db.execute(sql)
        db.execute(f"PRAGMA user_version = {version}")
        print("applied", version)

db = sqlite3.connect(":memory:")
migrate(db)
migrate(db)   # nothing left to do: safe to run again
print("version", db.execute("PRAGMA user_version").fetchone()[0])
print([row[1] for row in db.execute("PRAGMA table_info(members)")])
It runs right here in your browser, with nothing to install.

One line breaks a rule from earlier lessons: the version goes in with an f-string, not a ? placeholder. PRAGMA statements don't accept placeholders, and it's safe here only because the version is a number the code controls (the exercise wraps it in int() to be sure).

Two rules make this work. Never edit a migration that has run somewhere: other copies have already applied the old version, so they'd silently differ. Write a new migration instead. And apply migrations before the new code needs them, usually as one step in the deploy, before the app starts serving traffic. Run it once per deploy, not from every server as it starts: two servers starting together could both read version 2 and both try migration 3. Real tools take a lock first (in SQLite, BEGIN IMMEDIATE, then read the version inside that transaction).

Quick check
A teammate spots a typo in migration 3, which production already ran last week. What should they do?
  1. Delete migration 3 and run the migration runner again
  2. Write migration 4 that fixes it, and leave migration 3 alone
  3. Fix the table by hand in production, then update the file
  4. Edit migration 3 in place, since it's only a small typo
Show the answer

Write migration 4 that fixes it, and leave migration 3 alone

Production's version number already says 3 is done, so an edited 3 would never run there, and every copy would quietly differ. A new migration is the only change that reaches all of them the same way.

Quick check
A database is at version 2. The runner is given migrations 1 to 4, and migration 3 has a statement that fails, with no transactions. What is likely to happen on the next run, after migration 3 is fixed?
# the runner, without transactions
for version, statements in MIGRATIONS:
    if version <= current:
        continue
    for sql in statements:
        db.execute(sql)          # migration 3's second statement fails here
    db.execute(f"PRAGMA user_version = {version}")
  1. Only migration 3's second statement runs, since the runner remembers which statement failed last time
  2. Migration 3 runs again from its first statement, which already ran, and can fail again with "table already exists"
  3. Migrations 1 to 4 all run again from the start, since the database's version was never updated
  4. Nothing runs, because the database's version was already set to 3 before the failure
Show the answer

Migration 3 runs again from its first statement, which already ran, and can fail again with "table already exists"

The first statement of migration 3 was applied, but the version was never updated, so the database is half-changed and still claims version 2. The next run repeats the whole of migration 3. A transaction per migration prevents exactly this.

All or nothing: one transaction per migration

That's the half-changed state from the check above: the first statement applied, the version number not updated, and the next run tries the first statement again and crashes. The fix is a transaction around each migration, including the version update, so it either fully happens or not at all.

SQLite can roll back table changes too, not just data, but Python's sqlite3 normally manages transactions for you in a way that doesn't cover them. Opening the connection with isolation_level=None hands you the controls: you write BEGIN, COMMIT and ROLLBACK yourself. (Python 3.12 added an autocommit setting for the same job; isolation_level=None works on every version.)

import sqlite3

db = sqlite3.connect(":memory:", isolation_level=None)
db.execute("CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")

broken = (2, ["CREATE TABLE trips (id INTEGER PRIMARY KEY, name TEXT)",
              "CREATE INDEX trips_by_day ON trips (day)"])    # there's no day column: this fails

version, statements = broken
db.execute("BEGIN")
try:
    for sql in statements:
        db.execute(sql)
    db.execute(f"PRAGMA user_version = {version}")
    db.execute("COMMIT")
except sqlite3.Error as e:
    db.execute("ROLLBACK")
    print("migration", version, "failed and was undone:", e)

print("version:", db.execute("PRAGMA user_version").fetchone()[0])
print("tables:", [r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")])

The trips table that the first statement created is gone again, and the version is still 0, so after fixing the migration, the next run starts cleanly. (Some databases, MySQL among them, can't roll back table changes, which is one more reason to keep each migration small.)

Adding a required column to a table with rows

Here's the change that trips people up. The members table has a thousand rows, and every member now needs a region. ALTER TABLE members ADD COLUMN region TEXT NOT NULL fails: the thousand existing rows would have no value, and NOT NULL forbids exactly that. There are two safe ways through:

  1. Add it with a default, ... NOT NULL DEFAULT 'unknown', when one value is a fair start for everyone.
  2. Add it as nullable, backfill, then tighten: add the column allowing NULL, fill it in from data you have (a backfill), and make the code require it from then on. Tightening the column itself to NOT NULL in SQLite means rebuilding the table (create a new one, copy the rows, swap the names), so many teams enforce it in the application instead, or schedule the rebuild for a quiet moment.
import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT, zip_code TEXT)")
db.executemany("INSERT INTO members (name, zip_code) VALUES (?, ?)",
               [("Ana", "97201"), ("Bo", "98101"), ("Cy", "80202"), ("Dee", None)])

try:
    db.execute("ALTER TABLE members ADD COLUMN region TEXT NOT NULL")
except sqlite3.OperationalError as e:
    print("refused:", e)

db.execute("ALTER TABLE members ADD COLUMN status TEXT NOT NULL DEFAULT 'active'")   # way 1: a default
print(db.execute("SELECT name, status FROM members").fetchall())

db.execute("ALTER TABLE members ADD COLUMN region TEXT")       # way 2, step 1: allow NULL for now
db.execute("""UPDATE members SET region = CASE substr(zip_code, 1, 2)
                  WHEN '97' THEN 'oregon' WHEN '98' THEN 'washington' WHEN '80' THEN 'colorado'
                  ELSE 'unknown' END""")                       # step 2: backfill from what we know
print(db.execute("SELECT name, zip_code, region FROM members").fetchall())
print("still missing:", db.execute("SELECT COUNT(*) FROM members WHERE region IS NULL").fetchone()[0])

The last line is the check to run before tightening anything: a backfill that left gaps would make the next step fail in production. On a big table, backfill in batches, say a thousand rows per UPDATE, each in its own transaction, so the table is never locked for long.

When ALTER TABLE isn't enough: the table rebuild

SQLite's ALTER TABLE can do a few things in place: ADD COLUMN (though not a UNIQUE or primary key column, so the exercise below adds a unique index instead), RENAME COLUMN, RENAME TO for the table, and DROP COLUMN for a column no index or rule uses. Anything else, such as tightening a column to NOT NULL, changing its type, or adding a CHECK or a foreign key, means rebuilding the table, all in one transaction:

import sqlite3

db = sqlite3.connect(":memory:", isolation_level=None)
db.executescript("""
CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT, region TEXT);
INSERT INTO members (name, region) VALUES ('Ana', 'oregon'), ('Bo', 'washington');
""")

db.execute("PRAGMA foreign_keys = OFF")    # must come before BEGIN: it's ignored inside a transaction
db.execute("BEGIN")
db.execute("CREATE TABLE members_new (id INTEGER PRIMARY KEY, name TEXT NOT NULL, region TEXT NOT NULL)")
db.execute("INSERT INTO members_new (id, name, region) SELECT id, name, region FROM members")
db.execute("DROP TABLE members")
db.execute("ALTER TABLE members_new RENAME TO members")
db.execute("COMMIT")
db.execute("PRAGMA foreign_keys = ON")

print(db.execute("SELECT * FROM members").fetchall())
try:
    db.execute("INSERT INTO members (name) VALUES ('Cy')")
except sqlite3.IntegrityError as e:
    print("refused:", e)

Create the new shape, copy the rows across, drop the old table and rename the new one. Indexes and triggers belong to the old table and go with it, so recreate them after the rename. PostgreSQL makes the same change in one statement, ALTER TABLE members ALTER COLUMN region SET NOT NULL, but it still checks every row, so the gap check comes first there too.

Quick check
During a rebuild, one member's region is still NULL when INSERT INTO members_new ... SELECT ... FROM members runs, and members_new requires a region. What happens?
  1. SQLite fills in an empty string for the missing region, so the copy goes through
  2. The insert fails; after ROLLBACK the old table is untouched, which is why you check for gaps first
  3. That member is quietly skipped, and every other member is copied into the new table
  4. The old table is dropped anyway, and that member's row is lost for good
Show the answer

The insert fails; after ROLLBACK the old table is untouched, which is why you check for gaps first

NOT NULL is checked as each row is copied, so the INSERT raises an IntegrityError. Because the rebuild runs in one transaction, rolling back leaves the original table exactly as it was.

Try it now

Each step is a separate deploy, so old and new code can run side by side while servers update one by one.

Why not just rename it?

SQLite can rename a column in one statement, ALTER TABLE members RENAME COLUMN name TO display_name. On your laptop that's fine. In production, the app runs on several servers, and a deploy updates them one at a time. For a few minutes, old code (which reads name) and new code (which reads display_name) run at the same moment against the same database. A one-step rename breaks whichever half is on the wrong side of it.

Expand and contract (the steps above) avoids that window entirely: each step is compatible with both the code before it and the code after it. It's slower, several small deploys instead of one, which is why teams save it for busy, shared tables and use the one-step change where a minute of errors doesn't matter.

The same thinking applies to every change. Adding things (a table, a nullable column, an index) is safe for the running code, though building an index on a big table can block writes while it runs (PostgreSQL's CREATE INDEX CONCURRENTLY avoids that). Removing or renaming things is only safe once no running code uses them. Ask of each migration: would the code that's running right now still work after this?

Drill

A migration that backfills

Write migration 4 as a list of SQL statements: add a nullable weekday column to trips, then backfill it from day (a YYYY-MM-DD string) with SQLite's strftime('%w', day), which gives '0' for Sunday up to '6' for Saturday. Store it as an integer with CAST(... AS INTEGER).

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE trips (id INTEGER PRIMARY KEY, name TEXT, day TEXT)")
db.executemany("INSERT INTO trips (name, day) VALUES (?, ?)", [("Fern Hollow loop", "2025-06-14"), ("Red Mesa climb", "2025-06-22")])

MIGRATION_4 = [
    "ALTER TABLE trips ADD COLUMN weekday INTEGER",
]
for sql in MIGRATION_4:
    db.execute(sql)
print(db.execute("SELECT name, day, weekday FROM trips").fetchall())
In the lesson, your code is checked as you write it.
Worked example

Worked example: what migration tools do, and don't

In Python projects the usual tool is Alembic, which works with SQLAlchemy models. This sketch shows the shape of what it generates; it doesn't run here, since Alembic isn't installed in the academy.

"""add region to members

Revision ID: 3f2a91c4e7b0
Revises: 9b1d55ae2c10
"""
from alembic import op
import sqlalchemy as sa

revision = "3f2a91c4e7b0"
down_revision = "9b1d55ae2c10"   # the migration this one follows

def upgrade():
    op.add_column("members", sa.Column("region", sa.String(), nullable=True))
    op.execute("UPDATE members SET region = 'unknown' WHERE region IS NULL")

def downgrade():
    op.drop_column("members", "region")

The ideas are the ones you just built. Each file is one migration, linked to the one before by down_revision instead of a plain number. When two developers add migrations on different branches, both point at the same parent, and Alembic sees two "heads": it refuses to upgrade until someone adds a merge revision (alembic merge), a clear stop rather than a silent clash. alembic upgrade head is your migrate(), and downgrade() undoes a step (on SQLite, Alembic's "batch" mode rebuilds tables for changes SQLite can't make in place). Its autogenerate compares your models with the database and writes a draft for you, and you must read every draft: it sees a renamed column as one column dropped and another added (losing the data), it can't write backfills, and it misses some changes entirely. Treat it as a typist, not a reviewer, and test migrations against a copy of real data before production sees them. And in practice most teams roll forward: a downgrade that drops a column destroys its data, so the usual fix for a bad migration is another migration.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Why does each migration run in its own transaction, together with the version update?

  1. A failure leaves the database as it was before that migration, ready to retry
  2. Transactions make migrations run faster by batching their changes
  3. SQLite refuses ALTER TABLE unless it runs inside a transaction
  4. So two migrations can safely run at the same time on one database

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson