Fixing GreenletSpawnError in Async SQLAlchemy Workflows

The fix is to stop calling synchronous ORM operations from an async def context — replace them with await session.execute(select(...)) and await session.scalars(...), set expire_on_commit=False in async_sessionmaker, and eager-load every relationship you need before the async context closes. Full request lifecycle patterns live in the guide to integrating SQLAlchemy async with FastAPI and Starlette.

Quick Answer

The error MissingGreenlet: greenlet_spawn has not been called (also written as GreenletSpawnError in older SQLAlchemy versions) means SQLAlchemy attempted a synchronous DBAPI call inside an async context where no greenlet bridge was active. The before/after migration is:

The error is a missing await, one frame away A four-stage explanation. Something touched an ORM attribute — order.items, or any expired column after a commit. That attribute is not loaded, so SQLAlchemy needs to emit SQL to fetch it. Emitting SQL from an AsyncSession requires running inside the greenlet that greenlet_spawn established, because that is the only way the synchronous ORM internals can suspend and await the driver. The access happened outside that greenlet, so there is nothing to suspend into, and MissingGreenlet is raised. The fix is always to make the data present before the access: an eager loader option, an explicit await session.refresh, or expire_on_commit set to False. an ORM attribute is touched order.items, or a column expired by commit no I/O intended by the caller the attribute is not loaded SQLAlchemy must emit SQL to get it a lazy load is triggered emitting SQL needs the greenlet set up by greenlet_spawn in AsyncSession the only way to suspend and await the access ran outside it → MissingGreenlet fix: load it earlier, do not catch this Three fixes, in order of preference: an eager loader option on the original query, an explicit await session.refresh(obj, [...]), or expire_on_commit=False on the sessionmaker.
# Legacy — triggers MissingGreenlet inside async def
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

engine = create_engine("postgresql+psycopg2://user:pass@localhost/db")
SessionLocal = sessionmaker(bind=engine)


def get_users(session: Session) -> list:
    # session.query() is a 1.x pattern; still works in sync but not in AsyncSession
    return session.query(User).filter(User.is_active == True).all()
# Async 2.0 — correct pattern; all I/O operations are awaited
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy import select

DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/db"

engine = create_async_engine(DATABASE_URL, pool_size=10, max_overflow=5, pool_pre_ping=True)
async_session_factory = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,  # prevents MissingGreenlet on attribute access after commit
)


async def get_users(session: AsyncSession) -> list:
    result = await session.scalars(select(User).where(User.is_active.is_(True)))
    return list(result.all())

Every database interaction must be await-ed. There is no synchronous fallback inside AsyncSession.

Execution Context & Async Workflow Integration

How the greenlet bridge works

SQLAlchemy's async layer wraps the synchronous DBAPI protocol using greenlet — a lightweight cooperative threading primitive. When your code calls async with async_session_factory() as session, SQLAlchemy establishes a greenlet execution frame for that session. All ORM operations that must call into the DBAPI run inside that frame. At each DBAPI await point, the greenlet suspends, the asyncio event loop picks up another ready coroutine, and resumes this greenlet when the I/O completes.

The greenlet frame is active only while you are inside the async with async_session_factory() as session context and during an active await session.execute(...) call. Outside those scopes, the frame is gone. Any attempt to trigger DBAPI I/O when no frame is active raises MissingGreenlet.

Greenlet frame lifetime in one async request A timeline showing that while control is inside the "async with async_session_factory() as session" block the greenlet frame is active and every awaited DBAPI call (execute, commit) is bridged safely; when the block exits the frame is torn down, so reading an expired attribute afterwards triggers a lazy SELECT with no active frame and raises MissingGreenlet. Greenlet frame lifetime in one async request Why a lazy attribute access after the block raises MissingGreenlet async with async_session_factory() as session: greenlet frame ACTIVE SQLAlchemy bridges every awaited DBAPI call await session.execute(select(User)) ✓ safe await session.commit() ✓ safe expire_on_commit=False keeps attributes loaded after the block exits NO greenlet frame code reads user.email → lazy SELECT attempted MissingGreenlet greenlet_spawn has not been called time enter async with frame torn down MissingGreenlet raised

The three root causes

Root cause 1 — Wrong engine or session type. You created an AsyncEngine with create_async_engine but used a plain synchronous Session against it, or you used create_engine (sync) and tried to await calls on it. The mismatch means no greenlet bridge was ever established. If you are unsure your engine is even async-capable, verify it against the async engine and connection pool configuration reference before debugging further.

Root cause 2 — Expired attribute access after commit. When async_sessionmaker is built with the default expire_on_commit=True, SQLAlchemy marks every attribute on every ORM instance as expired after await session.commit(). The next time code reads user.email, SQLAlchemy tries to emit a lazy SELECT. That lazy SELECT requires a greenlet frame that no longer exists — the session's async context has already exited. This is the most common cause of the error in FastAPI apps that appear structurally correct but fail at response serialization time; setting expire_on_commit=False in FastAPI dependencies removes this failure mode for an entire app in one place.

