Skip to content

SQLAlchemy 2.0 Reference: Mapped, select(), Relationships, Async, Alembic

SQLAlchemy 2.0 unified the ORM and Core APIs around a single pattern: everything goes through session.execute() with 2.0-style queries. The biggest shift from 1.x: session.query(User) is legacy — use select(User) instead. This reference covers the patterns that matter for production apps: relationship loading strategies, async support, and avoiding N+1 queries.

1. Models, Engine & Session

DeclarativeBase, engine setup, session factory, and model definition
from sqlalchemy import create_engine, String, Integer, ForeignKey, DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from datetime import datetime

# 2.0 style: DeclarativeBase + Mapped annotations:
class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id:         Mapped[int] = mapped_column(primary_key=True)
    name:       Mapped[str] = mapped_column(String(100), nullable=False)
    email:      Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())
    # Mapped[str | None] = Optional field (nullable):
    avatar_url: Mapped[str | None] = mapped_column(String(500))
    # Relationship:
    posts: Mapped[list["Post"]] = relationship("Post", back_populates="author", lazy="select")

class Post(Base):
    __tablename__ = "posts"
    id:        Mapped[int] = mapped_column(primary_key=True)
    title:     Mapped[str] = mapped_column(String(500))
    author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
    author:    Mapped["User"] = relationship("User", back_populates="posts")

# Sync engine:
engine = create_engine("postgresql+psycopg2://user:pass@localhost/db",
    pool_size=10, max_overflow=20, pool_pre_ping=True)
SessionLocal = sessionmaker(engine, expire_on_commit=False)

# Async engine (with asyncpg):
async_engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db",
    pool_size=10, max_overflow=20)
AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False)

# Create tables:
Base.metadata.create_all(engine)

2. Queries — select(), filter(), joins

2.0-style select, filtering, ordering, pagination, and JOIN patterns
from sqlalchemy import select, and_, or_, desc, func

with Session(engine) as session:
    # Basic select (2.0 style — NOT session.query()):
    stmt = select(User).where(User.email == "alice@example.com")
    user = session.scalars(stmt).first()   # scalars() unwraps the Row

    # Multiple conditions:
    stmt = select(User).where(
        and_(User.name.like("A%"), User.created_at >= "2026-01-01")
    )
    users = session.scalars(stmt).all()

    # OR:
    stmt = select(User).where(or_(User.name == "Alice", User.name == "Bob"))

    # IN:
    stmt = select(User).where(User.id.in_([1, 2, 3]))

    # ORDER BY + LIMIT + OFFSET:
    stmt = select(User).order_by(desc(User.created_at)).limit(20).offset(40)

    # COUNT:
    count = session.scalar(select(func.count()).select_from(User))

    # JOIN (explicit):
    stmt = (select(User, Post)
        .join(Post, Post.author_id == User.id)
        .where(Post.published == True)
        .order_by(desc(Post.created_at)))
    rows = session.execute(stmt).all()
    for user, post in rows:
        print(user.name, post.title)

    # EXISTS:
    from sqlalchemy import exists
    stmt = select(User).where(exists(select(Post).where(Post.author_id == User.id)))

3. Relationships & Loading Strategies

lazy vs eager loading, selectinload, joinedload, and avoiding N+1 queries
from sqlalchemy.orm import selectinload, joinedload, load_only

# lazy="select" (default): relationship loaded on attribute access — causes N+1!
# Fix: use selectinload or joinedload at query time:

with Session(engine) as session:
    # selectinload: one extra SELECT for the relationship (best for collections):
    stmt = select(User).options(selectinload(User.posts))
    users = session.scalars(stmt).all()
    for user in users:
        print(user.posts)   # no extra query! loaded in one SELECT ... IN (...)

    # joinedload: LEFT OUTER JOIN (best for single objects, one-to-one/many-to-one):
    stmt = select(Post).options(joinedload(Post.author))
    posts = session.scalars(stmt).unique().all()   # .unique() needed with joinedload

    # Nested loading:
    stmt = select(User).options(
        selectinload(User.posts).selectinload(Post.comments)
    )

    # load_only: select specific columns only (avoid loading large fields):
    stmt = select(User).options(load_only(User.id, User.name, User.email))

    # N+1 detection: enable query logging:
    # create_engine(..., echo=True)
    # or: logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)

# expire_on_commit=False: keep attribute values after commit (important for async):
# Without it, accessing user.name after session.commit() triggers a new SELECT

4. Async with FastAPI / SQLAlchemy 2.0

AsyncSession, async context manager, and FastAPI dependency injection
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from fastapi import Depends

async_engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False)

# FastAPI dependency:
async def get_db() -> AsyncSession:
    async with AsyncSessionLocal() as session:
        yield session

# Route using async session:
@app.get("/users/{user_id}")
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
    stmt = select(User).where(User.id == user_id)
    user = await db.scalar(stmt)
    if not user:
        raise HTTPException(status_code=404)
    return user

@app.post("/users")
async def create_user(data: UserCreate, db: AsyncSession = Depends(get_db)):
    user = User(name=data.name, email=data.email)
    db.add(user)
    await db.commit()
    await db.refresh(user)   # reload from DB (gets server defaults like created_at)
    return user

# Async relationship loading (lazy loading doesn't work in async!):
stmt = select(User).options(selectinload(User.posts)).where(User.id == user_id)
user = await db.scalar(stmt)
# user.posts is populated — no lazy load needed

5. Migrations with Alembic

alembic init, autogenerate migrations, upgrade/downgrade, and multi-env setup
# pip install alembic

# Initialize:
alembic init alembic

# alembic/env.py — point at your models:
from myapp.models import Base
target_metadata = Base.metadata

# alembic.ini:
# sqlalchemy.url = postgresql+psycopg2://user:pass@localhost/db
# Or use env var:
# sqlalchemy.url = %(DATABASE_URL)s

# Generate migration from model changes (autogenerate):
alembic revision --autogenerate -m "add avatar_url to users"

# Review the generated file in alembic/versions/ before applying!

# Apply all pending migrations:
alembic upgrade head

# Downgrade one step:
alembic downgrade -1

# Show current version:
alembic current

# Show pending:
alembic history --verbose

# Alembic does NOT detect: renamed columns, renamed tables, constraint changes on some DBs
# Always review autogenerated migrations — they can drop+recreate instead of alter

# Multi-env (staging vs prod) via config:
alembic -x env=prod upgrade head
# In env.py: config.get_main_option("env") or os.getenv("ENV")

Track Python and SQLAlchemy releases at ReleaseRun. Related: Python Reference | PostgreSQL Advanced SQL Reference | FastAPI Reference | Python EOL Tracker

🔍 Free tool: PyPI Package Health Checker — check SQLAlchemy, Alembic, and other Python ORM packages for known CVEs and active maintenance.

Founded

2023 in London, UK

Contact

hello@releaserun.com