Python — FastAPI Development

FastAPI + PostgreSQL + SQLAlchemy 2.0 Patterns

Direct answer

The stack that holds up in production: create_async_engine with the asyncpg driver, async_sessionmaker with expire_on_commit=False, a session-per-request dependency that commits on success and rolls back on error, 2.0-style models using Mapped and mapped_column, and select() queries with explicit eager loading. The single biggest async gotcha is implicit lazy loading, which fails outside a greenlet context — always load relationships explicitly with selectinload or joinedload.

SQLAlchemy 2.0 changed the idioms enough that most tutorials and half the production codebases I audit still mix old and new styles. These are the FastAPI + PostgreSQL patterns I standardize on so the ORM stays predictable under async.

Key facts, with sources

  • In the JetBrains Python Developers Survey 2024, which collected responses from more than 30,000 Python developers, FastAPI usage jumped from 29% to 38%, overtaking Django (35%) and Flask (34%) as the most-used Python web framework. (JetBrains Python Developers Survey 2024)
  • The 2025 Stack Overflow Developer Survey shows FastAPI at 14.8% of respondents doing extensive work with it, edging out Flask at 14.4% and Django at 12.6%. (Stack Overflow Developer Survey 2025)
  • FastAPI's official documentation cites independent TechEmpower benchmarks showing FastAPI applications running under Uvicorn as one of the fastest Python frameworks available, ranked only below Starlette and Uvicorn themselves. (FastAPI official documentation)
  • FastAPI surpassed Flask in GitHub stars in December 2025, reaching roughly 88,000 stars compared to Flask's 68,400. (DZone)
  • Industry analysis of FastAPI's 2025 growth reports about 40% year-over-year growth in job mentions and production adoption at companies including Uber, Netflix, and Microsoft. (byteiota)

Engine and session setup that survives production

One async engine per process, created at import time or in the lifespan handler, with asyncpg as the driver. I set pool_pre_ping=True so stale connections after a database failover get detected instead of throwing on first use, and I set expire_on_commit=False on the sessionmaker — without it, every attribute access after commit triggers a refresh query, which under async raises errors instead of quietly being slow.

The session dependency owns the transaction: yield the session, commit if the request handler succeeds, roll back if it raises. Handlers and service functions then never call commit themselves, which eliminates the half-committed state bugs I regularly find in audits.

Async engine and session-per-request dependency
from collections.abc import AsyncIterator

