The Python Database Layering Model
A Python service talking to a database is really running through several stacked layers, even when a single session.query(...) line makes it look like one step.
At the bottom sits a database driver speaking that database's actual wire protocol; above it sits a connection pool deciding how many of those wire-level connections stay open at once; above that sits SQLAlchemy Core, translating Python expressions into SQL; and above Core sits the ORM, tracking object identity and generating that SQL for you.
Databases Basics shows the working code for each of those layers; this page is about why the stack is shaped this way, and where sync-vs-async and connection pooling actually sit inside it.
Summary
- Python database access is a stack of layers - a DB-API driver, a connection pool, SQLAlchemy Core's expression language, and the ORM - each adding structure on top of the layer below it rather than replacing it.
- Insight: Confusing which layer owns which job (is the ORM slow, or is the pool undersized, or is the driver blocking?) is the most common source of misdiagnosed database performance problems in Python services.
- Key Concepts: DB-API 2.0, connection pool, SQLAlchemy Core, ORM unit of work, sync vs async drivers, N+1 queries.
- When to Use: Choosing between raw DB-API, Core, and the ORM for a new service; diagnosing whether a slowdown is driver-level, pool-level, or query-pattern-level; deciding whether an async rewrite will actually help.
- Limitations/Trade-offs: Each added layer buys convenience and portability at the cost of some visibility into the exact SQL and timing happening underneath it.
- Related Topics: connection pool sizing, the N+1 query pattern, Alembic migrations, sync-to-async driver differences.
Foundations
PEP 249, commonly called DB-API 2.0, is a specification, not a library: it defines a common shape - connect(), a cursor() object, .execute(), .fetchall() - that most Python database drivers (psycopg, sqlite3, mysqlclient) implement independently.
That shared shape is why application code that talks to Postgres through psycopg looks structurally similar to code talking to MySQL through a different driver, even though the two drivers share no code and the databases speak entirely different wire protocols underneath.
A useful analogy is a standard plug shape: DB-API doesn't make every database identical any more than a common outlet shape makes every appliance identical, but it means anything built to plug into that shape - like SQLAlchemy - doesn't need a separate integration for every driver's quirks.
SQLAlchemy Core sits directly on top of DB-API drivers: it adds an engine (which owns a connection pool) and a Python expression language (select(), insert(), Table) that compiles down to the specific SQL dialect a given database expects.
The ORM is a further layer built on Core, not a separate path to the database: declarative classes still execute through Core's engine and connections, but the ORM adds a Session that tracks which Python objects correspond to which database rows (the identity map) and batches changes into a single flush instead of one statement per attribute change.
Mechanics & Interactions
Connection pooling exists because opening a database connection is expensive relative to running a query - it typically means a TCP handshake, sometimes TLS, and an authentication exchange before the first statement can run.
SQLAlchemy's default pool keeps a small number of real DB-API connections open and hands them out to whoever calls engine.connect() or runs a query, returning the connection to the pool (not closing it) when the surrounding with block or session ends.
requests ──▶ engine.connect() ──▶ [pool: N connections] ──▶ driver ──▶ database
▲ borrowed / idle
request N+1 ── waits in queue ──────┘ (all N currently checked out)
That means pool size is a concurrency ceiling you set, not a speed dial - a pool of 10 caps concurrent in-flight queries at 10 regardless of how fast the surrounding code runs, and that ceiling multiplies across every worker process: five Gunicorn workers each holding a pool of 10 can present 50 simultaneous connections to a database that may only allow 100 total.
Async access is where this stack gets a genuinely new wrinkle, not just a faster version of the same path.
asyncpg is an async-native driver with its own protocol implementation; it doesn't implement DB-API 2.0 at all, because DB-API is inherently a synchronous specification.
SQLAlchemy's create_async_engine bridges this by using greenlet under the hood: it lets the ORM's traditionally synchronous-looking unit-of-work code run against an async driver by transparently switching context at I/O points, so await session.execute(...) isn't literally the same code path as the sync Session, but a bridged version of it.
# create_async_engine talks to asyncpg (async-native, not DB-API)
engine = create_async_engine("postgresql+asyncpg://user:pass@host/db")
async with AsyncSession(engine) as session:
result = await session.execute(select(Item))
# greenlet lets this ORM-style call await an async-native driver underneathThat bridging is precisely why async SQLAlchemy has its own gotchas (lazy-loading a relationship outside an explicit await context raises, rather than silently blocking) - the sync-style ORM API and the async driver underneath are reconciled by SQLAlchemy, not naturally compatible on their own.
Advanced Considerations & Applications
The driver-vs-Core-vs-ORM choice is really about where you want visibility to live, and it's worth separating from the sync-vs-async choice, because the two decisions interact differently at each layer.
Raw DB-API gives full control over exactly what SQL runs and when, at the cost of writing and maintaining that SQL by hand, including its parameterization.
SQLAlchemy Core keeps that level of SQL control while adding dialect portability and protecting against injection through its expression language rather than string formatting.
The ORM trades some of that visibility for developer velocity and typed, object-shaped code, at the cost of hiding potentially expensive patterns - most notoriously the N+1 query problem, where accessing a lazy-loaded relationship inside a loop turns one intended query into one-plus-N round trips.
| Layer | Strength | Weakness | Best Fit |
|---|---|---|---|
| Raw DB-API driver | Maximum control over exact SQL and timing | No dialect portability; manual parameterization discipline required | Narrow, performance-critical scripts or a single-database tool |
| SQLAlchemy Core | Dialect-portable SQL, pool management, safe parameterization | Still means writing query logic by hand, just in Python objects | Bulk operations, migrations metadata, performance-sensitive queries |
| SQLAlchemy ORM | Identity map, typed models, less boilerplate for CRUD-heavy code | Can hide N+1 patterns and extra round trips behind convenient syntax | Application code with rich object relationships and CRUD-heavy flows |
| SQLModel | ORM ergonomics plus Pydantic-based request/response models in one class | Younger project, tighter coupling between API schema and DB schema | FastAPI services wanting one model definition for both layers |
Neither sync nor async is universally "the fast one": async helps when a service is I/O-bound and needs to hold many concurrent requests without blocking a worker thread on each one, but it does nothing for CPU-bound work, and an async engine with an oversized pool still hits the same database connection ceiling a sync engine would.
Migration tooling (Alembic) sits alongside this stack rather than inside it: it reads the same Core/ORM metadata to generate schema diffs, which is why keeping models and migrations in sync matters more as the stack above the database grows.
Common Misconceptions
- "Async database access is always faster." It helps concurrency under I/O-bound load by freeing a worker while waiting on the database, but it adds no benefit for CPU-bound work and doesn't raise the database's own connection ceiling.
- "The ORM replaces Core, and Core replaces the driver." Each layer sits on top of the one below it - the ORM's
Sessionstill executes through Core's engine and a pooled DB-API (or async-native) connection underneath. - "A bigger connection pool is always safer." Past a point it just shifts the bottleneck to the database's own connection limit, which every worker process shares - oversized pools across several workers are a common cause of "database refusing connections" incidents.
- "
asyncpgis just an async version ofpsycopg." They're independently implemented drivers with different protocol handling;psycopg(v3) actually supports both sync and async in one driver, whileasyncpgis async-only and was never a DB-API implementation to begin with. - "SQLAlchemy's
Sessionis safe to share across concurrent requests." ASessionis not thread-safe by default and is meant to be scoped to one unit of work (typically one request); sharing one across concurrent requests risks corrupting its identity map.
FAQs
What is DB-API 2.0 and why does it matter?
It's PEP 249, a specification defining a common shape (connect(), cursor(), .execute()) that most Python database drivers implement independently. It's why tools like SQLAlchemy can support many databases without a bespoke integration for each driver's internals.
Does SQLAlchemy Core replace the database driver?
No - Core sits on top of a DB-API driver, adding an engine, connection pool, and SQL expression language. The driver still does the actual wire-protocol communication underneath.
Is the ORM a separate way to reach the database from Core?
No - the ORM's Session executes through the same engine and pooled connections Core uses. It adds an identity map and unit-of-work tracking on top, not an alternate connection path.
Why does async database access need something like greenlet?
Because SQLAlchemy's ORM API is written in a traditionally synchronous style, but async-native drivers like asyncpg aren't DB-API implementations at all. Greenlet lets that sync-style ORM code run against an async driver by switching context at I/O points, rather than the two being naturally compatible.
What actually determines how much concurrent database work my service can do?
Usually the connection pool size, not raw driver or query speed - a pool of N connections caps in-flight queries at N regardless of how fast the code around it runs. That ceiling also multiplies across every worker process running its own pool.
Why can five workers with pool size 10 still overwhelm a database that allows 100 connections?
Because 5 workers times a pool of 10 each is 50 possible connections, and that's before accounting for other services or replicas sharing the same database's connection limit. The math has to account for total instances, not just one process's pool setting.
What's the N+1 query problem, concretely?
Fetching a list of N rows and then separately fetching each row's related data one at a time, turning one intended query into N+1 round trips. It's most common when a lazy-loaded ORM relationship is accessed inside a loop without eager loading.
Should I always reach for the ORM instead of Core or raw DB-API?
Not necessarily - the ORM trades some visibility into exact SQL for less boilerplate on CRUD-heavy, object-relationship-rich code. Bulk operations, migrations metadata, or performance-critical queries often fit Core or raw DB-API better.
Is `psycopg` the same kind of driver as `asyncpg`?
No - psycopg (version 3) implements DB-API 2.0 and supports both sync and async modes in one driver, while asyncpg is an async-native driver that never implemented DB-API at all, since DB-API is inherently synchronous.
Can I share one SQLAlchemy `Session` across multiple concurrent requests?
No - a Session is not thread-safe by default and is meant to scope to one unit of work, typically one request or one task. Sharing it across concurrent requests risks corrupting its identity map and transaction state.
Does connection pooling mean I don't need to close sessions or connections explicitly?
No - you still need to use context managers or explicitly close a session/connection so it's returned to the pool. Skipping that leaves a connection checked out indefinitely, which starves the pool for other requests even though the underlying socket stays open.
Where does Alembic fit into this layering model?
It sits alongside the stack rather than inside it, reading the same Core or ORM metadata to generate schema diffs and apply them as migrations. It doesn't change how the application talks to the database at runtime.
Related
- Databases Basics - hands-on DB-API, engine, and ORM examples
- SQLAlchemy Core - the expression language and connection pool in detail
- SQLAlchemy ORM 2.0 - Session, identity map, and declarative models
- Async Database Access - async engines, async sessions, and greenlet bridging
- Connection Management - pool sizing, retries, and transaction scope
- Query Patterns & N+1 - diagnosing and fixing the N+1 pattern
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13).