Root cause 3 — Lazy relationship access. SQLAlchemy's default relationship loading strategy is lazy="select" — access the collection, get a SELECT. This lazy load is synchronous. Inside AsyncSession, accessing order.items without having specified .options(selectinload(Order.items)) on the original query raises MissingGreenlet because there is no active greenlet frame for a spontaneous lazy I/O call.

Fixing root cause 2: expire_on_commit=False

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

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

# expire_on_commit=False: attributes retain pre-commit in-memory values.
# Pydantic serializers, logging, and response construction after commit all work.
async_session_factory = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
)

The trade-off: attributes reflect the Python-side values from before the commit, not any DB-generated changes (triggers, DEFAULT expressions, sequences). If you need a DB-generated value — such as an invoice_number set by a trigger — refresh it explicitly before the session closes:

from sqlalchemy.ext.asyncio import AsyncSession
from myapp.models import Invoice


async def create_invoice(session: AsyncSession, data: dict) -> Invoice:
    invoice = Invoice(**data)
    session.add(invoice)
    await session.flush()  # assigns invoice.id from the sequence within the txn

    # Refresh only the trigger-populated column before the dependency commits
    await session.refresh(invoice, attribute_names=["invoice_number"])
    return invoice  # invoice_number is now populated; expire_on_commit=False keeps it

Fixing root cause 3: eager loading relationships

# WRONG — lazy relationship access raises MissingGreenlet
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from myapp.models import Order


async def get_order_bad(session: AsyncSession, order_id: int) -> Order:
    order = await session.scalar(select(Order).where(Order.id == order_id))
    # Accessing .items here triggers a lazy SELECT — no greenlet frame — MissingGreenlet
    print(len(order.items))
    return order


# CORRECT — selectinload populates .items within the async context
from sqlalchemy.orm import selectinload


async def get_order_good(session: AsyncSession, order_id: int) -> Order:
    stmt = (
        select(Order)
        .where(Order.id == order_id)
        .options(selectinload(Order.items))  # emits SELECT ... WHERE order_id IN (...)
    )
    return await session.scalar(stmt)

selectinload emits a second SELECT ... WHERE order_id IN (...) immediately, within the async context, and populates the items collection synchronously from the already-fetched rows. No lazy load is triggered later.

If you forgot to eager-load and need the relationship on an already-loaded instance, use session.refresh:

# Refresh a specific relationship without re-loading the entire object
await session.refresh(order, attribute_names=["items"])

Wrapping unavoidable synchronous code

Third-party libraries, legacy repository functions, or Alembic-style schema introspection may require synchronous DBAPI access. Use session.run_sync to offload the call:

from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession


async def run_legacy_reporting_query(session: AsyncSession) -> Any:
    def _sync_fn(conn: Any) -> Any:
        # This callable runs inside a managed greenlet frame.
        # Synchronous DBAPI calls are safe here.
        return conn.execute(text("SELECT pg_database_size(current_database())")).scalar()

    return await session.run_sync(_sync_fn)

run_sync receives a callable that accepts a synchronous Connection. SQLAlchemy establishes the greenlet bridge for the duration of that callable and cleans it up when the callable returns. Never await inside the callable — it is not an async context.

Resolving Warnings, Errors & Common Mistakes

Exact error stringRoot causeProduction fix
sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() hereSync ORM operation attempted in an async context with no greenlet frame activeReplace with await session.execute(select(...)) / await session.scalars(...), or use await session.run_sync(fn)
MissingGreenlet raised during Pydantic serialization / model_validateexpire_on_commit=True (default) expired attributes after commit; lazy SELECT attempted outside async contextasync_sessionmaker(..., expire_on_commit=False)
MissingGreenlet on order.items or any relationship attributeRelationship configured with default lazy="select"; no selectinload/joinedload on the queryAdd .options(selectinload(Model.relationship)) to the query, or await session.refresh(obj, attribute_names=["relationship"])
sqlalchemy.exc.InvalidRequestError: This session is provisioning a new connection; concurrent operations are not permittedTwo coroutines accessing the same AsyncSession simultaneouslyOne AsyncSession per coroutine; never share across asyncio.gather() tasks
AttributeError: 'coroutine' object has no attribute 'scalars'session.execute(stmt) used without await, returning a coroutine object instead of a ResultAlways write result = await session.execute(stmt)
greenlet.error: cannot switch to a different threadAsyncSession passed to asyncio.to_thread() and used from a different OS threadOpen a new session inside asyncio.to_thread(); never pass AsyncSession across thread boundaries
sqlalchemy.exc.IllegalStateChangeError on session.commit()Called sync session.commit() instead of await session.commit()Add await before every ORM state-mutation method: commit, rollback, flush, refresh, merge
sqlalchemy.exc.DetachedInstanceError: Instance <User> is not bound to a SessionObject accessed after its session was closed and expire_on_commit=True had expired its attributesexpire_on_commit=False; or await session.refresh(obj) before the session closes

Advanced Async Optimization

Detecting blocking calls with asyncio debug mode

