DEV Community

Cover image for asyncpg vs SQLAlchemy ORM - The 9x Slowdown Nobody Warns You About
Anas Issath
Anas Issath

Posted on

asyncpg vs SQLAlchemy ORM - The 9x Slowdown Nobody Warns You About

Async doesn't mean fast. Your ORM might be quietly throwing away everything asyncpg gives you.

We migrated a FastAPI service from sync SQLAlchemy to async SQLAlchemy, expecting a performance win. Async is supposed to be faster, right? Non-blocking I/O, more concurrent requests per worker, better resource utilization.

Our load test showed the opposite. The async version was slower per-request than the sync version had been. Not by a little: a specific endpoint that fetched 500 rows and mapped them to ORM objects went from 340ms to 890ms.

I assumed we'd misconfigured something. Wrong connection pool size, maybe, or a missing index. I profiled the endpoint with cProfile and found the answer: the async ORM layer itself was the bottleneck.

Object instantiation, identity map tracking, and attribute loading (the same overhead that exists in sync SQLAlchemy) was still there in async SQLAlchemy. We'd added the complexity of async without eliminating the cost of the ORM.

This isn't a reason to avoid async or avoid SQLAlchemy. It's a reason to understand exactly what you're paying for at each layer, so you can make an informed choice instead of assuming "async" automatically means "fast."


The Three Layers and What Each One Costs

When you fetch data from PostgreSQL in a modern async Python app, there are three layers between your code and the database:

Your code
  → SQLAlchemy ORM (optional)     ← object mapping, identity map, relationships
  → SQLAlchemy Core / Driver       ← query compilation, connection management
  → asyncpg or psycopg              ← the actual wire protocol to PostgreSQL
Enter fullscreen mode Exit fullscreen mode

Each layer adds capability and each layer adds overhead. Understanding where the overhead comes from tells you when it's worth paying and when it isn't.

Layer 1: The Driver (asyncpg vs psycopg 3)

At the bottom, you have the actual PostgreSQL wire protocol implementation. Two real choices in 2026:

  • asyncpg: built from scratch for asyncio. Speaks PostgreSQL's binary protocol directly. No general-purpose abstraction layer. This is the fastest way to talk to PostgreSQL from Python, full stop.
  • psycopg 3 (async mode): the modern rewrite of the classic psycopg2, with native async support. Slightly slower than asyncpg in raw benchmarks, but shares an API with sync psycopg, which matters if you're maintaining both sync and async code paths.
# Raw asyncpg - as fast as Python gets talking to PostgreSQL
import asyncpg

async def get_orders(user_id: int):
    conn = await asyncpg.connect(DATABASE_URL)
    try:
        rows = await conn.fetch(
            "SELECT id, total, status FROM orders WHERE user_id = $1", user_id
        )
        return [dict(row) for row in rows]
    finally:
        await conn.close()
Enter fullscreen mode Exit fullscreen mode
# psycopg 3 async - nearly as fast, more familiar API
import psycopg

async def get_orders(user_id: int):
    async with await psycopg.AsyncConnection.connect(DATABASE_URL) as conn:
        async with conn.cursor() as cur:
            await cur.execute(
                "SELECT id, total, status FROM orders WHERE user_id = %s", (user_id,)
            )
            rows = await cur.fetchall()
            return rows
Enter fullscreen mode Exit fullscreen mode

Both are dramatically faster than going through an ORM for simple queries. The difference between them is small enough that it rarely decides an architecture. asyncpg wins benchmarks by roughly 2x for raw throughput, but in a real application with network latency and business logic around the query, that gap shrinks to something you won't notice.

Layer 2: SQLAlchemy Core (No ORM)

SQLAlchemy Core gives you a query builder and connection management without object mapping. You write something close to SQL, but Python-native and composable:

from sqlalchemy import select, table, column
from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")

orders = table("orders", column("id"), column("total"), column("status"), column("user_id"))

