sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here means you touched a relationship or expired column that wasn't loaded, and SQLAlchemy tried to run a lazy-load query from synchronous attribute access inside an async session. The durable fix is to decide at query time which relationships you need (selectinload or joinedload) instead of letting attribute access trigger I/O. Use awaitable_attrs for the occasional one-off, and lazy="raise" to turn the bug into a loud, early failure. (Written against SQLAlchemy 2.0.x as of late 2026.)
Why does MissingGreenlet happen?
In a sync session, author.books on an unloaded relationship quietly emits a SELECT and returns the list. That works because attribute access is a plain Python call and the driver call blocks.
In an async session, the driver call must be awaited, but author.books is not awaitable. SQLAlchemy's async layer runs sync-style ORM code inside a greenlet so that it can bridge to await. If you trigger the lazy load outside that bridge, for example in your route handler after the query returned, there is no greenlet to switch to and you get MissingGreenlet.
Here is a minimal reproduction:
import asyncio from sqlalchemy import ForeignKey, select from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(AsyncAttrs, DeclarativeBase): pass class Author(Base): __tablename__ = "author" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] books: Mapped[list["Book"]] = relationship(back_populates="author") class Book(Base): __tablename__ = "book" id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] author_id: Mapped[int] = mapped_column(ForeignKey("author.id")) author: Mapped[Author] = relationship(back_populates="books") engine = create_async_engine("sqlite+aiosqlite:///:memory:") Session = async_sessionmaker(engine) async def main(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with Session() as session: session.add(Author(name="Ada", books=[Book(title="Notes")])) await session.commit() async with Session() as session: author = (await session.execute(select(Author))).scalars().one() print(author.books) # MissingGreenlet asyncio.run(main()) (This needs aiosqlite installed. Any async driver reproduces it.)
What tripped me up the first time: the failing line looks completely innocent. Nothing in it says "database". The query was written ten lines earlier.
Takeaway: in async SQLAlchemy, every attribute access that might hit the database must be loaded before you read it.
What are the three ways to fix it?
1. Eager-load at query time (default choice)
from sqlalchemy.orm import selectinload stmt = select(Author).options(selectinload(Author.books)) author = (await session.execute(stmt)).scalars().one() print([b.title for b in author.books]) # no extra I/O selectinload issues one additional SELECT with an IN list of parent keys, which avoids the row duplication that a join creates for collections. joinedload fetches everything in one statement and is a better fit for many-to-one relationships like Book.author.
2. awaitable_attrs for one-off access
Because Base mixes in AsyncAttrs, you can await a lazy load explicitly:
books = await author.awaitable_attrs.books This is honest about the I/O, which is nice. The drawback is that inside a loop it becomes an N+1 query pattern that is easy to write and hard to spot in review.
3. lazy="raise" to make the mistake impossible to miss
books: Mapped[list["Book"]] = relationship( back_populates="author", lazy="raise" ) Now any unloaded access raises InvalidRequestError naming the attribute, rather than a vague greenlet message, and it fires in tests instead of in one rarely used endpoint. You opt in per query with selectinload or joinedload. The cost is some boilerplate on every query that needs the relationship.
| Approach | Extra queries | Fails loudly if forgotten | Best for |
|---|---|---|---|
selectinload | 1 per relationship | Only with lazy="raise" | Collections (one-to-many, many-to-many) |
joinedload | 0 (one JOIN) | Only with lazy="raise" | Many-to-one, single related row |
awaitable_attrs | 1 per access | n/a (explicit) | Scripts, rare branches |
lazy="raise" default | n/a | Yes | Teams that want N+1 caught in tests |
Takeaway: pick loading strategy per query, and set lazy="raise" on the model so forgetting is a test failure rather than a production 500.
Why do I also get MissingGreenlet after commit()?
Same error, different trigger. By default commit() expires every loaded attribute on every object in the session. The next time you read author.name, SQLAlchemy wants to refresh it with a SELECT, which fails outside the greenlet bridge:
async with Session() as session: author = Author(name="Grace") session.add(author) await session.commit() print(author.name) # MissingGreenlet: attribute was expired by commit Two fixes. Either configure the sessionmaker so objects stay usable after commit:
Session = async_sessionmaker(engine, expire_on_commit=False) or call await session.refresh(author) when you really need fresh values. With expire_on_commit=False the trade-off is that your in-memory objects can go stale relative to the database (server-side defaults and triggers won't show up until you refresh). For typical request-scoped sessions that live for one HTTP request, I have found that acceptable, but it is a real trade-off, not a free setting.
A related mistake: returning ORM objects from a handler after the session closes and letting a serializer walk the relationships. The serializer triggers the lazy load on a detached object. Convert to plain schemas inside the session scope, or eager-load everything the serializer will touch.
Takeaway: commit() expires attributes by default, so expire_on_commit=False is the standard async setting, with staleness as the price.
How do I debug which attribute triggered it?
The traceback usually points at the access site, but not at which relationship. Three quick moves:
- Temporarily set
lazy="raise"on all relationships. The exception names the exact attribute. - Turn on
echo=Trueon the engine and look for a SELECT that appears where you expected none. - Check whether the object is coming from a different session, such as one cached or passed between tasks. Sessions are not safe to share across concurrent tasks.
If you use SQLModel or a FastAPI template that wraps SQLAlchemy, the same rules apply, since they sit on top of the same async layer.
FAQ
Why does SQLAlchemy say "greenlet_spawn has not been called"?
You accessed an unloaded attribute that requires a database query from synchronous code inside an async session. Eager-load the attribute with selectinload or joinedload, or await it through awaitable_attrs.
Should I use selectinload or joinedload with async SQLAlchemy?
Use selectinload for collections and joinedload for many-to-one relationships. Both work in async sessions because the loading happens inside the awaited execute() call.
Is expire_on_commit=False safe?
It is safe for short-lived, request-scoped sessions. It means objects keep their pre-commit values, so call await session.refresh(obj) when you need database-generated values.
Bottom line
If you are writing async SQLAlchemy, set lazy="raise" on relationships and expire_on_commit=False on the sessionmaker, then choose selectinload or joinedload explicitly in each query. Reach for awaitable_attrs in scripts and rare branches, not in loops. If your team is small and tests are thin, the lazy="raise" default is the single change that pays back fastest.