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:

# 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.

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.

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.

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:

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.