Skip to content

How SQLAlchemy Bridges Sync and Async with Greenlet

SQLAlchemy's ORM and Core were written as synchronous code, and its asyncio support did not rewrite them. Instead, AsyncSession and AsyncConnection run the synchronous internals inside a greenlet, and when the internals need I/O, the greenlet switches back to the event loop, which awaits the async driver and switches back with the result. Understanding that bridge explains both its cost and its best-known error. Measured with SQLAlchemy 2.1.2, greenlet 3.5.6 and asyncpg 0.31.0 against Postgres 17, fetching one row by primary key: raw asyncpg took 83 µs, SQLAlchemy Core through AsyncConnection 142 µs, and an ORM AsyncSession.get 224 µs. One ORM get made 4 greenlet switches, and a greenlet switch on its own cost 153 ns — so the bridge itself accounted for under 1 µs of the 224; the rest was SQLAlchemy's own work. Touching a lazy-loaded relationship inside async code raised MissingGreenlet: greenlet_spawn has not been called; can't call await_() here, while the same access inside run_sync worked. For comparison, synchronous SQLAlchemy with psycopg took 282 µs per query called directly — blocking the loop — and 337 µs through asyncio.to_thread. This guide explains the mechanism and how to work with it.

Prerequisites

1. Understand what the greenlet is for

A coroutine can only suspend at an await, and only an async def function can contain one. SQLAlchemy's ORM is deep, synchronous code — session flush logic, loaders, unit of work — with no awaits, so it cannot suspend in the middle of a query. Greenlets can: a greenlet is a separate stack that can be switched away from at any point, even inside synchronous code. SQLAlchemy uses that to run its synchronous core in a greenlet started by greenlet_spawn, and to make its async drivers look synchronous to that core:

# what the bridge does, simplified
async def execute(self, statement):
    return await greenlet_spawn(self._sync_session.execute, statement)   # run sync code in a greenlet

def await_only(awaitable):                       # called from inside the sync code
    current = greenlet.getcurrent()
    return current.parent.switch(awaitable)      # hand the awaitable to the loop side, wait for the result

When the synchronous code needs a database round trip, the driver adapter calls await_only(...), which switches to the parent greenlet — the coroutine side — where greenlet_spawn awaits the real asyncpg call and switches back with its result. Measured with a greenlet tracer: one ORM AsyncSession.get made 4 switches. The event loop never blocks; the synchronous code never knows it was suspended.

Verify: you can point at where your async SQLAlchemy calls cross from coroutine to synchronous code — every await session.<method>().

One await session.get() through the greenlet bridge A sequence of 7 messages between 4 participants. One await session.get() through the greenlet bridge coroutine greenlet_spawn sync ORM (greenlet) asyncpg on the loop await session.get(Book, 5) switch: run sync get() await_only(fetch): switch await fetch rows switch back with rows Book instance Measured: 4 greenlet switches per ORM get, 153 ns each.

2. Know where the time goes

The bridge sounds expensive, but switching greenlets is cheap; the cost of async SQLAlchemy is mostly SQLAlchemy:

for i in range(n):
    await raw.fetchrow("SELECT id, title, author_id FROM books WHERE id = $1", i)    # asyncpg
for i in range(n):
    (await conn.execute(stmt, {"id": i})).first()                                     # Core
for i in range(n):
    await session.get(Book, i, populate_existing=True)                                # ORM

Measured over 2,000 single-row lookups: 83 µs with raw asyncpg, 142 µs with Core, 224 µs with the ORM. A greenlet switch measured 153 ns in a ping-pong test, so 4 switches account for about 0.6 µs. The remaining difference — about 60 µs for Core and 140 µs for the ORM — is statement compilation, result processing and identity-map work, which the synchronous ORM does too. For request handlers making a handful of queries, that difference is small next to network and database time; for bulk processing, Core or raw driver calls are worth it, as discussed in Async Database Drivers.

Verify: per-query overhead is measured for the access style each hot path uses.

Microseconds per single-row lookup 5 horizontal bars comparing asyncpg fetchrow with the others. Microseconds per single-row lookup asyncpg fetchrow 83 us SQLAlchemy Core, async 142 us SQLAlchemy ORM get, async 224 us sync SQLAlchemy + psycopg (blocks loop) 282 us sync SQLAlchemy + psycopg via to_thread 337 us Postgres 17 in Docker on the same host; 2,000 lookups each; greenlet switch = 153 ns. The greenlet bridge is under 1 us of the ORM's 224.

3. Avoid implicit I/O: the MissingGreenlet error

The bridge only works while synchronous code runs inside greenlet_spawn. Attribute access on an ORM object is ordinary Python, outside any await — so when it triggers a lazy load, the driver adapter has no greenlet to switch from:

async with Session() as session:
    book = await session.get(Book, 1)
    book.author.name          # lazy load outside greenlet_spawn

Measured: MissingGreenlet: greenlet_spawn has not been called; can't call await_() here. Was IO attempted in an unexpected place?. The fix is to make every load explicit: eager-load relationships the code will use, or await them:

from sqlalchemy.orm import selectinload

stmt = select(Author).options(selectinload(Author.books))       # loaded with the query
authors = (await session.execute(stmt)).scalars().all()

