Alembic zapisuje zmiany schematu bazy jako kolejne pliki migracji, które możesz stosować (`upgrade`) i cofać (`downgrade`). Żeby `--autogenerate` nie tworzył pustych migracji, w `env.py` ustaw `target_metadata = Base.metadata` i zaimportuj modele. Na SQLite włącz też `render_as_batch=True`, bo ta baza nie obsługuje części poleceń `ALTER`.
W artykule o SQLAlchemy 2 w FastAPI tabele powstają przez create_all. To działa, dopóki baza jest pusta. Gdy dodasz kolumnę do modelu, create_all jej nie dopisze, a usunięcie pliku bazy oznacza utratę danych. Do bezpiecznej zmiany schematu służą migracje, a w świecie SQLAlchemy robi to Alembic.
Ten temat omawiamy na kursie Python online, w module o bazach danych i sesjach. Poniżej przechodzisz cały cykl na projekcie z historią rozmów: konfiguracja, pierwsza migracja, zmiany modelu i typowe błędy. Wszystko uruchomiłem lokalnie (Python 3.13, Alembic 1.20, SQLAlchemy 2.1, aiosqlite 0.22).
Czym jest migracja bazy danych i po co Alembic?
Migracja to plik opisujący jedną zmianę schematu, na przykład „dodaj kolumnę archived". Każda ma swój identyfikator i wskazuje poprzednią, więc razem tworzą historię. Alembic zapamiętuje w bazie, w tabeli alembic_version, na której migracji skończył. Dzięki temu każda kopia bazy (Twój komputer, serwer testowy, produkcja) dochodzi do tego samego schematu tymi samymi krokami.
| Zadanie | Bez migracji | Z Alembikiem |
|---|---|---|
| Dodanie kolumny | Kasujesz bazę albo piszesz ALTER TABLE ręcznie | Jedna migracja, stosowana wszędzie tak samo |
| Cofnięcie zmiany | Z pamięci lub z kopii zapasowej | alembic downgrade -1 |
| Sprawdzenie, co jest w bazie | Zgadujesz | alembic current |
| Praca w zespole | Każdy ma inny schemat | Wspólna historia migracji w repozytorium |
Jak zainstalować Alembic i skonfigurować go dla async?
pip install alembic
alembic init -t async migrations
Opcja -t async tworzy env.py przygotowany pod asynchroniczny silnik, czyli taki jak sqlite+aiosqlite. Powstają: alembic.ini (konfiguracja), katalog migrations/ z env.py oraz migrations/versions/ na pliki migracji.
W alembic.ini wskaż bazę:
sqlalchemy.url = sqlite+aiosqlite:///app.db
W migrations/env.py zastąp linię target_metadata = None tym kodem:
from db import Base
import models # noqa: F401
target_metadata = Base.metadata
Base.metadata to spis wszystkich tabel, które znają Twoje modele. Alembic porównuje go z rzeczywistą bazą i na tej podstawie wylicza różnice.
Dlaczego autogenerate tworzy pustą migrację?
To najczęstszy problem początkujących i wystarczy jeden brakujący wiersz. Odtworzyłem go celowo. Gdy zostawisz target_metadata = None, Alembic przynajmniej głośno protestuje: Can't proceed with --autogenerate option; environment script env.py does not provide a MetaData object.
Gorzej, gdy ustawisz target_metadata = Base.metadata, ale zapomnisz o import models. Wtedy alembic revision --autogenerate kończy się sukcesem, a plik wygląda tak:
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass
Przyczyna: klasy modeli rejestrują się w Base.metadata dopiero w chwili, gdy Python je zaimportuje. Jeśli env.py ich nie importuje, metadane są puste, a puste metadane i pusta baza niczym się nie różnią.
Co sprawdzić, gdy migracja jest pusta:
- Czy w
env.pyjestimport models(albo import każdego pliku z modelami, jeśli masz ich kilka)? - Czy
target_metadatawskazuje to samoBase, po którym dziedziczą modele? - Czy baza z
sqlalchemy.urljest tą, którą chcesz porównywać? Jeśli tabele już w niej są, a modele się z nimi zgadzają, pusta migracja jest prawidłowa.
Przy kilku plikach z modelami wystarczy jeden moduł, który je wszystkie importuje (np. models/__init__.py), i jeden import models w env.py.
Jak wygenerować i zastosować pierwszą migrację?
alembic revision --autogenerate -m "tabele startowe"
alembic upgrade head
Pierwsze polecenie tworzy plik w migrations/versions/. Zajrzyj do niego przed uruchomieniem, bo to Ty odpowiadasz za jego treść. W moim projekcie powstały trzy op.create_table(...) dla users, conversations i messages. Drugie polecenie stosuje migracje aż do najnowszej (head). Po nim w bazie są Twoje tabele oraz alembic_version z identyfikatorem ostatniej migracji.
Od teraz usuń create_all z lifespan. Schematem zarządza wyłącznie Alembic.
Jak dodać kolumnę do tabeli, w której są już dane?
Dodaj do modelu Conversation pole archived: Mapped[bool] = mapped_column() i wygeneruj migrację. Alembic zapisze:
op.add_column('conversations', sa.Column('archived', sa.Boolean(), nullable=False))
Przy tabeli, w której już jest wiersz, alembic upgrade head kończy się błędem:
sqlite3.OperationalError: Cannot add a NOT NULL column with default value NULL
Baza nie wie, co wpisać w nowej kolumnie dla istniejących wierszy. Rozwiązaniem jest wartość domyślna na poziomie bazy, czyli server_default:
op.add_column(
'conversations',
sa.Column('archived', sa.Boolean(), nullable=False, server_default=sa.false()),
)
Po tej poprawce istniejąca rozmowa dostała archived = 0. Warto też dopisać server_default do modelu, żeby kolejny autogenerate nie widział różnicy. Migracja wygenerowana automatycznie to szkic. Czytaj ją i poprawiaj, zanim ją zastosujesz.
Czym jest tryb batch i dlaczego SQLite go potrzebuje?
SQLite obsługuje tylko część poleceń ALTER TABLE. Zmiana typu kolumny albo zdjęcie NOT NULL kończy się błędem składni. Sprawdziłem na zmianie title z VARCHAR(200) NOT NULL na VARCHAR(300) z dopuszczeniem NULL:
sqlite3.OperationalError: near "ALTER": syntax error
[SQL: ALTER TABLE conversations ALTER COLUMN title DROP NOT NULL]
Alembic ma na to tryb batch: tworzy nową tabelę o poprawionym schemacie, kopiuje do niej dane, usuwa starą i zmienia nazwę nowej. W env.py włącz go w funkcji do_run_migrations:
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
Od tej pory autogenerate opakowuje zmiany w batch_alter_table:
with op.batch_alter_table('conversations', schema=None) as batch_op:
batch_op.alter_column(
'title',
existing_type=sa.VARCHAR(length=200),
type_=sa.String(length=300),
nullable=True,
)
Po upgrade head tabela ma nowy schemat, a mój testowy wiersz został na miejscu. Ustaw render_as_batch=True od pierwszej migracji, bo później łatwo o tym zapomnieć.
Jedno ograniczenie, na które się natknąłem: migracje w trybie batch nie działają z opcją --sql (generowanie samego skryptu SQL bez bazy), bo Alembic musi odczytać tabelę z żywej bazy.
Jak cofnąć migrację?
alembic downgrade -1
Polecenie cofa jedną migrację. Po cofnięciu zmiany title kolumna wróciła do VARCHAR(200) NOT NULL, a alembic current pokazał poprzednią wersję. Komenda alembic upgrade head znowu przesuwa bazę do przodu. Przydatne polecenia pomocnicze:
| Polecenie | Co robi |
|---|---|
alembic current | Pokazuje wersję, na której jest baza |
alembic history | Wypisuje całą historię migracji |
alembic downgrade -1 | Cofa ostatnią migrację |
alembic upgrade head | Stosuje wszystkie brakujące migracje |
alembic check | Sprawdza, czy modele zgadzają się z bazą po wszystkich migracjach |
downgrade jest tak dobry, jak funkcja downgrade() w pliku migracji. Autogenerate ją wypełnia, ale nie przywróci danych usuniętej kolumny.
Dlaczego Alembic nie rozpoznaje zmiany nazwy kolumny?
Nie rozpoznaje, i to jest pułapka, która kosztuje dane. Zmieniłem w modelu email na mail i uruchomiłem autogenerate. Zamiast zmiany nazwy dostałem:
batch_op.add_column(sa.Column('mail', sa.String(length=255), nullable=False))
batch_op.create_unique_constraint(None, ['mail'])
batch_op.drop_column('email')
Dla Alembica to dwie niezależne zmiany: nowa kolumna i usunięta stara. Po zastosowaniu takiej migracji wszystkie adresy e-mail by zniknęły. Przy zmianie nazwy zawsze popraw migrację ręcznie na batch_op.alter_column('email', new_column_name='mail'). Ogólna zasada: każde drop_column i drop_table w wygenerowanym pliku traktuj jak alarm i sprawdź, czy to na pewno miało zniknąć.
Skąd się bierze ModuleNotFoundError w env.py?
Alembic wykonuje env.py jak zwykły skrypt, więc from db import Base zadziała tylko wtedy, gdy Python widzi katalog z Twoim kodem. Domyślnie alembic.ini ma prepend_sys_path = ., czyli „bieżący katalog". Uruchom alembic z innego miejsca niż katalog projektu, a dostaniesz:
ModuleNotFoundError: No module named 'db'
Rozwiązanie: uruchamiaj polecenia z katalogu, w którym leży alembic.ini, albo zmień ustawienie tak, żeby nie zależało od katalogu bieżącego:
prepend_sys_path = %(here)s
%(here)s oznacza katalog pliku alembic.ini. Po tej zmianie alembic -c projekt/alembic.ini current zadziałał z dowolnego katalogu.
Co zrobić, gdy baza powstała przez create_all, a migracje dodajesz później?
Sytuacja z życia: aplikacja żyje już na create_all, więc tabele istnieją. Dodajesz Alembic, generujesz pierwszą migrację z create_table i uruchamiasz upgrade head. Wynik:
sqlite3.OperationalError: table users already exists
Migracja próbuje stworzyć to, co już jest. Wystarczy oznaczyć bazę jako będącą na tej wersji, bez wykonywania zmian:
alembic stamp head
Po tym alembic current pokazuje head, a kolejne migracje będą już stosowane normalnie. Zanim to zrobisz, upewnij się, że modele zgadzają się z bazą. Pomoże alembic check: gdy znajdzie różnice, kończy się komunikatem New upgrade operations detected i listą zmian (w moim teście: brakująca kolumna nick). W CI możesz to dopisać jako krok, który wyłapuje zmianę modelu bez migracji.
Jak wygląda bezpieczna praca z migracjami na co dzień?
- Zmieniasz model.
- Generujesz migrację:
alembic revision --autogenerate -m "opis zmiany". - Czytasz plik: nowe kolumny
NOT NULLmająserver_default, nic nie jest usuwane przez przypadek, zmiana nazwy nie jest parą add i drop. - Stosujesz:
alembic upgrade head, a w razie potrzeby sprawdzaszdowngrade -1. - Plik migracji commitujesz razem ze zmianą modelu.
- Na serwerze
upgrade headuruchamiasz przed startem aplikacji, a przed większą zmianą robisz kopię pliku bazy.
Jakie błędy mentor poprawia najczęściej?
Najczęstszy błąd przy pierwszych migracjach to wiara w autogenerate. Alembic wylicza różnice, ale nie wie, co chciałeś zrobić: zmianę nazwy kolumny zamienia w „dodaj nową i usuń starą”, czyli w kasowanie danych. Zanim uruchomisz upgrade, przeczytaj plik migracji i każde drop_column sprawdź ręcznie.
Co dalej?
Masz schemat, który potrafi się zmieniać razem z aplikacją. W następnym artykule sprawdzimy, czy warstwa danych naprawdę działa: napiszemy testy w pytest, z bazą w pamięci i wycofywaniem zmian po każdym teście. Pamiętaj o dwóch rzeczach z tego artykułu: migrację zawsze czytasz, zanim ją uruchomisz, a kolumnę usuwasz dopiero wtedy, gdy jesteś pewien, że dane nie są już potrzebne.
Wszystkie te zagadnienia omówimy na przyszłym kursie AI developer, który niedługo ogłosimy. Śledź listę naszych ścieżek, żeby nie przegapić startu.

