Skip to main content
fastapisqlalchemyasyncpostgresormproduction

SQLAlchemy 2.0 Async with FastAPI: Best Practices

The right way to use SQLAlchemy 2.0 async sessions in FastAPI — dependency injection, transaction management, eager loading, and common pitfalls.

FastAPI AI Kit Team··3 min read

SQLAlchemy 2.0's async API with FastAPI is the correct choice for production — but it has subtle differences from the sync API that cause real bugs. This covers the patterns you need and the mistakes to avoid.

Session factory and dependency

# app/db.py
from sqlalchemy.ext.asyncio import (
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

engine = create_async_engine(
    settings.DATABASE_URL,
    pool_size=10,
    max_overflow=20,
    pool_pre_ping=True,  # Check connection health before use
    echo=settings.DEBUG,
)

AsyncSessionLocal = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,  # Critical — see below
)

async def get_db():
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
        finally:
            await session.close()

expire_on_commit=False is critical for async. With expire_on_commit=True (the default), accessing attributes after commit() triggers a lazy load — but in async mode, that's a MissingGreenlet error. Set it to False and access attributes before committing if you need them in the response.

Correct model definitions

Use SQLAlchemy 2.0's Mapped annotations throughout:

# app/models/user.py
import uuid
from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import String, Boolean, DateTime
from app.db import Base

class User(Base):
    __tablename__ = "users"
    
    id: Mapped[uuid.UUID] = mapped_column(
        primary_key=True,
        default=uuid.uuid4,
    )
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    hashed_password: Mapped[str]
    is_active: Mapped[bool] = mapped_column(default=True)
    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
    
    # Relationship — use selectin loading for async
    api_keys: Mapped[list["APIKey"]] = relationship(
        back_populates="owner",
        lazy="selectin",  # NOT "select" or "subquery"
    )

lazy="selectin" is the right loading strategy for async. It issues a second query with SELECT IN rather than lazy loading, which is compatible with async sessions.

Repository pattern

Don't write raw SQLAlchemy in route handlers. Use a repository pattern:

# app/repositories/user.py
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User

class UserRepository:
    def __init__(self, db: AsyncSession):
        self.db = db
    
    async def get_by_email(self, email: str) -> User | None:
        result = await self.db.execute(
            select(User).where(User.email == email)
        )
        return result.scalar_one_or_none()
    
    async def create(self, email: str, hashed_password: str) -> User:
        user = User(email=email, hashed_password=hashed_password)
        self.db.add(user)
        await self.db.flush()  # Get the ID without committing
        return user
    
    async def get_with_keys(self, user_id: uuid.UUID) -> User | None:
        result = await self.db.execute(
            select(User)
            .options(selectinload(User.api_keys))
            .where(User.id == user_id)
        )
        return result.scalar_one_or_none()

# Usage in routes
async def get_user_repo(db: AsyncSession = Depends(get_db)):
    return UserRepository(db)

Common async pitfalls

1. Lazy loading after session close

# Wrong — session closes when context exits
async with AsyncSessionLocal() as db:
    user = await db.get(User, user_id)
# Session closed here

# This triggers lazy load — MissingGreenlet error!
print(user.api_keys)

# Right — load everything you need inside the session
async with AsyncSessionLocal() as db:
    result = await db.execute(
        select(User)
        .options(selectinload(User.api_keys))
        .where(User.id == user_id)
    )
    user = result.scalar_one()
    api_keys = user.api_keys  # Loaded inside session

2. Using sync session methods

# Wrong — sync, blocks event loop
user = db.query(User).filter(User.id == user_id).first()

# Right — async
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()

3. Missing flush before accessing generated IDs

# Wrong — id may not be populated yet
db.add(user)
print(user.id)  # May be None

# Right — flush populates DB-generated values without committing
db.add(user)
await db.flush()
print(user.id)  # UUID is set

Transactions across multiple operations

Use a single session for operations that must be atomic:

async def create_user_with_api_key(
    email: str,
    db: AsyncSession = Depends(get_db),
) -> tuple[User, APIKey]:
    user_repo = UserRepository(db)
    key_repo = APIKeyRepository(db)
    
    user = await user_repo.create(email, hash_password("temp"))
    # user.id is available after flush (happens in create)
    
    raw_key, key_hash, prefix = generate_api_key()
    api_key = await key_repo.create(
        owner_id=user.id,
        key_hash=key_hash,
        prefix=prefix,
    )
    
    # Both user and api_key commit together — atomic
    # get_db() commits on yield return
    return user, raw_key  # Return raw key before commit

FastAPI AI Kit implements these patterns throughout: get_db() dependency, repository classes for all models, and selectin loading by default. The boilerplate decisions are made for you.

Build your AI backend with FastAPI AI Kit.

Clone, configure, and ship — everything is already wired up.

Read the docs
No subscriptions · One-time payment · Lifetime updates