Python's asyncio debug mode logs a warning for any coroutine that blocks the event loop for more than 100ms. Enable it during development to catch synchronous leaks before they reach production:

Four places it appears, and the fix for each Four recurring sites. Response serialisation after a commit is the most common: the commit expired every attribute and the serialiser touches them, so set expire_on_commit False. A background task that captured an object from the request has outlived the session that owned it, so it must re-query by primary key in its own session. A __repr__ that formats a relationship turns any log line or debugger inspection into a lazy load, so keep repr to columns only. And a list comprehension over query results that reaches into a relationship performs one lazy load per row, which is the N+1 shape wearing an exception — add the loader option to the original select. serialising after commit commit expired every attribute fix: expire_on_commit=False the most common site a background task holds an object past its session fix: re-query by primary key in its own session __repr__ touching a relationship every log line becomes a query fix: repr columns only and it raises under async comprehension over results one lazy load per row fix: loader option on the select N+1 wearing an exception All four share one shape: an attribute is read at a point where no one expected I/O. Deciding what is loaded at query time, once, removes the whole class of failure.
import asyncio
import logging

# Set in the process entrypoint or conftest.py:
logging.basicConfig(level=logging.DEBUG)

# Or use the environment variable — no code change required:
# PYTHONASYNCIODEBUG=1 uvicorn myapp.main:app --reload

# Tighten the warning threshold from 0.1s to 50ms during stress testing:
loop = asyncio.get_event_loop()
loop.slow_callback_duration = 0.05

Any synchronous SQLAlchemy call that blocks the event loop longer than the threshold appears in the logs with a full stack trace pointing to the exact ORM call responsible.

NullPool for serverless and short-lived runtimes

In AWS Lambda, Google Cloud Run, or any short-lived execution environment, connection pooling is counterproductive. Each function invocation creates its own pool, connections are not shared between invocations, and idle connections accumulate in the database server until they are forcibly closed. Use NullPool to disable pooling entirely:

from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool


engine = create_async_engine(
    "postgresql+asyncpg://user:pass@localhost/db",
    poolclass=NullPool,  # every checkout opens a new connection; every release closes it
)

NullPool eliminates idle connection leaks and avoids MissingGreenlet errors from recycled connections that have accumulated unexpected state between Lambda invocations. The cost is one TCP handshake per request — acceptable in serverless where requests are infrequent and connection overhead is small relative to cold-start latency.

Detecting stale engines with pool_pre_ping

Without pool_pre_ping=True, a checked-out connection that the database server closed server-side (idle timeout, failover, restart) raises OperationalError on the first query of the new request. With pool_pre_ping, SQLAlchemy issues a lightweight SELECT 1 before handing the connection to your code. If the ping fails, the pool discards that connection and checks out another. See handling connection leaks and pool exhaustion for the full diagnostic strategy.

Profiling greenlet overhead

In high-throughput applications, the overhead of greenlet context switches can add measurable latency. Profile with py-spy or yappi (which supports async-aware profiling). Key signals: if greenlet.switch appears high in the profile and you are not using run_sync, the session may be making unexpectedly many DBAPI calls (N+1 query pattern). The solution is query consolidation with selectinload rather than reducing greenlet usage.

Frequently Asked Questions

Why does MissingGreenlet only appear after session.commit(), not during the query?

The query itself runs inside the async session's active greenlet frame, so the DBAPI call is safe. After commit(), SQLAlchemy expires all attributes on tracked instances (when expire_on_commit=True). When code reads an expired attribute later — outside the session's async context manager — SQLAlchemy tries to emit a lazy SELECT with no active greenlet frame, raising MissingGreenlet.

Can I disable the greenlet dependency entirely?

No. greenlet is a required runtime dependency of SQLAlchemy's async extension (sqlalchemy[asyncio]). The goal is not to eliminate greenlets but to ensure all DBAPI calls happen inside a managed greenlet frame — which AsyncSession and async_sessionmaker provide automatically as long as you use await correctly and do not access expired attributes outside the session context.

Does switching from psycopg2 to asyncpg fix the error automatically?

No. The async driver controls network I/O, but MissingGreenlet is an ORM and session-layer error. Switching to asyncpg without also migrating to create_async_engine and AsyncSession still raises the error, just at a different call site. Both changes are required.

How do I load a relationship that I forgot to eager-load, without rewriting the original query?

Call await session.refresh(obj, attribute_names=["items"]). This issues a new SELECT for just the named relationship within the current async context, populating the attribute without a lazy-load trigger. You can call refresh on multiple attributes in one call: await session.refresh(user, attribute_names=["orders", "profile"]).

Is session.run_sync safe to use in production?

Yes, with caveats. run_sync executes the synchronous callable in the current thread (not a thread pool) — it is not asyncio.to_thread(). This means it blocks the event loop for the duration of the synchronous call if that call does significant CPU work or if it calls synchronous blocking I/O not mediated by the greenlet bridge. Use run_sync for legacy DBAPI operations that SQLAlchemy knows how to bridge; for CPU-heavy work, use asyncio.to_thread() with a fresh, independently-opened synchronous connection.