Миграции Alembic в проекте на FastAPI: рабочие приёмы
Alembic в связке с FastAPI и SQLAlchemy решает задачу миграций, но на практике ломается на autogenerate, ветвлениях и данных. Разбираем приёмы, которые спасают от типичных проблем в реальных проектах.

Почему «alembic revision --autogenerate» — только начало
Стандартная связка FastAPI + SQLAlchemy + Alembic покрывает девяносто процентов потребностей в миграциях. Проблемы начинаются в оставшихся десяти: autogenerate не замечает изменения типов колонок, путает порядок зависимостей между таблицами, игнорирует изменения в Enum и не трогает данные. Если в команде больше одного человека, рано или поздно две ветки порождают конфликт миграций, который alembic merge решает формально, но не по смыслу.
Ниже — набор приёмов, которые закрывают эти дыры. Всё проверено на Alembic 1.13.x и SQLAlchemy 2.0.x; если у вас более ранние версии, поведение может отличаться.
Правильная структура env.py
Главная точка, где Alembic встречается с FastAPI-проектом, — файл alembic/env.py. По умолчанию он не знает о ваших моделях. Задача — импортировать Base.metadata так, чтобы autogenerate видел все таблицы, и подставить URL базы из конфигурации приложения, а не дублировать его в alembic.ini.
# alembic/env.py (ключевые фрагменты, Alembic 1.13, SQLAlchemy 2.0)
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Импорт метаданных из вашего проекта
from app.database import Base # Base = declarative_base()
from app.models import * # noqa: F401 — чтобы все модели зарегистрировались в metadata
target_metadata = Base.metadata
# URL берём из переменной окружения, а не из alembic.ini
def get_url() -> str:
return os.environ.get("DATABASE_URL", "postgresql://localhost/mydb")
def run_migrations_offline() -> None:
context.configure(
url=get_url(),
target_metadata=target_metadata,
literal_binds=True,
compare_type=True, # ← без этого изменение VARCHAR(100) → VARCHAR(255) не замечается
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
configuration = context.config.get_section(context.config.config_ini_section, {})
configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
Два ключевых флага — compare_type=True и compare_server_default=True. Без первого Alembic молча пропустит смену типа колонки; без второго — не увидит, что вы добавили или убрали server_default.
Если autogenerate создал пустую миграцию, а вы точно меняли модель — в девяти случаях из десяти дело в том, что модуль с моделью не импортирован в env.py. Проверяйте target_metadata.tables: там должен быть полный список таблиц.
Что autogenerate не замечает
Autogenerate — мощный инструмент, но у него есть задокументированные слепые пятна. Знать их — значит не получить рассинхрон между моделями и базой, который всплывёт в проде.
| Изменение | Видит autogenerate? | Что делать |
|---|---|---|
| Добавление / удаление таблицы | Да | — |
| Добавление / удаление колонки | Да | — |
| Изменение типа колонки | Только с compare_type=True | Включить флаг в env.py |
| Переименование таблицы или колонки | Нет — видит как удаление + создание | Писать op.rename_table() / op.alter_column() вручную |
| Изменение значений Enum | Нет | Ручная миграция с ALTER TYPE ... ADD VALUE |
| Изменение данных (data migration) | Нет | Отдельная миграция с op.execute() |
| Изменение индексов с условиями (partial index) | Частично | Проверять сгенерированный код |
Правило простое: после каждого alembic revision --autogenerate открывайте файл миграции и читайте его целиком. Автогенерация — черновик, а не готовый результат.
Миграции данных: отдельный файл, отдельный подход
Смешивать DDL-изменения (структура) и DML-изменения (данные) в одной миграции — путь к проблемам с откатом. Если downgrade откатывает структуру, восстановить удалённые или преобразованные данные он не сможет.
Приём: разносить по двум файлам
- Первый файл — добавляет новую колонку с
nullable=True. - Второй файл — заполняет колонку данными через
op.execute()и ставитnullable=False.
Это даёт точку отката между структурой и данными. Если заполнение упало, структура на месте — повторите только второй шаг.
# migrations/versions/0002_populate_slug.py
"""Заполнить slug из name"""
from alembic import op
import sqlalchemy as sa
def upgrade() -> None:
# Генерируем slug из name для существующих строк
op.execute(
"""
UPDATE product
SET slug = lower(replace(name, ' ', '-'))
WHERE slug IS NULL
"""
)
op.alter_column("product", "slug", nullable=False)
def downgrade() -> None:
op.alter_column("product", "slug", nullable=True)
Обратите внимание: downgrade не пытается восстановить старые данные — только возвращает колонку к nullable=True. Это честнее, чем делать вид, что данные можно откатить.
Конфликты веток: merge heads без сюрпризов
Два разработчика создали миграции из одного head. Alembic при попытке upgrade скажет: "Multiple head revisions are present". Стандартный ход — alembic merge heads, который создаёт пустую merge-миграцию.
Где это ломается
Merge формально соединяет графы, но не проверяет, совместимы ли изменения по смыслу. Если обе ветки меняли одну таблицу — добавляли колонку с одинаковым именем, но разным типом, или обе ставили NOT NULL с разным default — merge пройдёт, а upgrade упадёт на базе.
Практика, которая работает:
- Перед merge прочитайте обе миграции. Сравните, какие таблицы затронуты.
- Если конфликт по данным — создайте после merge ещё одну миграцию, которая приводит базу в согласованное состояние.
- В CI добавьте проверку на количество heads. Если больше одного — сборка красная.
# Команда для CI: проверка, что head ровно один
alembic heads | wc -l
# Ожидаемый результат: 1
# Если больше — merge не сделан, блокируем деплой
Миграции в CI/CD: чек-лист
Миграция, которая работает на локальной SQLite, но падает на PostgreSQL в проде — классика. Вот минимальный набор проверок, который ловит проблемы до деплоя:
- Запуск на той же СУБД, что и прод. Если прод на PostgreSQL — тестируйте на PostgreSQL, а не на SQLite. Типы, синтаксис, поведение транзакций отличаются существенно.
alembic upgrade headна пустой базе — проверяет, что вся цепочка миграций от начала до конца проходит без ошибок.alembic downgrade -1иupgrade head— проверяет, что последняя миграция корректно откатывается.- Проверка количества heads — не больше одного (см. выше).
- Проверка autogenerate на чистоту: после
upgrade headзапуститеalembic revision --autogenerate, и если файл не пустой, значит модели и миграции разъехались.
Пятый пункт особенно полезен: он ловит случаи, когда разработчик поменял модель, но забыл сгенерировать миграцию.
Именование миграций и порядок в каталоге
По умолчанию Alembic создаёт файлы с хешами вроде 3a8bc1d4e5f6_add_user_table.py. На третьем десятке миграций каталог превращается в кашу. Два приёма, которые наводят порядок:
Числовой префикс
В alembic.ini задайте шаблон имени файла:
# alembic.ini
file_template = %%(epoch)s_%%(slug)s
Файлы будут сортироваться по времени создания. Вместо epoch можно использовать rev — четырёхзначный номер ревизии, если настроить его генерацию.
Осмысленные сообщения
Сообщение миграции — это её документация. "update models" не говорит ни о чём. Формат, который работает: действие + объект + причина.
"add slug column to product for SEO URLs""drop legacy payment_status, replaced by order_state""populate user.display_name from first_name + last_name"
Где подход ломается
Alembic хорошо работает, когда миграции линейны и каждая среда проходит одну цепочку от начала до текущего head. Проблемы начинаются в специфических сценариях:
- Мультитенант с отдельными схемами — Alembic по умолчанию работает с одной схемой. Для нескольких нужен кастомный
env.pyс циклом по схемам. - Десятки микросервисов с общей базой — конфликты между миграциями разных сервисов Alembic не контролирует. Здесь стоит разносить сервисы по отдельным схемам или базам.
- Огромные таблицы (сотни миллионов строк) —
ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT ...может заблокировать таблицу на минуты в PostgreSQL версий ниже 11. Начиная с PostgreSQL 11 добавление колонки с константным default не перезаписывает строки, но другие операции (изменение типа) по-прежнему блокируют.
Что делать дальше
Настройте пятый пункт из чек-листа CI — проверку autogenerate на чистоту. Это единственная автоматическая гарантия, что модели и база не разъедутся после очередного merge request. Если проект растёт — изучите Alembic Operations Plugins для кастомных операций и посмотрите на раздел документации Alembic по работе с несколькими базами ("Working with Multiple Databases"). Следите за changelog SQLAlchemy 2.x: поведение autogenerate активно дорабатывается, и новые релизы могут начать замечать то, что раньше пропускали.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Alembic — официальная документация, SQLAlchemy 2.0 — What's New
Частые вопросы
Autogenerate создал пустую миграцию, хотя я менял модель. В чём причина?
Можно ли использовать SQLite для тестирования миграций, если прод на PostgreSQL?
Что будет, если я забуду сделать alembic merge при двух heads и задеплою?
Как откатить миграцию данных, если она удалила или преобразовала строки?
Работают ли эти приёмы с async-движком SQLAlchemy?
Миграция прошла локально, но падает в проде с ошибкой блокировки. Что делать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.