sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place?

MissingGreenlet means code touched something that needs a database round trip (an expired column or an unloaded relationship) from ordinary synchronous code. AsyncSession can only run queries inside an await, so the implicit load that works with a sync Session fails here.

TLDR

  • Trace follows a session.commit() and ends while the response is being built, on a plain column like id: commit() expired the object. Create the session factory with expire_on_commit=False, or await session.refresh(obj) before returning it.
  • Trace goes through serialization and a relationship attribute (author.books): the relationship was never loaded. Add selectinload(Author.books) to the query.
  • Error appears in a background task: the object outlived its session. Pass plain values instead.
  • To make this fail loudly in development, set lazy="raise" on relationships.

Full trace for the commit case

This is what a typical failure looks like when a POST handler commits and then returns the ORM object:

Traceback (most recent call last):
  File "/app/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 301, in app
    content = await serialize_response(
  File "/app/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 169, in serialize_response
    return field.serialize(
  File "/app/app/schemas.py", line 12, in to_user_out
    return UserOut.model_validate(user)
  File "/app/.venv/lib/python3.12/site-packages/sqlalchemy/orm/attributes.py", line 566, in __get__
    return self.impl.get(state, dict_)
  File "/app/.venv/lib/python3.12/site-packages/sqlalchemy/orm/loading.py", line 1580, in load_scalar_attributes
    result = load_on_ident(
  File "/app/.venv/lib/python3.12/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 123, in await_only
    raise exc.MissingGreenlet(
sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place?

Exact paths and line numbers vary by version, and this trace is illustrative. The shape is what matters: your route is not the frame that fails. Serialization code reads an attribute, and SQLAlchemy’s loading code then tries to run a query. Pasting a long trace like this into the Debugly formatter collapses the framework frames so you can see which attribute access started it.

commit() expires every attribute on the object

By default Session.commit() expires all loaded attributes, so the next read re-fetches the row. With a sync session that read happens silently. With AsyncSession it fails, and it fails after the commit, usually while FastAPI serializes the return value.

@app.post("/users", response_model=UserOut)
async def create_user(data: UserIn, session: AsyncSession = Depends(get_session)):
    user = User(**data.model_dump())
    session.add(user)
    await session.commit()
    return user  # serialization reads user.id, which is expired -> MissingGreenlet

You can recognize it by the route calling commit() and the failing attribute being a plain column (id, created_at), not a relationship.

The usual fix is to disable expiry when you build the session factory:

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/app")
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)

If you want to keep expiry, refresh explicitly before returning:

await session.commit()
await session.refresh(user)
return user

refresh() also loads server-generated values such as column defaults and triggers, which expire_on_commit=False does not. If your response needs those, use refresh().

A relationship is read during serialization

class Author(Base):
    __tablename__ = "authors"
    id: Mapped[int] = mapped_column(primary_key=True)
    books: Mapped[list["Book"]] = relationship(back_populates="author")

@app.get("/authors/{author_id}", response_model=AuthorOut)
async def get_author(author_id: int, session: AsyncSession = Depends(get_session)):
    return await session.get(Author, author_id)  # AuthorOut includes books

session.get() loads the author row only. When Pydantic (with from_attributes=True) reads author.books, SQLAlchemy attempts a lazy load and raises. The failing attribute is a relationship, and there may be no commit() involved.

Load the relationship in the query:

from sqlalchemy import select
from sqlalchemy.orm import selectinload

result = await session.execute(
    select(Author).where(Author.id == author_id).options(selectinload(Author.books))
)
author = result.scalar_one_or_none()

selectinload issues one extra SELECT ... WHERE author_id IN (...), a good default for collections. Use joinedload for many-to-one. If you only need the relationship occasionally, load it after the fact:

await session.refresh(author, attribute_names=["books"])

The AsyncAttrs mixin on your declarative base also gives each object an awaitable_attrs accessor:

books = await author.awaitable_attrs.books

That works per attribute, but inside a loop it brings back one query per row, covered in the FastAPI SQLAlchemy N+1 guide.

Make unloaded relationships raise with a clear message

Setting lazy="raise" on a relationship makes any access that was not eager loaded fail with an InvalidRequestError naming the attribute, instead of a MissingGreenlet that points into SQLAlchemy internals:

books: Mapped[list["Book"]] = relationship(back_populates="author", lazy="raise")

It also keeps behavior consistent: a lazy load can pass in a sync test session and then break only in the async app.

The object outlived its session

If the object is handed to a background task, a queue, or a cache, the session may be closed by the time the task reads it.

@app.post("/orders")
async def create_order(background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session)):
    order = Order(total=10)
    session.add(order)
    await session.commit()
    background_tasks.add_task(send_receipt, order)  # reads order attributes after the request

Don’t rely on an ORM object staying usable once the request’s session is gone. Read the values while the session is open and pass plain data:

background_tasks.add_task(send_receipt, order_id=order.id, total=order.total)

If the task needs the database, open its own session from SessionLocal instead of reusing the request’s. Background task failures in FastAPI covers other ways tasks break after the response.

Sync-only calls on an async engine

Calling sync-only APIs on an async engine, such as Base.metadata.create_all(engine) with an AsyncEngine, fails with an error from SQLAlchemy rather than running. Your own call sits directly above the error in the trace. Run these through run_sync:

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