Сообщение sqlalchemy.exc.MissingGreenlet, которое появляется в логах FastAPI с упоминанием await_only и подозрительной фразой "Was IO attempted in an unexpected place", знакомо почти каждому, кто переходил с синхронной SQLAlchemy на асинхронную. Текст ошибки сбивает с толку. Кажется, будто речь о низкоуровневых проблемах с greenlet, хотя на самом деле она почти всегда означает простую и понятную вещь - где-то в коде драйвер захотел сделать запрос в базу синхронно, а вокруг асинхронный контекст, в котором такие фокусы запрещены. Разбор того, где именно прячется источник проблемы, и есть основная работа при отладке.

Что на самом деле говорит эта ошибка и почему она возникает в асинхронном коде

Под капотом у асинхронной SQLAlchemy лежит хитрая конструкция. Сам ORM написан синхронно, и чтобы он мог работать с асинхронными драйверами вроде asyncpg или aiosqlite, поверх него натянута прослойка на основе greenlet. Когда код вызывает await session.execute(...), SQLAlchemy внутри себя думает, что выполняет обычный синхронный запрос, но в нужный момент через greenlet перебрасывает управление обратно в event loop, который и делает реальную сетевую операцию.

Конструкция работает, пока вызовы происходят через явные методы вроде execute, commit, refresh, scalars. Стоит коду случайно дёрнуть атрибут, который требует обращения к базе, без обёртки await, и греплет-прослойка не находит контекста, в котором можно подождать сетевую операцию. Тогда летит MissingGreenlet. Иными словами, ошибка не про сам greenlet, а про неявный синхронный поход в базу из асинхронной функции.

Самые частые поводы для такого похода это ленивая загрузка связей, истёкшие после коммита атрибуты, попытка использовать синхронный create_engine с asyncpg-драйвером и обращение к объекту после закрытия сессии. Каждый случай заслуживает отдельного разбора.

Ленивая загрузка отношений как главный источник проблем

Самая распространённая история. Эндпоинт возвращает объект, у которого есть relationship, и где-то ниже по стеку - в Pydantic-схеме, в сериализаторе, в шаблоне - этот атрибут читается. SQLAlchemy по умолчанию подгружает связанные объекты лениво, то есть в момент обращения. В синхронном коде это работало само. В асинхронном это превращается в попытку синхронного запроса из асинхронного контекста, и тут как раз и срабатывает MissingGreenlet.

Вот типичный пример проблемного кода:

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends

router = APIRouter()


