No-Fail Guide: Getting Started with Database Migrations (FastAPI × SQLAlchemy × Alembic)
✅ Summary (Conclusion & Big Picture First)
- What you can do with this article
Learn how to safely, reproducibly, and team-friendly apply schema changes (migrations) to the database used in your FastAPI app. Define models in SQLAlchemy and use Alembic for history tracking, automatic diff generation, and rollbacks — with concrete, production-ready implementation steps. - Main Steps
- Prepare project structure → 2) Define SQLAlchemy ORM models → 3) Initialize Alembic → 4) Autogenerate revisions → 5)
upgrade/downgrade→ 6) Avoid pitfalls (naming conventions, merge conflicts, SQLite quirks)
- Prepare project structure → 2) Define SQLAlchemy ORM models → 3) Initialize Alembic → 4) Autogenerate revisions → 5)
- Target DB
Start development with SQLite and later migrate to PostgreSQL or MySQL with minimal friction. - Benefits
- Prevent “Oops, I broke it with a manual ALTER…”
- Keep change history so your team can reproduce the same state anytime
- Build a stable workflow that fits easily into CI/CD
🎯 Who Will Benefit (Examples)
- Solo developer (college junior, first web project)
Building a ToDo app in SQLite. Wants to safely add “due date” or “priority” columns later. Copy-paste runnable commands and see the changes visually (history). - Small team (3-person contract dev)
Specs change weekly, DB changes conflict often, and colleagues can’t reproduce each other’s local DB states. Needs naming conventions, history tracking, rollbacks for zero-accident operations. - Startup planning to scale
Start with SQLite, then migrate to PostgreSQL as users grow. Learn a robust flow centered on autogenerated diffs for smooth scaling.
1. Preparation: Minimal Project Layout & Installation
1.1 Directory Structure
fastapi-db/
├─ app/
│ ├─ main.py
│ ├─ db.py
│ ├─ models.py
│ └─ schemas.py
├─ alembic/ # Generated by Alembic (after init)
├─ alembic.ini # Alembic config
└─ .env # DATABASE_URL, etc. (optional)
1.2 Required Packages
python3 -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn sqlalchemy alembic pydantic "psycopg[binary]" # For future PostgreSQL
# For SQLite only, psycopg is not needed
Term: Migration = A system for programmatically tracking DB schema changes (tables, columns, constraints, etc.) so you can
upgrade(apply) ordowngrade(revert) safely and repeatedly.
1.3 Choosing the Database URL (SQLite is fine for dev)
- Development (local):
sqlite:///./app.db - Production (PostgreSQL example):
postgresql+psycopg://USER:PASSWORD@HOST:PORT/DBNAME
Keep it in .env for easy switching.
Key Takeaways
- Set up structure first → install dependencies
- Switch DB URL via environment variables, start with SQLite
2. Define SQLAlchemy ORM Models (2.x style for future-proofing)
2.1 db.py: Engine & Session (sync version)
Alembic works best with a sync engine (even if your app is async).
# app/db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
DATABASE_URL = "sqlite:///./app.db"
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
2.2 models.py: Declarative Base & Naming Conventions
Naming conventions keep autogenerated diffs stable — very important.
# app/models.py
from datetime import datetime
from sqlalchemy import String, DateTime, func, MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
naming_convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=naming_convention)
class Todo(Base):
__tablename__ = "todos"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(String(1000))
is_done: Mapped[bool] = mapped_column(default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
Tip:
server_default=func.now()uses DB defaults — works in SQLite and carries over naturally to PostgreSQL.
2.3 Let Alembic Handle Initial Table Creation
Do not call Base.metadata.create_all() directly.
Always create tables via Alembic upgrade so history stays consistent.
Key Takeaways
- Use SQLAlchemy 2.x type-hinted ORM for a solid future
- Naming conventions reduce diff “noise”
- From the start, let Alembic control schema creation
3. Initialize Alembic & Link App Metadata
3.1 Initialize
alembic init alembic
Generates alembic/ and alembic.ini.
3.2 Edit alembic.ini (DB URL)
Leave sqlalchemy.url commented; load from env vars to avoid accidental prod changes.
3.3 alembic/env.py: Point to App’s metadata
Pass your app’s Base.metadata to target_metadata so --autogenerate works.
# alembic/env.py (excerpt)
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from app.models import Base
target_metadata = Base.metadata
def get_url():
url = os.getenv("DATABASE_URL")
if url:
return url
return config.get_main_option("sqlalchemy.url")
def run_migrations_offline():
context.configure(
url=get_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
{"sqlalchemy.url": get_url()},
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()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
Key Takeaways
- Set
target_metadatato yourBase.metadata- Enable
compare_type/compare_server_default- Prefer env var for URL
4. Create & Apply First Migration (Autogenerate)
4.1 Create Initial Revision
alembic revision --autogenerate -m "create todos table"
Check the generated file in alembic/versions/ — ensure the DDL matches your intent.
4.2 Apply to DB
alembic upgrade head
SQLite now has the todos table.
From here, every schema change is tracked in history.
4.3 Try a Rollback
alembic downgrade -1
alembic upgrade head
Always test
upgrade/downgradelocally before production.
Key Takeaways
--autogenerateis not magic — review the script- Test round-trip migrations for safety