author = await book.awaitable_attrs.author                       # explicit async lazy load (AsyncAttrs mixin)

Measured: selectinload returned authors with their 10 books each, and awaitable_attrs.author returned the author, both without error. Setting lazy="raise" on relationships turns any accidental lazy load into an immediate, clear error in tests rather than a MissingGreenlet in production. Expired attributes after a commit trigger the same error; expire_on_commit=False on the session factory avoids it for objects used after commit.

Verify: relationships are declared with lazy="raise" or always loaded explicitly, and tests touch every attribute the code reads.

4. Use run_sync for synchronous code that does I/O

Some code is synchronous by nature — a legacy helper, a library that walks relationships, metadata.create_all. run_sync runs a synchronous function inside the bridge, where implicit I/O works again:

def count_books(sync_session, author_id: int) -> int:
    author = sync_session.get(Author, author_id)
    return len(author.books)                   # lazy load: fine inside run_sync

n = await session.run_sync(count_books, 2)    # 10

async with engine.begin() as conn:
    await conn.run_sync(Base.metadata.create_all)

Measured: the lazy load that raised MissingGreenlet outside returned 10 inside run_sync. The function runs on the event loop's thread, in a greenlet, switching out for each query — so it does not block the loop during database I/O, but any CPU-heavy or truly blocking non-database work inside it does. run_sync is the bridge's escape hatch for code you cannot rewrite; it is not a way to run arbitrary blocking code, for which asyncio.to_thread remains the tool, as in running blocking SDK calls with asyncio.to_thread.

Verify: synchronous helpers that touch the ORM are called through run_sync, not directly from coroutines.

5. Choose async SQLAlchemy, sync in a thread, or the raw driver

Three ways to use a relational database from asyncio, with measured per-query cost and their trade-offs:

# async SQLAlchemy: ORM and Core, non-blocking, explicit loading required
async with AsyncSession(engine) as s:
    book = await s.get(Book, 1)

# sync SQLAlchemy in a thread: unchanged sync code, a thread per concurrent call
row = await asyncio.to_thread(sync_query, 1)

# raw driver: fastest, no ORM
row = await conn.fetchrow("SELECT ... WHERE id = $1", 1)

Measured per lookup: 224 µs for the async ORM, 337 µs for sync SQLAlchemy via to_thread — which also occupies a thread from the default executor for each concurrent query, capping concurrency at its size — and 83 µs for asyncpg. Calling the sync engine directly from a coroutine, at 282 µs, blocks the event loop for every query and should never happen in a service. Async SQLAlchemy is the right default for applications built on its ORM; to_thread is a migration path for large synchronous codebases; and raw driver calls belong in hot paths where the ORM's overhead shows up in profiles.

Verify: no coroutine calls a synchronous engine directly, and the access style for each hot path is chosen from measured cost.

How should this async code talk to the database? A decision on What does the code look like with 4 outcomes. How should this async code talk to the database? What does the code look like? ORM models, new async code AsyncSession + selectinload 224 us/get sync helper walking relationships session.run_sync(fn) lazy loads work large sync codebase, migrating sync engine via to_thread 337 us, thread per call hot path, overhead visible Core or asyncpg 142 / 83 us Never call the sync engine directly from a coroutine.

Verification

Async SQLAlchemy is used correctly when:

  • Every database access is an explicit await, with relationships eager-loaded or awaited.
  • Lazy loading is disabled or raises in tests, so MissingGreenlet cannot reach production.
  • Synchronous ORM helpers run through run_sync, and blocking non-database code through to_thread.
  • Per-query overhead is measured for hot paths, and Core or the raw driver is used where it matters.

Diagnostic Hook: search logs for MissingGreenlet. Each occurrence names a code path that performed implicit I/O — almost always an attribute access on an expired or lazily loaded object — and the stack trace's last application frame is the attribute to load explicitly.

Pitfalls & edge cases

  • Lazy-loaded relationships in async code. Measured: MissingGreenlet.
  • Expired attributes after commit. Same error; use expire_on_commit=False or reload.
  • Blaming greenlets for ORM overhead. Measured: under 1 µs of 224.
  • Sync engines called from coroutines. Measured: 282 µs of loop blocking per query.

Frequently Asked Questions

How does SQLAlchemy asyncio work?

It runs SQLAlchemy's synchronous core inside a greenlet; when the core needs I/O, the async driver adapter switches back to the coroutine, which awaits the real driver and switches back with the result. One ORM get made 4 greenlet switches in testing.

What does MissingGreenlet mean?

Synchronous SQLAlchemy code tried to do I/O outside greenlet_spawn, usually a lazy load triggered by attribute access in async code. Eager-load with selectinload, await awaitable_attrs, or use run_sync.

Is async SQLAlchemy slower than asyncpg?

Yes: 142 µs per Core query and 224 µs per ORM get against 83 µs for raw asyncpg in testing; greenlet switching accounted for under 1 µs of that.

Should I run sync SQLAlchemy in a thread instead?

It works (337 µs per query here) but uses a thread per concurrent query; prefer AsyncSession for new code and keep to_thread as a migration path.