12.13. Database Migrations
Table of Contents
Why Database Migrations Matter
When you start a new backend project, the database schema feels simple. You create a few tables, run some CREATE TABLE statements, and everything works.
Then the application grows.
You add new features, change how data is stored, rename columns, split tables, or remove fields that are no longer used. In production you already have real data that you must not lose.
This is where database migrations become essential.
A database migration is a controlled change to your database schema, stored as code, that you can apply, track, and if needed, roll back.
In this chapter, we focus on what migrations are, why they exist, and how they are typically used with ORMs. Detailed usage of Alembic itself is covered later in the Alembic chapter, so here we stay at the conceptual and practical level.
The Core Idea of Migrations
A database schema is not static. It evolves with your code.
If you treat the schema as something you edit manually in a database console, you quickly run into problems:
- You forget which changes you made locally.
- Your teammates have different schemas from you.
- Production, staging, and local environments drift apart.
- Rolling back a bad change is hard and dangerous.
Migrations solve this by:
- Representing every schema change as versioned migration files.
- Applying these files in order to the target database.
- Recording which migrations have been applied.
You can think of it as Git for your database schema.
- Git tracks changes to files over time.
- Migrations track changes to the database over time.
Schema-as-Code vs Manual Changes
Without migrations, you might do something like:
ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE;You run this directly in your production database console. It works, but:
- You have no record of when and why you added it.
- Other environments do not automatically get the same change.
- You cannot safely recreate the database schema elsewhere.
With migrations, you instead:
- Create a migration file (manually or auto generated).
- Put the
ALTER TABLEstatement inside that file. - Commit the migration file to version control.
- Run a migration command in each environment to apply it.
Now the change is:
- Reproducible.
- Shared with the whole team.
- Traceable via Git history.
Typical Migration Workflow with an ORM
Most ORMs support a similar workflow, even though tools differ.
Here is a common pattern:
- Define or update models in code
For example, using SQLAlchemy models:
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, unique=True, nullable=False)
is_active = Column(Boolean, default=True)- Generate a migration
A migration tool (like Alembic) compares the current database schema with the models, and generates a migration file, for example:
def upgrade():
op.create_table(
'users',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('email', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
)
def downgrade():
op.drop_table('users')- Apply the migration
You run a command that applies new migrations to your database:
alembic upgrade head
The database now has the users table.
- Evolve the schema
Later you decide to add a full_name column:
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, unique=True, nullable=False)
full_name = Column(String, nullable=True) # new
is_active = Column(Boolean, default=True)You create a new migration that contains something like:
def upgrade():
op.add_column('users', sa.Column('full_name', sa.String(), nullable=True))
def downgrade():
op.drop_column('users', 'full_name')
Then you apply it again with upgrade.
The important thing here is that your schema changes are synchronized with your code and every change is captured in a migration.
Versioning and Migration History
Migrations are usually stored as files, each with a unique identifier.
A typical directory might look like:
migrations/
20230915_01_create_users_table.py
20230920_02_add_full_name_to_users.py
20231001_03_create_products_table.pyInside the database, a special table keeps track of which migrations have run, for example:
| version_id | applied_at |
|---|---|
| 20230915_01_create_users... | 2023-09-15 10:15:22 |
| 20230920_02_add_full_name.. | 2023-09-20 17:02:10 |
This lets your migration tool know:
- Which migrations are already applied.
- Which new ones must be applied.
- How to roll back to a previous version.
Always apply migrations in sequence and never manually edit previously applied migrations in a shared project. If you change an old migration that has already run in production, you risk corrupting the schema or losing data.
Upgrades and Downgrades
Each migration typically has two main operations:
- Upgrade: Move the schema from the previous version to the new version.
- Downgrade: Reverse that change.
Example, adding a column:
def upgrade():
op.add_column("users", sa.Column("full_name", sa.String(), nullable=True))
def downgrade():
op.drop_column("users", "full_name")Example, renaming a column:
You might simulate renaming by using ALTER TABLE operations appropriate to your database.
For PostgreSQL via Alembic operations:
def upgrade():
op.alter_column("users", "username", new_column_name="login")
def downgrade():
op.alter_column("users", "login", new_column_name="username")Not all schema changes are easily reversible. For example, dropping a column will lose data.
In those cases:
- The
downgrademight be impossible. - Or it may just recreate the column but with no data.
You should design migrations carefully when data must be preserved.
Types of Schema Changes Managed by Migrations
Migrations typically handle:
| Change type | Examples |
|---|---|
| Creating / dropping | Create or drop tables, indexes, constraints |
| Modifying columns | Add, remove, rename, change type, change nullability |
| Constraints | Add or drop primary keys, foreign keys, unique constraints |
| Defaults | Add or change default values |
| Relationships | Add foreign keys for relationships |
| Data fixes | One time data updates or backfills |
A migration file is not only for structure. It can also include data migrations, where you transform existing rows.
Example, splitting a name column into first_name and last_name:
- Schema change:
def upgrade():
op.add_column("users", sa.Column("first_name", sa.String(), nullable=True))
op.add_column("users", sa.Column("last_name", sa.String(), nullable=True))- Data migration:
In the same migration you might, in pseudocode:
from sqlalchemy import table, column, String
from sqlalchemy.orm import Session
users_table = table(
"users",
column("id"),
column("name", String),
column("first_name", String),
column("last_name", String),
)
def upgrade():
# add columns first (schema)
op.add_column("users", sa.Column("first_name", sa.String(), nullable=True))
op.add_column("users", sa.Column("last_name", sa.String(), nullable=True))
bind = op.get_bind()
session = Session(bind=bind)
for user in session.execute(sa.select(users_table)).all():
if user.name and " " in user.name:
first, last = user.name.split(" ", 1)
else:
first, last = user.name, None
session.execute(
users_table.update()
.where(users_table.c.id == user.id)
.values(first_name=first, last_name=last)
)
session.commit()- Optionally drop the old
namecolumn in a later migration.
This combination of structure and data changes is powerful but also risky, so you must test migrations thoroughly.
Migrations in Different Environments
You usually have at least these environments:
- Local development
- Staging or testing
- Production
You want the same database schema in all of them.
Typical process:
- Create or modify models locally.
- Create a new migration file.
- Apply the migration locally.
- Run tests.
- Commit and push the migration.
- In CI or deployment scripts, run migrations automatically on the staging and production databases.
Example deployment command:
# Inside your deployment script
alembic upgrade headIf the migration fails in staging, you fix it before it ever touches production.
Never manually apply schema changes to production that are not represented by a migration file under version control. This leads to schema drift, which is very hard to fix later.
Common Migration Scenarios
Here are some common change scenarios and how migrations help.
Adding a new optional column
You add a phone_number column that is not required.
- Migration
upgrade:
op.add_column("users", sa.Column("phone_number", sa.String(), nullable=True))- Usually safe because existing rows can have
NULL.
Adding a new required column
You add a username column that must be unique and not null.
This is more complex because existing rows do not have a username yet.
A safer approach:
- Add the column as nullable, without a uniqueness constraint.
- Backfill data in a data migration.
- Add constraints and make it non nullable in a later migration.
Example sequence:
Migration 1:
def upgrade():
op.add_column("users", sa.Column("username", sa.String(), nullable=True))Migration 2:
def upgrade():
# backfill usernames, for example copy from email prefix
# (example, actual code depends on your ORM and tool)
passMigration 3:
def upgrade():
op.create_unique_constraint("uq_users_username", "users", ["username"])
op.alter_column("users", "username", nullable=False)This multi step approach reduces the risk of breaking production when data already exists.
Renaming a column
Some databases have a direct rename operation, others need a workaround.
If direct rename is not available, a typical pattern:
- Add new column.
- Copy data from old column to new.
- Update code to use new column.
- Drop old column in a later migration, after deployment is stable.
Changing column type
For example, from Integer to BigInteger or from String to Text.
You must:
- Check database restrictions.
- Ensure existing data fits the new type.
- Possibly use intermediate columns or casts.
Migrations give you a central place to implement and test such changes.
Auto Generated vs Manual Migrations
Many tools can auto generate migrations by comparing your ORM models with the actual database schema.
Pros:
- Faster to create.
- Less boilerplate.
Cons:
- The tool might not understand complex changes.
- You must always review generated code.
- Sometimes small model changes lead to large unexpected SQL changes.
A good approach:
- Run auto generation.
- Carefully inspect the migration file.
- Edit it if necessary.
- Add or refine
downgradelogic. - Write data migrations by hand if needed.
Example review questions:
- Will this operation lock the table for a long time?
- Is there any risk of data loss?
- Does this work on all target databases, not just your local one?
Migrations and Application Deployment
Migrations affect how you deploy your backend.
Safe pattern:
- Build new application version.
- Run migrations on the database.
- Start new application version.
Why run migrations before starting the new code?
Because the new code often expects the new schema. If the schema is not ready, the code might crash.
Sometimes you need backward compatible migrations. That means:
- The new schema must work with both old and new versions of the code for a short time.
- This allows zero downtime deployments where some servers run old code while others run new code.
Typical backward compatible sequence for a breaking change:
- Add new columns or tables without removing old ones.
- Update code to write to both old and new columns.
- Once all instances run new code, migrate data completely.
- In a later migration, remove old columns.
Migrations are a key part of safe, zero downtime deployment strategies.
Testing Migrations
You should test migrations, especially in larger systems.
Useful tests:
- Fresh install: Applying all migrations to an empty database works.
- Upgrade from previous version: Applying only new migrations to existing schema works.
- Downgrade (where possible): Downgrading and re upgrading yields the same schema.
- Data correctness: Data migrations produce expected results.
Example simple test flow:
# Start with empty test database
alembic upgrade head
# Run application tests
pytestSome projects also snapshot database schemas and compare them before and after migrations.
Practical Tips and Best Practices
Here are practical guidelines that help when working with migrations.
Keep migrations small and focused
Each migration should represent a clear, limited change.
Bad:
- One migration that creates 10 tables and changes 8 existing ones.
Better:
- Several migrations, each doing one thing: create a table, add a column, add an index.
This makes:
- Review easier.
- Debugging easier.
- Rollback less risky.
Never edit applied migrations in a shared project
If you already ran migration 20230915_01 on any shared database:
- Do not change its content.
- If you made a mistake, create a new migration that fixes it.
Editing old migrations breaks the logic of the migration history.
Use meaningful names
Name migrations by what they do, not just timestamps.
Compare:
20230915_01_add_users_table.py20230915_01_migration.py
The first is much easier to understand when scanning history.
Beware of destructive operations
Dropping tables or columns removes data forever.
Before you:
- Drop a table.
- Drop a column.
- Truncate data.
Make sure:
- You really do not need it.
- You have backups.
- You performed the same change in staging and confirmed everything works.
Consider long running operations
Some operations can lock large tables for a long time, for example:
- Adding a column with a non trivial default.
- Adding indexes on very large tables.
- Complex ALTER statements.
For big databases you might:
- Use concurrent index creation if the database supports it.
- Split the operation into smaller steps.
- Schedule heavy migrations during low traffic windows.
How Migrations Fit with the ORM Layer
Migrations and ORM models must stay synchronized.
- The ORM models describe the schema in code.
- Migrations describe how to move the actual database schema to match the models over time.
Typical checklist when changing models:
- Modify ORM models.
- Generate and inspect migration.
- Apply migration to your local database.
- Run tests.
- Commit models and migrations together.
If you change models but forget to update migrations:
- Your local environment might work if you recreate the database from scratch with
Base.metadata.create_all. - Other environments that rely on migrations will break.
So in a mature project:
- You avoid calling ORM methods that create tables directly in production.
- You rely exclusively on migrations to change the schema.
Summary
Database migrations are a foundational part of backend development with ORMs.
They let you:
- Treat your database schema as versioned code.
- Evolve the schema safely over time.
- Keep all environments in sync.
- Apply complex structural and data changes in a controlled way.
- Support safe deployment and rollback strategies.
In practice, you will create, review, and run migrations regularly. Mastering this workflow is essential for building reliable, maintainable backend systems.
Views: 7
KAHIBARO