vM.

Fix Async SQLAlchemy MissingGreenlet and Lazy-Loading Errors in FastAPI

Author
Vishal Maurya
Published on
Reading time
5 min read

Overview

You move a FastAPI app to async SQLAlchemy, and a route that worked in a sync version now crashes:

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

Often it appears when you return an ORM object and Pydantic reads author.posts, or after a commit() when you touch an attribute. The error is confusing, but the cause is specific: something tried to run database I/O implicitly, and async code cannot do that.

1. Why It Happens

In sync SQLAlchemy, accessing an unloaded relationship such as user.posts quietly emits a query. In async SQLAlchemy, all I/O must be awaited. A lazy load is an implicit, non-awaited query, so it raises MissingGreenlet.

There are two common triggers:

  • Lazy relationship access, either in your code or when Pydantic serializes the object after your function returns.
  • Expired attributes. By default commit() expires all loaded attributes, and the next access wants to refresh them from the database, which is the same implicit I/O.

2. Load Relationships Explicitly

Decide which relationships a route needs and load them in the query.

from sqlalchemy import select
from sqlalchemy.orm import selectinload

from app.models import User


async def get_user_with_posts(session, user_id: int) -> User | None:
    stmt = (
        select(User)
        .where(User.id == user_id)
        .options(selectinload(User.posts))
    )
    result = await session.execute(stmt)
    return result.scalar_one_or_none()

For collections, selectinload issues a second SELECT ... WHERE id IN (...) and is generally a good default with async. For many-to-one or one-to-one, joinedload fetches in a single query via a join. Joined loading of collections multiplies rows, so prefer selectinload there.

You can also set the loading strategy on the relationship, for example lazy="selectin", when you almost always want that data. Use it sparingly, because it applies to every query.

For one-off access to an already loaded object, you can await the load instead:

await session.refresh(user, attribute_names=["posts"])

Another escape hatch is AsyncAttrs: add it to your declarative base and use await user.awaitable_attrs.posts. It makes the lazy load explicit rather than preventing it.

3. Stop Expiring Objects on Commit

If you commit and then return the object, its attributes are expired and serialization triggers a refresh. Configure the session factory:

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

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

With expire_on_commit=False, objects keep their loaded state after the commit. The tradeoff is that they can be stale compared to the database, and server-generated values (defaults, triggers, an autoincrement id you did not flush) are not refreshed automatically. After an insert, call await session.flush() or await session.refresh(obj) to get generated values.

async def create_post(session, data) -> Post:
    post = Post(**data.model_dump())
    session.add(post)
    await session.commit()
    await session.refresh(post)  # loads server defaults, such as created_at
    return post

4. Serialize With Explicit Response Models

Returning an ORM object makes FastAPI and Pydantic walk its attributes, and any unloaded relationship in the response schema triggers the lazy load. Make the schema declare exactly what you loaded, and validate from attributes.

from pydantic import BaseModel, ConfigDict


class PostOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    title: str


class UserOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    name: str
    posts: list[PostOut]


@router.get("/users/{user_id}", response_model=UserOut)
async def read_user(user_id: int, session: AsyncSession = Depends(get_session)):
    user = await get_user_with_posts(session, user_id)
    if user is None:
        raise HTTPException(status_code=404)
    return user

The from_attributes option is Pydantic v2 (the v1 equivalent was orm_mode). If a schema field is not loaded, you get the error at response time, which is a good prompt to add the loader option. A route that does not need posts should use a schema without it.

5. Other Sources of the Same Error

  • Calling sync-style code such as session.query(...) or running a sync driver URL with an async engine.
  • Using Session methods in a thread, or passing an AsyncSession across event loops.
  • Accessing relationships on objects after the session closes, which raises a detached instance error instead; it points to the same issue of loading data you need before leaving the session.
  • Using run_sync is available when you need to call sync-only code, with await session.run_sync(fn).

6. Debug by Logging SQL

Set echo=True on the engine in development to see which statement fires. Seeing N similar queries per request reveals N+1 patterns, which selectinload fixes. Making every access explicit is the central design shift of async SQLAlchemy.

Conclusion

MissingGreenlet is SQLAlchemy telling you an implicit query tried to run. Load what you need with selectinload or joinedload, use expire_on_commit=False with an explicit refresh when needed, and make response models match what you loaded.

If you are migrating a FastAPI service to async SQLAlchemy and want help with the data layer, I can help. Contact me.

Additional Resources

  • SQLAlchemy asyncio extension
  • SQLAlchemy relationship loading techniques
  • FastAPI SQL databases tutorial