🎯 Learning Objectives
- Understand the SQLAlchemy Core vs ORM distinction
- Define mapped classes with
DeclarativeBaseand column types - Create async engines and session factories with
asyncpg - Perform CRUD operations using
AsyncSessionandselect() - Define relationships: one-to-many, many-to-many with association tables
- Write and run Alembic migrations
- Integrate the async session as a FastAPI
yielddependency
SQLAlchemy Overview
SQLAlchemy ships two layers that you can use together or independently:
- Core — a SQL expression language that sits close to raw SQL but gives you Python composability and cross-database portability.
- ORM — maps Python classes to database tables, tracks object state, and manages relationships.
SQLAlchemy 2.0 unified the API — the ORM now uses the same select()
style as Core, eliminating the old session.query() split.
Async support is first-class via asyncpg (PostgreSQL) or
aiosqlite (SQLite for dev/testing).
pip install sqlalchemy asyncpg alembic
# For dev/testing with SQLite:
pip install aiosqlite
terminal
| Approach | When to use |
|---|---|
Raw SQL (text()) |
Complex queries, performance-critical, legacy schemas |
Core (select()) |
Fine-grained control without full ORM overhead |
ORM (Session + mapped classes) |
Standard CRUD, relationships, most FastAPI apps |
| Alembic | Schema migrations in all three cases |
select(Model) ORM style that works
identically in sync and async contexts. Avoid the legacy
session.query(Model) API in new code.
Defining Models
SQLAlchemy 2.0 uses Mapped[T] type annotations together with
mapped_column() to declare columns. This gives you full type-checker
support and a single source of truth for both the schema and the Python type.
# models.py
from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from datetime import datetime
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_pw: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
posts: Mapped[list["Post"]] = relationship(
"Post", back_populates="author", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<User id={self.id} username={self.username!r}>"
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[str] = mapped_column(String, nullable=False)
published: Mapped[bool] = mapped_column(Boolean, default=False)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
author: Mapped["User"] = relationship("User", back_populates="posts")
models.py
Key points:
Mapped[T]+mapped_column()is the SQLAlchemy 2.0 typed style — it replaces the oldColumn()declarations.server_default=func.now()generates aDEFAULT NOW()clause in the DDL — the database sets the timestamp, not Python.cascade="all, delete-orphan"means deleting aUserautomatically deletes theirPostrows.relationship()withback_populatescreates a bidirectional link —user.postsandpost.authorstay in sync.
Async Engine & Session Factory
# database.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from models import Base
# PostgreSQL in production
DATABASE_URL = "postgresql+asyncpg://user:password@localhost:5432/mydb"
# SQLite for local dev / tests
# DATABASE_URL = "sqlite+aiosqlite:///./dev.db"
engine = create_async_engine(
DATABASE_URL,
echo=True, # log all SQL (disable in production)
pool_size=10,
max_overflow=20,
)
AsyncSessionLocal = async_sessionmaker(
engine,
expire_on_commit=False, # keep objects usable after commit
class_=AsyncSession,
)
async def create_tables():
"""Create all tables — dev only; use Alembic in production."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
database.py
expire_on_commit=False is essential for async sessions. Without it,
accessing any attribute after commit() triggers a lazy load — which
fails in an async context because SQLAlchemy cannot implicitly issue a synchronous
I/O call. Always set this to False for async sessions.
echo=True during development to see every SQL query in the
console. Disable it in production to avoid log noise and potential credential
leakage.
FastAPI Session Dependency
Expose the async session via a FastAPI yield dependency. The
dependency commits on success or rolls back on exception — route handlers
never call commit() themselves.
# dependencies.py
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from database import AsyncSessionLocal
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
dependencies.py
Using the dependency in a route:
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from dependencies import get_db
from models import User
app = FastAPI()
@app.get("/users")
async def list_users(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.is_active == True))
return result.scalars().all()
main.py
CRUD Operations
# crud.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from models import User, Post
# ── CREATE ──
async def create_user(db: AsyncSession, username: str, email: str, hashed_pw: str) -> User:
user = User(username=username, email=email, hashed_pw=hashed_pw)
db.add(user)
await db.flush() # assign id without committing (commit handled by dependency)
return user
# ── READ ──
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
return await db.get(User, user_id) # fastest single-PK lookup
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 10) -> list[User]:
result = await db.execute(
select(User).offset(skip).limit(limit).order_by(User.id)
)
return list(result.scalars().all())
# ── UPDATE ──
async def update_user_email(db: AsyncSession, user_id: int, new_email: str) -> User | None:
user = await db.get(User, user_id)
if not user:
return None
user.email = new_email # modify the mapped attribute directly
await db.flush()
return user
# ── DELETE ──
async def delete_user(db: AsyncSession, user_id: int) -> bool:
user = await db.get(User, user_id)
if not user:
return False
await db.delete(user)
return True
crud.py
flush() vs commit():
flush() writes changes to the database within the current transaction
(assigning auto-generated IDs) but does not finalise.
commit() finalises the transaction. Since our get_db
dependency commits after yield, CRUD functions only need
flush() to get IDs back.
| Method | Returns | When to use |
|---|---|---|
scalar_one_or_none() |
One object or None |
Lookup by unique field |
scalar_one() |
One object (raises if 0 or 2+) | When exactly one row is expected |
scalars().all() |
List of objects | Multiple rows |
Relationships & Eager Loading
In async SQLAlchemy you must explicitly load relationships using eager-loading options — lazy loading is not supported in async context.
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models import User, Post
# ── selectinload — separate IN query (recommended for one-to-many) ──
async def get_user_with_posts(db: AsyncSession, user_id: int) -> User | None:
result = await db.execute(
select(User)
.where(User.id == user_id)
.options(selectinload(User.posts)) # loads posts in a second query
)
return result.scalar_one_or_none()
# ── joinedload — single JOIN query (good for many-to-one / single object) ──
async def get_post_with_author(db: AsyncSession, post_id: int) -> Post | None:
result = await db.execute(
select(Post)
.where(Post.id == post_id)
.options(joinedload(Post.author)) # JOIN users ON posts.author_id = users.id
)
return result.scalar_one_or_none()
crud.py
Many-to-many relationships use an association table:
from sqlalchemy import Table, Column, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models import Base
post_tags = Table(
"post_tags", Base.metadata,
Column("post_id", ForeignKey("posts.id"), primary_key=True),
Column("tag_id", ForeignKey("tags.id"), primary_key=True),
)
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True)
posts: Mapped[list["Post"]] = relationship(
"Post", secondary=post_tags, back_populates="tags"
)
models.py (addition)
Add the corresponding tags relationship to the Post model:
# Add to the Post class
tags: Mapped[list["Tag"]] = relationship(
"Tag", secondary=post_tags, back_populates="posts"
)
models.py
selectinload or joinedload — lazy loading is not
supported in async context (it would silently trigger a sync DB call and raise
MissingGreenlet).
Alembic Migrations
Alembic is the official migration tool for SQLAlchemy. It tracks schema changes as versioned migration scripts so you can upgrade, downgrade, and reproduce your database schema deterministically.
# 1 — Initialise Alembic
pip install alembic
alembic init alembic
terminal
Modify alembic/env.py to support async engines:
# alembic/env.py — key changes
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from alembic import context
from models import Base
from database import DATABASE_URL
target_metadata = Base.metadata # tell Alembic what schema to compare against
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
engine = create_async_engine(DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(do_run_migrations)
await engine.dispose()
def run_migrations_online():
asyncio.run(run_async_migrations())
alembic/env.py
Common Alembic commands:
# Auto-generate migration from model changes
alembic revision --autogenerate -m "add users and posts tables"
# Apply pending migrations
alembic upgrade head
# Roll back one step
alembic downgrade -1
# View migration history
alembic history --verbose
# Show current revision in DB
alembic current
terminal
--autogenerate detects column additions/removals and index changes
but does NOT detect: column renames, changes to
server_default, or custom SQL types. Always review the generated
migration file before applying.
N+1 Problem & Query Optimisation
The N+1 problem occurs when you load N rows then issue a separate query for each row's related data — 1 query for users + N queries for their posts = N+1 total.
Bad — N+1 with a loop
# ❌ N+1: 1 query for users + N queries for posts
result = await db.execute(select(User))
users = result.scalars().all()
for user in users:
posts_result = await db.execute(select(Post).where(Post.author_id == user.id))
user_posts = posts_result.scalars().all() # 1 extra query PER user!
bad_n_plus_1.py
Good — eager loading in 2 queries
# ✅ 2 queries total: one for users, one IN-query for all their posts
from sqlalchemy.orm import selectinload
result = await db.execute(
select(User).options(selectinload(User.posts))
)
users = result.scalars().all()
# user.posts already loaded — no extra queries
good_eager_load.py
Pagination — offset vs keyset
# Offset pagination — simple but slow on large tables
result = await db.execute(
select(Post).order_by(Post.id).offset(200).limit(20)
)
# Keyset (cursor) pagination — fast at any depth
last_seen_id = 200
result = await db.execute(
select(Post).where(Post.id > last_seen_id).order_by(Post.id).limit(20)
)
pagination.py
Aggregations with func
from sqlalchemy import func
result = await db.execute(
select(User.id, func.count(Post.id).label("post_count"))
.join(Post, isouter=True)
.group_by(User.id)
.order_by(func.count(Post.id).desc())
.limit(10)
)
rows = result.all() # list of Row(id, post_count)
aggregations.py
EXPLAIN ANALYZE in PostgreSQL to inspect query plans.
SQLAlchemy's echo=True shows emitted SQL but not the execution plan —
paste the query into psql or pgAdmin for real optimisation.
Pydantic + SQLAlchemy Integration
Keep Pydantic schemas (request/response shapes) separate from
SQLAlchemy models (DB tables). The bridge is
from_attributes=True — Pydantic reads ORM object attributes instead
of requiring a dict.
# schemas.py
from pydantic import BaseModel, ConfigDict, EmailStr
from datetime import datetime
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True) # read from ORM object
id: int
username: str
email: str
is_active: bool
created_at: datetime
class PostOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
published: bool
author: UserOut # nested ORM → Pydantic conversion
schemas.py
# Route that returns an ORM object directly
@app.post("/users", response_model=UserOut, status_code=201)
async def create_user_route(
body: UserCreate,
db: AsyncSession = Depends(get_db),
):
existing = await get_user_by_email(db, body.email)
if existing:
raise HTTPException(409, detail="Email already registered")
user = await create_user(db, body.username, body.email, hash_password(body.password))
return user # FastAPI calls UserOut.model_validate(user) via from_attributes=True
routes.py
from_attributes=True (formerly orm_mode = True in Pydantic v1)
enables UserOut.model_validate(orm_obj) to read attributes instead of dict keys.
FastAPI calls this automatically when serialising a response_model, so you can
return raw ORM objects from route handlers without manual conversion.
Testing with a Real SQLite DB
Use SQLite + aiosqlite for integration tests — it exercises the full async
SQLAlchemy stack without needing a running PostgreSQL instance in CI.
# conftest.py
import pytest, pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from fastapi.testclient import TestClient
from main import app
from database import get_db
from models import Base
TEST_DB_URL = "sqlite+aiosqlite:///./test.db"
@pytest_asyncio.fixture(scope="session")
async def engine():
eng = create_async_engine(TEST_DB_URL)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await eng.dispose()
@pytest_asyncio.fixture
async def db_session(engine):
AsyncTestSession = async_sessionmaker(engine, expire_on_commit=False)
async with AsyncTestSession() as session:
yield session
await session.rollback() # undo test changes — fast, no DDL
@pytest.fixture
def client(db_session):
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
app.dependency_overrides.clear()
conftest.py
scope="session" for the engine (created once) and default
scope="function" for the session (fresh per test).
Best Practices
- Use async SQLAlchemy for FastAPI — sync sessions block the event loop.
- Always load relationships explicitly —
selectinloadfor one-to-many,joinedloadfor many-to-one. Never rely on lazy loading in async code. - Use Alembic for all schema changes — never call
Base.metadata.create_all()in production. flush()in CRUD,commit()in the dependency — keeps transaction control at the boundary.- Set
expire_on_commit=Falseon async sessions — preventsDetachedInstanceError. - Use
from_attributes=Trueon all response Pydantic models — cleanly converts ORM objects without manual.model_dump(). - Add indexes on foreign keys and frequently queried columns —
mapped_column(index=True)generates a B-tree index. - Test with SQLite + aiosqlite in CI — fast, zero-config, schema-compatible with PostgreSQL for most queries.
Exercises
Exercise 1 — Blog CRUD API
Build a complete FastAPI + SQLAlchemy async blog API:
- Models:
User(id, username, email, hashed_pw) andPost(id, title, body, published, author_id → User) - CRUD functions:
create_user,get_user,create_post,list_posts(published_only),publish_post - Routes:
POST /users,POST /posts,GET /posts,GET /posts/{id},PATCH /posts/{id}/publish - Response models with
from_attributes=True; raise 404 on missing resources
💡 Hint — list posts with author pre-loaded
@app.get("/posts", response_model=list[PostOut])
async def list_posts(published_only: bool = False, db: AsyncSession = Depends(get_db)):
q = select(Post).options(selectinload(Post.author)).order_by(Post.id)
if published_only:
q = q.where(Post.published == True)
result = await db.execute(q)
return result.scalars().all()
Exercise 2 — Tags (Many-to-Many)
Extend the blog with a Tag system:
- Add
Tagmodel andpost_tagsassociation table POST /tags— create a tagPOST /posts/{id}/tags— attach a list of tag names (create missing tags)GET /posts/{id}— include tags in the response
💡 Hint — attach tags
async def add_tags_to_post(db: AsyncSession, post_id: int, tag_names: list[str]):
result = await db.execute(
select(Post).where(Post.id == post_id).options(selectinload(Post.tags))
)
post = result.scalar_one_or_none()
if not post:
return None
for name in tag_names:
tag_r = await db.execute(select(Tag).where(Tag.name == name))
tag = tag_r.scalar_one_or_none()
if not tag:
tag = Tag(name=name)
db.add(tag)
await db.flush()
if tag not in post.tags:
post.tags.append(tag)
return post
Exercise 3 — Write & Run an Alembic Migration
Practice the full Alembic workflow:
- Start from the Blog schema (Exercise 1)
alembic init alembic— configureenv.pywith your models and async enginealembic revision --autogenerate -m "initial schema"— inspect the generated filealembic upgrade head— confirm tables exist- Add
bio: Mapped[str | None]toUser alembic revision --autogenerate -m "add user bio"thenalembic upgrade headalembic downgrade -1— confirm the column is removed
💡 Hint
alembic current # show active revision
alembic history --verbose # show full migration log