from sqlalchemy.ext.asyncio import (
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

engine = create_async_engine(
    settings.database_url,  # postgresql+asyncpg driver
    pool_size=10,
    max_overflow=20,
    pool_pre_ping=True,
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)


async def get_db() -> AsyncIterator[AsyncSession]:
    async with SessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

2.0-style models with Mapped and mapped_column

I declare every model with Mapped type annotations and mapped_column. The win is not aesthetics — it is that your type checker now understands your models, so a query returning Project | None gets caught when you forget the None branch. Mixing the legacy Column style with 2.0 typing in one codebase is where I see the most confusion, so I convert everything in one pass when I take over a project.

Relationships get explicit back_populates on both sides. The implicit backref style still works, but explicit declarations make the object graph greppable, and in async code you need to know exactly which relationships exist because every single one must be loaded deliberately.

Typed models and an eager-loaded query
from sqlalchemy import ForeignKey, select
from sqlalchemy.orm import (
    DeclarativeBase,
    Mapped,
    mapped_column,
    relationship,
    selectinload,
)


class Base(DeclarativeBase):
    pass


class Project(Base):
    __tablename__ = "projects"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(index=True)
    tasks: Mapped[list["Task"]] = relationship(back_populates="project")


class Task(Base):
    __tablename__ = "tasks"

    id: Mapped[int] = mapped_column(primary_key=True)
    project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"))
    title: Mapped[str]
    project: Mapped[Project] = relationship(back_populates="tasks")


async def list_projects(db: AsyncSession) -> list[Project]:
    result = await db.execute(
        select(Project).options(selectinload(Project.tasks)).order_by(Project.id)
    )
    return list(result.scalars())

Kill lazy loading before it kills you

In sync SQLAlchemy, touching an unloaded relationship silently fires a query. In async, it raises a MissingGreenlet error — usually deep in a Pydantic serializer, long after the session context, at the worst possible time. This is the number one error I get called in to explain on async FastAPI projects.

My rule: every query states its loading strategy. selectinload for collections, joinedload for many-to-one, and nothing left implicit. If serialization needs a relationship, the query that fetched the object must have loaded it. Some teams go further and set lazy="raise" on relationships so any accidental lazy access fails loudly in tests instead of intermittently in production — a habit I recommend.

Alembic discipline

Autogenerate is a draft, not a migration. I read every generated diff before committing because autogenerate misses server defaults, misreads some type changes, and cannot express data backfills. One migration per pull request, never edit a migration that has reached a shared environment, and every migration gets a downgrade path even if the honest downgrade is raising an error with an explanation.

For async projects, Alembic runs happily with a sync driver in its own env, which keeps migration scripts simple. I run migrations as a release step before the new application version boots — never at import time inside the app, where concurrent instances can race each other.

Pool sizing and the pgbouncer caveat

Pool size multiplied by worker count multiplied by instance count must stay under your PostgreSQL max_connections with room to spare for migrations and admin sessions. Teams typically discover this the day autoscaling kicks in and the database starts refusing connections. Work the arithmetic backward from the database limit, not forward from a tutorial default.

If you put pgbouncer in transaction-pooling mode in front of the database — common on managed platforms — asyncpg's prepared statement cache can conflict with it. The usual fix is disabling the statement cache through connect_args. It is a one-line change, but it typically costs a team an evening of confusing errors to find, so know about it in advance.

When to hire senior help

Bring in senior help when your API needs to handle real concurrency, when you are designing service boundaries and auth for the first time, or when an existing FastAPI codebase mixes sync and async code and latency is degrading. An experienced engineer can usually diagnose event-loop blocking and connection-pool misconfiguration in days, which is far cheaper than re-architecting after launch. If your stack includes React Native + Python + AI, a senior engineer who owns the full product beats coordinating multiple juniors.

Bottom line

Dhairya Senjaliya ships Python — FastAPI Development projects worldwide — book a scoping call to discuss your specific situation.

Common pitfalls to avoid

  • Calling blocking libraries (classic SQLAlchemy sessions, requests, heavy file I/O) inside async def endpoints, which stalls the event loop and erases FastAPI's concurrency advantage
  • Deploying a single Uvicorn process with no process manager or worker scaling, leaving most CPU cores idle under production load
  • Treating the auto-generated OpenAPI docs as a versioning strategy, then breaking mobile and partner clients when response schemas change
  • Running large payloads through deeply nested Pydantic models on every request and response, adding serialization latency that shows up only at scale

Frequently asked questions

Should I use async or sync SQLAlchemy with FastAPI?

Use async if your endpoints are IO-bound and you are committing to the discipline: asyncpg driver, explicit eager loading, and no lazy access after the session closes. Sync SQLAlchemy in def endpoints is still legitimate — FastAPI runs those in a threadpool — and it is simpler for teams new to the ORM. What fails is mixing both carelessly in one service.

What causes MissingGreenlet errors in async SQLAlchemy?

Accessing a relationship or expired attribute that requires a database round trip outside an async-aware context — most often lazy-loading a relationship during Pydantic serialization after the request's session work is done. Fix it by eager loading with selectinload or joinedload in the original query, and by setting expire_on_commit=False so committed objects keep their loaded state.

Why set expire_on_commit=False in FastAPI apps?

By default SQLAlchemy expires all objects on commit, so the next attribute access triggers a refresh query. In a FastAPI flow where you commit in a dependency and then serialize the object in the response, that refresh happens outside the transaction and fails under async. Setting expire_on_commit=False keeps loaded attribute values available after commit, which matches how request-scoped sessions are actually used.

Is FastAPI mature enough for production?

Yes. It was the most-used Python web framework in the JetBrains 2024 survey at 38%, and companies including Uber, Netflix, and Microsoft run it in production. The ecosystem for auth, ORMs, and testing is now well established.

How much faster is FastAPI than Flask or Django really?

Independent TechEmpower benchmarks place FastAPI among the fastest Python frameworks, and published comparisons show several times Flask's throughput on I/O-bound endpoints. For CPU-bound work or database-bottlenecked apps, the framework choice matters far less than query and infrastructure design.

Should we pick FastAPI or Django for a new SaaS backend?

FastAPI suits API-first products, microservices, and ML model serving because of async support and automatic OpenAPI docs. Django ships batteries included (admin, ORM, auth) and is often faster to launch a conventional CRUD product. Many teams run both, per the JetBrains finding that a third of Django developers also use Flask or FastAPI.

Bottom line: Dhairya Senjaliya ships Python — FastAPI Development projects worldwide. Book a scoping call at https://dhairyasenjaliya.com/#book-call.

Sources

Related guides

Keep up with new guides

New deep-dive guides on React Native, Python, and AI ship regularly. Subscribe via RSS or follow on LinkedIn.

Want help implementing this?

30-minute scoping call · Clear milestones · Senior engineer ownership