async def get_orders(user_id: int):
    async with engine.connect() as conn:
        result = await conn.execute(
            select(orders).where(orders.c.user_id == user_id)
        )
        return result.fetchall()
Enter fullscreen mode Exit fullscreen mode

This adds a small amount of overhead over raw asyncpg (query compilation, parameter binding, connection pool management), but it's a fraction of what the full ORM costs.

Layer 3: The ORM (SQLAlchemy's Declarative Models)

The ORM adds object identity tracking, relationship loading, change tracking (dirty attribute detection), and Python object instantiation for every row:

from sqlalchemy import select
from sqlalchemy.orm import declarative_base, Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession

Base = declarative_base()

class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    total: Mapped[float]
    status: Mapped[str]
    user_id: Mapped[int]

async def get_orders(session: AsyncSession, user_id: int):
    result = await session.execute(select(Order).where(Order.user_id == user_id))
    return result.scalars().all()
Enter fullscreen mode Exit fullscreen mode

Same query. Same data. But now every row becomes a full Python object with SQLAlchemy's instrumentation attached:

  • Tracking whether each attribute has changed
  • Registering the object in the session's identity map, so session.get(Order, id) returns the same instance
  • Setting up lazy-loading machinery for any relationships

The Benchmark: What Each Layer Actually Costs

I ran this on a table with 100,000 rows, fetching 500 rows matching a filter, on a local PostgreSQL instance to eliminate network variance:

The jump from Core to ORM is the one that matters. Going from raw asyncpg to SQLAlchemy Core costs you a small, reasonable overhead for a massive gain in composability and safety. Going from Core to full ORM costs you almost 3x more on top of that, for object mapping you may not even need.

This matches what other benchmarks in the Python community have found: SQLAlchemy's ORM layer with async drivers can be an order of magnitude slower than raw driver access for simple read-heavy queries, primarily because of Python-level object construction overhead, not the database round trip itself.


Why the ORM Overhead Is So High With Async

Here's the part that surprised me most. You'd expect async to at least match sync ORM performance, since the actual database call is non-blocking either way. But in some benchmarks, async ORM queries are actually slower than sync ORM queries for CPU-bound work like object instantiation.

The reason: async adds its own overhead (event loop scheduling, coroutine creation, awaiting each step of the driver protocol) on top of the ORM's existing object-mapping cost.

  • For I/O-bound work (waiting on the network), async wins because the event loop can do other things while waiting.
  • But object instantiation isn't I/O-bound. It's pure CPU work happening on the same thread, and async adds bookkeeping around it without adding parallelism, because Python's GIL means you're not getting extra CPU time from asyncio. You're just avoiding blocking waits.

So the ORM's object-mapping tax is paid in full, and the async machinery adds a smaller additional tax on top, for operations that were never going to benefit from concurrency in the first place.


When Each Layer Is the Right Choice

Use raw asyncpg when...

You're building a narrow, performance-critical service: a data pipeline, an analytics endpoint, a service that streams large result sets. You control the SQL directly and don't need object mapping, relationships, or migrations tied to this code path.

# High-throughput analytics endpoint
@app.get("/api/analytics/daily-revenue")
async def daily_revenue(pool: asyncpg.Pool = Depends(get_pool)):
    async with pool.acquire() as conn:
        rows = await conn.fetch("""
            SELECT date_trunc('day', created_at) as day, SUM(total) as revenue
            FROM orders
            WHERE created_at >= NOW() - INTERVAL '30 days'
            GROUP BY day
            ORDER BY day
        """)
        return [dict(r) for r in rows]
Enter fullscreen mode Exit fullscreen mode

Use SQLAlchemy Core when...

You want composable, injection-safe query building without the ORM's object overhead. This is the sweet spot for most read-heavy API endpoints (list views, search, reporting) where you need dynamic filters but don't need to mutate and save objects back.

async def list_orders(db: AsyncSession, filters: OrderFilters):
    stmt = select(
        orders_table.c.id,
        orders_table.c.total,
        orders_table.c.status,
    ).where(orders_table.c.is_active == True)

    if filters.status:
        stmt = stmt.where(orders_table.c.status == filters.status)
    if filters.min_total:
        stmt = stmt.where(orders_table.c.total >= filters.min_total)

    result = await db.execute(stmt)
    return result.mappings().all()  # Returns dicts, not ORM objects
Enter fullscreen mode Exit fullscreen mode

result.mappings().all() is the key detail. It returns plain dictionaries instead of ORM entity objects, skipping the identity map and change-tracking machinery entirely while still using SQLAlchemy's query builder and connection pooling.

Use the full ORM when...

  • You're doing writes with relationships (creating an order with items in one transaction)
  • You need the identity map to prevent duplicate object instances across a request
  • You're using SQLAlchemy's relationship loading (selectinload, joinedload) to manage complex object graphs
  • Your team's productivity gain from the ORM's ergonomics outweighs the performance cost for that specific endpoint
async def create_order_with_items(db: AsyncSession, user_id: int, items: list[dict]):
    order = Order(user_id=user_id, status="pending")
    for item in items:
        order.items.append(OrderItem(
            product_id=item["product_id"],
            quantity=item["quantity"],
        ))
    db.add(order)
    await db.commit()
    await db.refresh(order)
    return order
Enter fullscreen mode Exit fullscreen mode

This is exactly where the ORM earns its overhead: cascading inserts, relationship management, and object lifecycle tracking that would take significantly more raw SQL and manual bookkeeping to replicate correctly.

Layer Best for Returns
Raw asyncpg Narrow, performance-critical paths, analytics, streaming Records / dicts
SQLAlchemy Core Read-heavy list, search and reporting endpoints Dicts via .mappings()
Full ORM Writes with relationships, complex object graphs Tracked ORM objects

The Hybrid Pattern I Actually Use

Most production FastAPI services benefit from using different layers for different endpoints, all sharing the same async engine and connection pool:

# app/database.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

engine = create_async_engine(
    "postgresql+asyncpg://user:pass@localhost/mydb",
    pool_size=10,
    max_overflow=20,
    pool_pre_ping=True,
)

AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db():
    async with AsyncSessionLocal() as session:
        yield session
Enter fullscreen mode Exit fullscreen mode
from sqlalchemy import text

# Writes and relationship-heavy reads: full ORM
@app.post("/api/orders")
async def create_order(data: OrderCreate, db: AsyncSession = Depends(get_db)):
    order = await order_service.create_order(db, data)  # Uses ORM
    return order

# High-volume list endpoints: SQLAlchemy Core, dicts not objects
@app.get("/api/orders")
async def list_orders(
    filters: OrderFilters = Depends(),
    db: AsyncSession = Depends(get_db),
):
    stmt = build_order_query(filters)  # Core query, no ORM mapping
    result = await db.execute(stmt)
    return result.mappings().all()

# Reporting and analytics: raw SQL via the same session
@app.get("/api/reports/summary")
async def report_summary(db: AsyncSession = Depends(get_db)):
    result = await db.execute(text("""
        SELECT status, COUNT(*) as count, SUM(total) as revenue
        FROM orders GROUP BY status
    """))
    return result.mappings().all()
Enter fullscreen mode Exit fullscreen mode

All three share the same AsyncSession and connection pool. You're not managing separate drivers or separate pools. You're choosing, per endpoint, how much of the ORM's machinery you actually need.


The One Setting That Changes Everything: expire_on_commit

If you do use the ORM, this default setting quietly causes extra queries you didn't ask for:

# Default behavior - expires all objects after commit
async_session = async_sessionmaker(engine)

order = await create_order(db, data)
await db.commit()
print(order.status)  # Triggers a NEW query to refresh the expired object!
Enter fullscreen mode Exit fullscreen mode

By default, SQLAlchemy marks every object's attributes as "expired" after a commit, so the next time you access them, it silently re-fetches from the database to guarantee freshness.

In a request-response API, where the session ends right after the commit, this is almost always wasted work. You're about to serialize the object and return it, not keep using it across a long-lived session.

# Disable expiration - trust the in-memory data after commit
async_session = async_sessionmaker(engine, expire_on_commit=False)
Enter fullscreen mode Exit fullscreen mode

expire_on_commit=False is the single highest-leverage SQLAlchemy async setting for API workloads.

I set it on every FastAPI project now. Without it, a simple "create and return" endpoint fires a COMMIT and then a silent SELECT to re-fetch data you already had in memory.


Bottom Line

Async doesn't erase the cost of the layers above the database driver. It just changes what kind of cost you're paying.

  • Raw asyncpg is fast because it does almost nothing beyond speaking PostgreSQL's protocol.
  • SQLAlchemy Core adds a thin, worthwhile layer of safety and composability.
  • The full ORM adds real, measurable overhead in exchange for object mapping, relationship management, and identity tracking: features that are essential for some endpoints and pure cost for others.

The mistake isn't choosing SQLAlchemy's ORM. It's using it uniformly across every endpoint without asking whether that endpoint needs what it provides. A list endpoint returning 500 rows of read-only data doesn't need an identity map. A checkout endpoint creating an order with five related line items absolutely does.

That endpoint that went from 340ms to 890ms after our sync-to-async migration? We didn't revert to sync. We kept async, dropped the ORM for that specific read path, and switched to result.mappings().all() with SQLAlchemy Core.

It came back down to 95ms: faster than the original sync version, using the same connection pool, the same database, and about fifteen lines of changed code.


Over to You

What's your database access pattern in async Python? Full ORM everywhere, raw SQL everywhere, or a hybrid like this? And has anyone measured the async-vs-sync ORM overhead in their own benchmarks? I'd love to see numbers from a different workload than mine. Share in the comments 👇


If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.---
title: asyncpg vs SQLAlchemy ORM - The 9x Slowdown Nobody Warns You About
description: Async doesn't mean fast. Your ORM might be quietly throwing away everything asyncpg gives you.

tags: python, fastapi, postgres, performance

Async doesn't mean fast. Your ORM might be quietly throwing away everything asyncpg gives you.

We migrated a FastAPI service from sync SQLAlchemy to async SQLAlchemy, expecting a performance win. Async is supposed to be faster, right? Non-blocking I/O, more concurrent requests per worker, better resource utilization.

Our load test showed the opposite. The async version was slower per-request than the sync version had been. Not by a little: a specific endpoint that fetched 500 rows and mapped them to ORM objects went from 340ms to 890ms.

I assumed we'd misconfigured something. Wrong connection pool size, maybe, or a missing index. I profiled the endpoint with cProfile and found the answer: the async ORM layer itself was the bottleneck.

Object instantiation, identity map tracking, and attribute loading (the same overhead that exists in sync SQLAlchemy) was still there in async SQLAlchemy. We'd added the complexity of async without eliminating the cost of the ORM.

This isn't a reason to avoid async or avoid SQLAlchemy. It's a reason to understand exactly what you're paying for at each layer, so you can make an informed choice instead of assuming "async" automatically means "fast."


The Three Layers and What Each One Costs

When you fetch data from PostgreSQL in a modern async Python app, there are three layers between your code and the database:

Your code
  → SQLAlchemy ORM (optional)     ← object mapping, identity map, relationships
  → SQLAlchemy Core / Driver       ← query compilation, connection management
  → asyncpg or psycopg              ← the actual wire protocol to PostgreSQL
Enter fullscreen mode Exit fullscreen mode

Each layer adds capability and each layer adds overhead. Understanding where the overhead comes from tells you when it's worth paying and when it isn't.

Layer 1: The Driver (asyncpg vs psycopg 3)

At the bottom, you have the actual PostgreSQL wire protocol implementation. Two real choices in 2026:

  • asyncpg: built from scratch for asyncio. Speaks PostgreSQL's binary protocol directly. No general-purpose abstraction layer. This is the fastest way to talk to PostgreSQL from Python, full stop.
  • psycopg 3 (async mode): the modern rewrite of the classic psycopg2, with native async support. Slightly slower than asyncpg in raw benchmarks, but shares an API with sync psycopg, which matters if you're maintaining both sync and async code paths.
# Raw asyncpg - as fast as Python gets talking to PostgreSQL
import asyncpg

async def get_orders(user_id: int):
    conn = await asyncpg.connect(DATABASE_URL)
    try:
        rows = await conn.fetch(
            "SELECT id, total, status FROM orders WHERE user_id = $1", user_id
        )
        return [dict(row) for row in rows]
    finally:
        await conn.close()
Enter fullscreen mode Exit fullscreen mode
# psycopg 3 async - nearly as fast, more familiar API
import psycopg

async def get_orders(user_id: int):
    async with await psycopg.AsyncConnection.connect(DATABASE_URL) as conn:
        async with conn.cursor() as cur:
            await cur.execute(
                "SELECT id, total, status FROM orders WHERE user_id = %s", (user_id,)
            )
            rows = await cur.fetchall()
            return rows
Enter fullscreen mode Exit fullscreen mode

Both are dramatically faster than going through an ORM for simple queries. The difference between them is small enough that it rarely decides an architecture. asyncpg wins benchmarks by roughly 2x for raw throughput, but in a real application with network latency and business logic around the query, that gap shrinks to something you won't notice.

Layer 2: SQLAlchemy Core (No ORM)

SQLAlchemy Core gives you a query builder and connection management without object mapping. You write something close to SQL, but Python-native and composable:

from sqlalchemy import select, table, column
from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")

orders = table("orders", column("id"), column("total"), column("status"), column("user_id"))

async def get_orders(user_id: int):
    async with engine.connect() as conn:
        result = await conn.execute(
            select(orders).where(orders.c.user_id == user_id)
        )
        return result.fetchall()
Enter fullscreen mode Exit fullscreen mode

This adds a small amount of overhead over raw asyncpg (query compilation, parameter binding, connection pool management), but it's a fraction of what the full ORM costs.

Layer 3: The ORM (SQLAlchemy's Declarative Models)

The ORM adds object identity tracking, relationship loading, change tracking (dirty attribute detection), and Python object instantiation for every row:

from sqlalchemy import select
from sqlalchemy.orm import declarative_base, Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession

Base = declarative_base()

class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    total: Mapped[float]
    status: Mapped[str]
    user_id: Mapped[int]

async def get_orders(session: AsyncSession, user_id: int):
    result = await session.execute(select(Order).where(Order.user_id == user_id))
    return result.scalars().all()
Enter fullscreen mode Exit fullscreen mode

Same query. Same data. But now every row becomes a full Python object with SQLAlchemy's instrumentation attached:

  • Tracking whether each attribute has changed
  • Registering the object in the session's identity map, so session.get(Order, id) returns the same instance
  • Setting up lazy-loading machinery for any relationships

The Benchmark: What Each Layer Actually Costs

I ran this on a table with 100,000 rows, fetching 500 rows matching a filter, on a local PostgreSQL instance to eliminate network variance:

The jump from Core to ORM is the one that matters. Going from raw asyncpg to SQLAlchemy Core costs you a small, reasonable overhead for a massive gain in composability and safety. Going from Core to full ORM costs you almost 3x more on top of that, for object mapping you may not even need.

This matches what other benchmarks in the Python community have found: SQLAlchemy's ORM layer with async drivers can be an order of magnitude slower than raw driver access for simple read-heavy queries, primarily because of Python-level object construction overhead, not the database round trip itself.


Why the ORM Overhead Is So High With Async

Here's the part that surprised me most. You'd expect async to at least match sync ORM performance, since the actual database call is non-blocking either way. But in some benchmarks, async ORM queries are actually slower than sync ORM queries for CPU-bound work like object instantiation.

The reason: async adds its own overhead (event loop scheduling, coroutine creation, awaiting each step of the driver protocol) on top of the ORM's existing object-mapping cost.

  • For I/O-bound work (waiting on the network), async wins because the event loop can do other things while waiting.
  • But object instantiation isn't I/O-bound. It's pure CPU work happening on the same thread, and async adds bookkeeping around it without adding parallelism, because Python's GIL means you're not getting extra CPU time from asyncio. You're just avoiding blocking waits.

So the ORM's object-mapping tax is paid in full, and the async machinery adds a smaller additional tax on top, for operations that were never going to benefit from concurrency in the first place.


When Each Layer Is the Right Choice

Use raw asyncpg when...

You're building a narrow, performance-critical service: a data pipeline, an analytics endpoint, a service that streams large result sets. You control the SQL directly and don't need object mapping, relationships, or migrations tied to this code path.

# High-throughput analytics endpoint
@app.get("/api/analytics/daily-revenue")
async def daily_revenue(pool: asyncpg.Pool = Depends(get_pool)):
    async with pool.acquire() as conn:
        rows = await conn.fetch("""
            SELECT date_trunc('day', created_at) as day, SUM(total) as revenue
            FROM orders
            WHERE created_at >= NOW() - INTERVAL '30 days'
            GROUP BY day
            ORDER BY day
        """)
        return [dict(r) for r in rows]
Enter fullscreen mode Exit fullscreen mode

Use SQLAlchemy Core when...

You want composable, injection-safe query building without the ORM's object overhead. This is the sweet spot for most read-heavy API endpoints (list views, search, reporting) where you need dynamic filters but don't need to mutate and save objects back.

async def list_orders(db: AsyncSession, filters: OrderFilters):
    stmt = select(
        orders_table.c.id,
        orders_table.c.total,
        orders_table.c.status,
    ).where(orders_table.c.is_active == True)

    if filters.status:
        stmt = stmt.where(orders_table.c.status == filters.status)
    if filters.min_total:
        stmt = stmt.where(orders_table.c.total >= filters.min_total)

    result = await db.execute(stmt)
    return result.mappings().all()  # Returns dicts, not ORM objects
Enter fullscreen mode Exit fullscreen mode

result.mappings().all() is the key detail. It returns plain dictionaries instead of ORM entity objects, skipping the identity map and change-tracking machinery entirely while still using SQLAlchemy's query builder and connection pooling.

Use the full ORM when...

  • You're doing writes with relationships (creating an order with items in one transaction)
  • You need the identity map to prevent duplicate object instances across a request
  • You're using SQLAlchemy's relationship loading (selectinload, joinedload) to manage complex object graphs
  • Your team's productivity gain from the ORM's ergonomics outweighs the performance cost for that specific endpoint
async def create_order_with_items(db: AsyncSession, user_id: int, items: list[dict]):
    order = Order(user_id=user_id, status="pending")
    for item in items:
        order.items.append(OrderItem(
            product_id=item["product_id"],
            quantity=item["quantity"],
        ))
    db.add(order)
    await db.commit()
    await db.refresh(order)
    return order
Enter fullscreen mode Exit fullscreen mode

This is exactly where the ORM earns its overhead: cascading inserts, relationship management, and object lifecycle tracking that would take significantly more raw SQL and manual bookkeeping to replicate correctly.

Layer Best for Returns
Raw asyncpg Narrow, performance-critical paths, analytics, streaming Records / dicts
SQLAlchemy Core Read-heavy list, search and reporting endpoints Dicts via .mappings()
Full ORM Writes with relationships, complex object graphs Tracked ORM objects

The Hybrid Pattern I Actually Use

Most production FastAPI services benefit from using different layers for different endpoints, all sharing the same async engine and connection pool:

# app/database.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

engine = create_async_engine(
    "postgresql+asyncpg://user:pass@localhost/mydb",
    pool_size=10,
    max_overflow=20,
    pool_pre_ping=True,
)

AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db():
    async with AsyncSessionLocal() as session:
        yield session
Enter fullscreen mode Exit fullscreen mode
from sqlalchemy import text

# Writes and relationship-heavy reads: full ORM
@app.post("/api/orders")
async def create_order(data: OrderCreate, db: AsyncSession = Depends(get_db)):
    order = await order_service.create_order(db, data)  # Uses ORM
    return order

# High-volume list endpoints: SQLAlchemy Core, dicts not objects
@app.get("/api/orders")
async def list_orders(
    filters: OrderFilters = Depends(),
    db: AsyncSession = Depends(get_db),
):
    stmt = build_order_query(filters)  # Core query, no ORM mapping
    result = await db.execute(stmt)
    return result.mappings().all()

# Reporting and analytics: raw SQL via the same session
@app.get("/api/reports/summary")
async def report_summary(db: AsyncSession = Depends(get_db)):
    result = await db.execute(text("""
        SELECT status, COUNT(*) as count, SUM(total) as revenue
        FROM orders GROUP BY status
    """))
    return result.mappings().all()
Enter fullscreen mode Exit fullscreen mode

All three share the same AsyncSession and connection pool. You're not managing separate drivers or separate pools. You're choosing, per endpoint, how much of the ORM's machinery you actually need.


The One Setting That Changes Everything: expire_on_commit

If you do use the ORM, this default setting quietly causes extra queries you didn't ask for:

# Default behavior - expires all objects after commit
async_session = async_sessionmaker(engine)

order = await create_order(db, data)
await db.commit()
print(order.status)  # Triggers a NEW query to refresh the expired object!
Enter fullscreen mode Exit fullscreen mode

By default, SQLAlchemy marks every object's attributes as "expired" after a commit, so the next time you access them, it silently re-fetches from the database to guarantee freshness.

In a request-response API, where the session ends right after the commit, this is almost always wasted work. You're about to serialize the object and return it, not keep using it across a long-lived session.

# Disable expiration - trust the in-memory data after commit
async_session = async_sessionmaker(engine, expire_on_commit=False)
Enter fullscreen mode Exit fullscreen mode

expire_on_commit=False is the single highest-leverage SQLAlchemy async setting for API workloads.

I set it on every FastAPI project now. Without it, a simple "create and return" endpoint fires a COMMIT and then a silent SELECT to re-fetch data you already had in memory.


Bottom Line

Async doesn't erase the cost of the layers above the database driver. It just changes what kind of cost you're paying.

  • Raw asyncpg is fast because it does almost nothing beyond speaking PostgreSQL's protocol.
  • SQLAlchemy Core adds a thin, worthwhile layer of safety and composability.
  • The full ORM adds real, measurable overhead in exchange for object mapping, relationship management, and identity tracking: features that are essential for some endpoints and pure cost for others.

The mistake isn't choosing SQLAlchemy's ORM. It's using it uniformly across every endpoint without asking whether that endpoint needs what it provides. A list endpoint returning 500 rows of read-only data doesn't need an identity map. A checkout endpoint creating an order with five related line items absolutely does.

That endpoint that went from 340ms to 890ms after our sync-to-async migration? We didn't revert to sync. We kept async, dropped the ORM for that specific read path, and switched to result.mappings().all() with SQLAlchemy Core.

It came back down to 95ms: faster than the original sync version, using the same connection pool, the same database, and about fifteen lines of changed code.


Over to You

What's your database access pattern in async Python? Full ORM everywhere, raw SQL everywhere, or a hybrid like this? And has anyone measured the async-vs-sync ORM overhead in their own benchmarks? I'd love to see numbers from a different workload than mine. Share in the comments 👇


If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.

Top comments (0)