Chapter 57, Backend
Alembic: database migrations
The database schema evolves along with the code. The migration is the versioned history of this evolution, applicable in any environment in a repeatable way.
The files for this chapter are in exemplos/api_pedidos/.
The problem
You add a column to the model. Your local database does not have this column, the test one neither, and the production one even less. Changing it manually in each environment is a sure mistake. The Alembic keeps each change as a versioned migration file, which knows to upgrade (upgrade) and to downgrade (downgrade) the schema, and records in a table of the database itself which version is applied.
Configuration
The alembic.ini points to the migrations folder. The env.py is the heart: it connects Alembic to your models and to the same database configuration that the application uses, so that migration and API never diverge in URL:
[alembic]
script_location = %(here)s/migrations
prepend_sys_path = src
path_separator = os
file_template = %%(rev)s_%%(slug)s
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from api_pedidos.config import obter_configuracao
from api_pedidos.modelos import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
# A URL vem da configuração da aplicação, a mesma que a API usa. O "%" precisa ser escapado
# porque o ConfigParser do Alembic trata "%" como caractere especial.
url = obter_configuracao().database_url.get_secret_value()
config.set_main_option("sqlalchemy.url", url.replace("%", "%%"))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Gera o SQL sem conectar ao banco (alembic upgrade head --sql)."""
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as conexao:
context.configure(connection=conexao, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
The target_metadata = Base.metadata is what allows the --autogenerate to compare your models with the real database.
Create a migration
The --autogenerate compares the models with the database and writes the migration for you. It only generates the draft, and review is mandatory:
uv run alembic revision --autogenerate -m "criar pedidos" --rev-id 0001
INFO [alembic.autogenerate.compare.tables] Detected added table 'pedidos'
INFO [alembic.autogenerate.compare.constraints] Detected added index 'ix_pedidos_cliente' on '('cliente',)'
INFO [alembic.autogenerate.compare.tables] Detected added table 'itens_pedido'
Generating migrations/versions/0001_criar_pedidos.py ... done
The generated file, after my review (Alembic writes comments and old typing that I clean up):
"""criar pedidos
Revision ID: 0001
Revises:
Create Date: 2026-10-06 15:46:53
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"pedidos",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("cliente", sa.String(length=120), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("chave_idempotencia", sa.String(length=80), nullable=True),
sa.Column("criado_em", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("chave_idempotencia"),
)
op.create_index("ix_pedidos_cliente", "pedidos", ["cliente"], unique=False)
op.create_table(
"itens_pedido",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("pedido_id", sa.Integer(), nullable=False),
sa.Column("produto", sa.String(length=120), nullable=False),
sa.Column("quantidade", sa.Integer(), nullable=False),
sa.Column("preco_centavos", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["pedido_id"], ["pedidos.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("itens_pedido")
op.drop_index("ix_pedidos_cliente", table_name="pedidos")
op.drop_table("pedidos")
Apply, check and undo
export API_DATABASE_URL="postgresql+psycopg://notes:notes@localhost:5432/notes_mig"
uv run alembic upgrade head
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> 0001, criar pedidos
PostgreSQL executes schema changes within a transaction (transactional DDL), so a migration that fails in the middle is undone entirely. This is one of the reasons why I prefer it. The commands you use all the time:
uv run alembic current
uv run alembic check
uv run alembic downgrade base
uv run alembic upgrade head --sql
0001 (head)
The check fails if the models changed without a corresponding migration (it says No new upgrade operations detected. when everything is synchronized), and the --sql prints the SQL without executing anything, useful for review by those who manage the database.
Changes that affect data
Add a column NOT NULL to a table that already has rows would fail, because the existing rows have no value. The safe pattern has three steps in the same migration: add the column accepting null, fill the existing rows, and only then make it mandatory:
def upgrade() -> None:
op.add_column("pedidos", sa.Column("canal", sa.String(length=20), nullable=True))
op.execute("UPDATE pedidos SET canal = 'web' WHERE canal IS NULL")
op.alter_column("pedidos", "canal", existing_type=sa.String(length=20), nullable=False)
def downgrade() -> None:
op.drop_column("pedidos", "canal")
To deploy without downtime the pattern is called expand and contract: first a migration that only adds (compatible with the old code), then the deployment of the new code, and only in a later migration the removal of what became obsolete. Never rename or delete a column in the same version where the code stops using it.
Rules I don't break
Never edit a migration that has already been applied in another environment: create a new one. Keep a single
head(two migration lines in parallel need to be unified withalembic merge). The autogenerate does not detects column renaming: it sees a removal and a creation, which would delete the data. To rename, writeop.alter_column(..., new_column_name=...)by hand.
In SQLite, altering a table is different
SQLite cannot alter existing columns. Alembic offers the batch mode (
render_as_batch=True), which recreates the table. That's why I run migrations against PostgreSQL, the same database as production, and avoid having SQLite as a "test database" for migrations.