@router.get("/users/{user_id}")
async def get_user_with_posts(
    user_id: int,
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one()
    return {
        "id": user.id,
        "name": user.name,
        "posts": [p.title for p in user.posts],
    }

Запрос отрабатывает, объект user приходит, и всё выглядит хорошо до момента обращения к user.posts. SQLAlchemy не загружал эту связь в основном запросе, теперь хочет сходить за ней в базу - но не может, потому что находится вне greenlet-контекста. Падение происходит уже на этапе сериализации, в логах появляется знакомая строка про await_only.

Правильное решение - загружать связи явно, через стратегии eager loading. Для коллекций (один ко многим, многие ко многим) подходит selectinload, для одиночных связей (многие к одному, один к одному) - joinedload:

from sqlalchemy import select
from sqlalchemy.orm import selectinload, joinedload


@router.get("/users/{user_id}")
async def get_user_with_posts(
    user_id: int,
    db: AsyncSession = Depends(get_db),
):
    stmt = (
        select(User)
        .options(selectinload(User.posts))
        .where(User.id == user_id)
    )
    result = await db.execute(stmt)
    user = result.scalar_one()
    return {
        "id": user.id,
        "name": user.name,
        "posts": [p.title for p in user.posts],
    }

Здесь selectinload выполняет дополнительный SELECT для подтягивания постов в момент основного запроса. К моменту обращения к user.posts данные уже находятся в объекте, и поход в базу не нужен.

Если в проекте десятки эндпоинтов и везде надо помнить про eager loading, помогает превентивная мера. Можно объявить relationship со стратегией lazy="raise", и тогда любая случайная ленивая загрузка будет приводить к понятной ошибке ещё на стадии разработки, а не молчать до первого боевого запроса:

from sqlalchemy.orm import Mapped, mapped_column, relationship, DeclarativeBase


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    posts: Mapped[list["Post"]] = relationship(
        back_populates="author",
        lazy="raise",
    )

С таким объявлением забытый selectinload вылетит понятной ошибкой "lazy loading not allowed", а не загадочным MissingGreenlet где-то внутри сериализатора. Боль остаётся, но становится управляемой.

Истёкшие атрибуты после коммита и зачем выключать expire_on_commit

Второй сценарий, который ловит даже опытных разработчиков. После выполнения commit() сессия по умолчанию помечает все объекты как expired, то есть просит при следующем обращении к их атрибутам подтянуть свежие данные из базы. В синхронном мире это полезное поведение, защищающее от работы с устаревшими данными. В асинхронном это та же ловушка, что и с ленивой загрузкой.

Классический сценарий выглядит так:

@router.post("/users")
async def create_user(
    data: UserCreate,
    db: AsyncSession = Depends(get_db),
):
    user = User(name=data.name, email=data.email)
    db.add(user)
    await db.commit()
    return {"id": user.id, "name": user.name}

После await db.commit() атрибуты user считаются истёкшими. Возврат словаря с user.id и user.name приводит к попытке заново их подгрузить из базы - синхронно, посреди асинхронной функции. И снова MissingGreenlet.

Лечится это двумя способами. Первый - при создании async_sessionmaker явно отключить expire_on_commit:

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

engine = create_async_engine(
    "postgresql+asyncpg://user:password@localhost/dbname",
    pool_pre_ping=True,
)

AsyncSessionLocal = async_sessionmaker(
    bind=engine,
    class_=AsyncSession,
    expire_on_commit=False,
    autoflush=False,
)

Второй - явно вызвать await db.refresh(user) после коммита, если по какой-то причине expire_on_commit отключать нежелательно. Refresh заберёт свежие данные сетевым запросом через тот же асинхронный механизм, и атрибуты снова станут валидными:

@router.post("/users")
async def create_user(
    data: UserCreate,
    db: AsyncSession = Depends(get_db),
):
    user = User(name=data.name, email=data.email)
    db.add(user)
    await db.commit()
    await db.refresh(user)
    return {"id": user.id, "name": user.name}

В подавляющем большинстве случаев правильнее именно первый путь - выключить expire_on_commit на уровне фабрики сессий. Это снимает целый класс ошибок и убирает скрытые сетевые запросы, которые потом всплывают как боттлнеки в нагрузочных тестах.

Синхронный create_engine с асинхронным URL

Третья ошибка из тех, что вызывают недоумение. Разработчик подключает asyncpg как драйвер, прописывает в строке подключения postgresql+asyncpg, но дальше по привычке вызывает create_engine вместо create_async_engine. Подключение формально создаётся, а на первой же реальной операции выстреливает та же MissingGreenlet, иногда на уровне Alembic-миграций, иногда на инициализации.

Чаще всего такая комбинация возникает в местах, где синхронный код встречается с асинхронным - в скриптах миграций, в утилитах для seed-данных, в health-check эндпоинтах. Правило простое. Если в строке подключения есть +asyncpg, движок должен быть асинхронный:

from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    "postgresql+asyncpg://user:password@localhost/dbname",
    echo=False,
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True,
    pool_recycle=3600,
)

Если же нужен синхронный движок, например для запуска миграций через Alembic, в строке подключения должен быть либо psycopg2, либо psycopg (третья версия), но не asyncpg:

from sqlalchemy import create_engine

sync_engine = create_engine("postgresql+psycopg2://user:password@localhost/dbname")

Многие проекты держат отдельные строки подключения для приложения и для миграций именно по этой причине. Это выглядит избыточно ровно до того момента, как кто-то запускает alembic upgrade head с asyncpg в URL и получает на выходе всё ту же ошибку про greenlet.

Доступ к объекту после закрытия сессии и почему этого нельзя делать

Четвёртый сценарий встречается реже, но диагностируется труднее. Сессия открывается в зависимости FastAPI, эндпоинт получает объект, возвращает его, и где-то на этапе формирования ответа на сериализованный объект пытаются достучаться через relationship или через истёкший атрибут. Сессия к этому моменту уже закрыта, потому что вышел из контекстного менеджера, и любая операция, требующая базы, обречена.

Такая зависимость должна выглядеть аккуратно. Сессия живёт в пределах одного запроса, открывается через async_sessionmaker и закрывается в finally:

from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession


async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionLocal() as session:
        try:
            yield session
        except Exception:
            await session.rollback()
            raise
        finally:
            await session.close()

Главное правило при работе с такой зависимостью - не возвращать наружу объекты, у которых ещё могут быть незагруженные связи или истёкшие атрибуты. К моменту выхода из эндпоинта вся работа с базой должна быть завершена. Если для ответа нужны данные из связей, они должны быть подтянуты внутри сессии через selectinload или joinedload, а лучше - сразу преобразованы в Pydantic-схему ещё внутри корутины эндпоинта.

AsyncAttrs как способ разрешить ленивую загрузку в асинхронном коде

В SQLAlchemy 2.0 появился миксин AsyncAttrs, который частично решает проблему ленивой загрузки. Он добавляет к каждому объекту специальный awaitable_attrs, через который можно ожидать атрибуты так, как они должны были бы загружаться:

from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(AsyncAttrs, DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    posts: Mapped[list["Post"]] = relationship(back_populates="author")

Использование выглядит так:

@router.get("/users/{user_id}/posts")
async def list_user_posts(
    user_id: int,
    db: AsyncSession = Depends(get_db),
):
    user = await db.get(User, user_id)
    posts = await user.awaitable_attrs.posts
    return [{"id": p.id, "title": p.title} for p in posts]

Подход удобен, когда заранее непонятно, какие связи понадобятся, или когда логика принимает решение динамически. Но злоупотреблять им не стоит. AsyncAttrs делает скрытые запросы видимыми, что хорошо, но не отменяет того факта, что каждая такая операция это отдельный поход в базу. Для нагруженных эндпоинтов всё равно выгоднее планировать загрузку наперёд через стратегии eager loading.

Возврат значения после commit и неочевидная ловушка с ID

Есть отдельный класс случаев, в которых ошибка появляется именно при попытке вернуть из эндпоинта только что созданный объект. Проблема в том, что user.id до коммита равен None, а после коммита атрибут оказывается expired. Если код пишет return {"id": entry.id} прямо после await db.commit(), и в фабрике сессий не выставлено expire_on_commit=False, то происходит та самая ленивая попытка перечитать атрибут из базы, с уже знакомым результатом.

Опытный способ обойти этот частный случай - сохранить значение в локальной переменной до коммита, если для него уже есть данные (например, в случае flush), или явно refreshнуть объект:

@router.post("/items")
async def create_item(
    data: ItemCreate,
    db: AsyncSession = Depends(get_db),
):
    item = Item(name=data.name)
    db.add(item)
    await db.flush()
    item_id = item.id
    await db.commit()
    return {"id": item_id, "name": data.name}

В этом варианте flush получает идентификатор от базы без завершения транзакции, item.id уже валиден, и значение копируется в локальную переменную. После commit оно остаётся в локальной переменной независимо от того, что произошло с атрибутами объекта. Решение чуть многословное, но в проектах, где expire_on_commit оставлен включённым по каким-то причинам, оно спасает.

Чеклист на случай новой встречи с MissingGreenlet

Если ошибка снова появилась в логах, проверка обычно идёт по одному и тому же списку:

  1. Сессия открыта как AsyncSession через async_sessionmaker, а не как обычный Session;
  2. Движок создан через create_async_engine, а не через create_engine с asyncpg-драйвером;
  3. У фабрики сессий выставлено expire_on_commit=False;
  4. Все relationship, которые нужны для ответа, загружены через selectinload или joinedload;
  5. Эндпоинт объявлен как async def, а не как обычная функция;
  6. Объекты не сериализуются за пределами контекста сессии;
  7. Альтернативно подключён миксин AsyncAttrs там, где ленивая загрузка действительно нужна.

Чаще всего источником оказывается один из первых четырёх пунктов. Когда проект только переходит на асинхронный стек, имеет смысл прописать lazy="raise" во все relationship по умолчанию - один день разбора падений на стейдже окупится спокойствием в продакшене.

Что меняется в подходе при переходе на полностью асинхронный стек

Главный сдвиг в мышлении состоит в том, что данные нужно планировать наперёд. Синхронный код прощал ленивые загрузки и тихие походы в базу. Асинхронный требует явности - где данные нужны, там их и подтягиваем сразу. Это меняет архитектуру слоёв доступа к данным. Репозиторий теперь не возвращает голый объект, а сразу собирает структуру с нужными связями. Сервисный слой реже опирается на ORM-атрибуты и чаще работает с уже подготовленными Pydantic-моделями.

Поначалу это раздражает. Кажется, что появилась лишняя бойлерплейтная работа. На длинной дистанции эффект противоположный - запросов становится меньше, они становятся понятнее, N+1 проблемы выявляются ещё на ревью, а ошибка MissingGreenlet в логах перестаёт быть еженедельным событием. Асинхронная SQLAlchemy в этом смысле строгий учитель. Она не прощает небрежности, но взамен даёт стабильную производительность под нагрузкой, которой синхронный аналог достичь не может.