Blogs / FastAPI und Async SQLAlchemy Sessions: Ein vollständiger Leitfaden

FastAPI und Async SQLAlchemy Sessions: Ein vollständiger Leitfaden

Veröffentlicht
29. August 2026
Autor
Faizan Nadeem
Tags
FastAPI SQLAlchemy Python PostgreSQL
Abstrakte grüne Lichtspuren und Code, die einen leuchtenden digitalen Tunnel bilden
Foto von Logan Voss auf Unsplash

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called ist der Fehler, der fast jedes Team begrüßt, das eine FastAPI-App von synchronem SQLAlchemy auf async migriert, und oft ist überhaupt nicht offensichtlich, worüber er sich eigentlich beschwert. Der Traceback zeigt meist auf eine Zeile, die völlig harmlos aussieht — etwa der Zugriff auf order.customer.name in einem Jinja-Template oder in einem Response-Serializer, also weit entfernt von irgendeinem Datenbankaufruf, den man selbst geschrieben hat. Der erste Impuls ist, das Ganze als irgendeinen obskuren Async-Kompatibilitätsfehler zu behandeln. Ist es aber nicht. SQLAlchemy sagt Ihnen damit so präzise, wie es nur kann, dass Sie versucht haben, eine per Lazy Loading geladene Query außerhalb des Async-Kontexts auszuführen, der Lazy Loading überhaupt erst möglich macht.

Async SQLAlchemy in FastAPI richtig einzusetzen bedeutet nicht, sich eine bestimmte Fehlermeldung einzuprägen — es geht darum zu verstehen, dass der Lebenszyklus einer Session, der Lebenszyklus einer Engine und die Ladestrategie einer Relationship miteinander übereinstimmen müssen, und async macht die Stellen, an denen sie unbemerkt auseinanderlaufen können, deutlich weniger nachsichtig als synchrones SQLAlchemy es je war. Dieser Beitrag behandelt korrekt umgesetztes Session-per-Request-Scoping, was MissingGreenlet tatsächlich auslöst, warum Relationships, die Sie nie explizit abgefragt haben, eine Response zum Absturz bringen können, und wo die Engine selbst im Verhältnis zur Lebensdauer Ihrer App liegen sollte.

Sie lernen:

  • Wie man eine async SQLAlchemy-Session pro Request mit FastAPIs Depends und yield korrekt scoped
  • Was MissingGreenlet tatsächlich bedeutet und an welchen drei Stellen es am häufigsten auftaucht
  • Warum der Zugriff auf ein nicht geladenes Relationship-Attribut nach Ende des Session-Kontexts eine Lazy-Load-Explosion auslöst
  • Den Unterschied zwischen einer per Lifespan verwalteten Engine und einer Request-gescopten Session und warum das nicht derselbe Lebenszyklus ist
  • Eager-Loading-Strategien (selectinload, joinedload), die Lazy-Load-Fehler verhindern, bevor sie passieren
  • Ein vollständiges, funktionierendes async-Session-Setup, das Sie direkt in ein Projekt übernehmen können

Inhaltsverzeichnis

  1. Die Grundlagen
  2. Session-Per-Request-Scoping
  3. Der MissingGreenlet-Fehler erklärt
  4. Lazy-Load-Explosionen
  5. Per Lifespan verwaltete Engine vs. Request-gescopte Session
  6. Eager-Loading-Strategien
  7. Ein vollständiges, funktionierendes Setup
  8. Async-SQLAlchemy-Code testen
  9. Größe des Connection-Pools unter gleichzeitiger Last
  10. Häufige Fallstricke
  11. Best Practices für die Produktion

Die Grundlagen

Warum sich Async SQLAlchemy anders verhält als Sync

Das Lazy Loading von synchronem SQLAlchemy ist fast zufällig nachsichtig: Wenn Sie auf order.customer zugreifen und es noch nicht geladen wurde, führt SQLAlchemy genau dort sofort eine neue synchrone Query aus und gibt das Ergebnis zurück. Das funktioniert, weil ein blockierender Datenbankaufruf innerhalb eines Python-Attributzugriffs unsichtbar ist — Python unterscheidet nicht zwischen „schnellem Attributzugriff“ und „Attributzugriff, der heimlich I/O ausführt“.

Async SQLAlchemy kann das nicht stillschweigend tun, weil das Ausführen einer Query await erfordert, und man kann innerhalb eines einfachen Attributzugriffs nicht awaiten (__getattr__ kann kein Coroutine in einer Form sein, die Python implizit für Sie awaited). SQLAlchemy löst das tatsächlich über eine Brücke namens greenlet, die bestimmte synchron wirkende Aufrufe innerhalb eines Async-Kontexts laufen lässt, indem in ein Greenlet gewechselt wird, das in Ihrem Namen awaiten kann — aber diese Brücke existiert nur innerhalb der Grenzen, die SQLAlchemy dafür einrichtet, konkret solange der Async-Kontext einer Session aktiv ist. Sobald Sie diese Grenzen verlassen, sagt Ihnen MissingGreenlet, dass die Brücke nicht mehr da ist.

Die Engine, die Session und die Relationship — drei verschiedene Lebenszyklen

Das wiederkehrende Muster hinter fast jedem Async-SQLAlchemy-Bug in einer FastAPI-App ist, dass drei Dinge kompatible Lebenszyklen brauchen, und man sie leicht falsch aufeinander abstimmt: die Engine (langlebig, einmal beim Start der App erstellt), die Session (kurzlebig, eine pro Request) und das Laden von Relationships (muss passieren, solange die Session, die das Laden übernimmt, noch offen ist). Wenn eines dieser drei Dinge falsch konfiguriert ist, entsteht eine andere Variante desselben Grundproblems.

Session-Per-Request-Scoping

Das korrekte Muster ist eine yield-basierte Dependency, genau das Muster, das allgemein in FastAPI Dependency Injection: Patterns and Anti-Patterns besprochen wird — eine Session wird geöffnet, wenn ein Request startet, und garantiert geschlossen, wenn er endet, unabhängig davon, ob der Handler erfolgreich war oder einen Fehler ausgelöst hat:

# app/core/database.py
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

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

async def get_db():
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
@router.get("/orders/{id}")
async def get_order(id: int, db: AsyncSession = Depends(get_db)):
    order = await db.get(Order, id)
    return order

Zwei Dinge daran sind leicht falsch zu machen. Erstens ist expire_on_commit=False speziell für FastAPI wichtig: Standardmäßig markiert SQLAlchemy nach einem Commit alle geladenen Attribute als abgelaufen, was bedeutet, dass der nächste Attributzugriff erneut ein Lazy Load auslöst — und genau das ist, wie im nächsten Abschnitt gezeigt, die Situation, die MissingGreenlet erzeugt, wenn dieser Zugriff beginnt, nachdem Ihre Response-Serialisierung bereits angelaufen ist. Wenn Sie es auf False setzen, bleiben bereits geladene Attribute nach dem Commit nutzbar, ohne eine neue Query auszulösen. Zweitens muss die Session innerhalb der Dependency-Funktion erstellt werden, nicht zur Modul-Importzeit — eine Session, die einmal erstellt und dann über Requests hinweg wiederverwendet wird, teilt stillschweigend Zustand (und schlimmer noch: Connections) über nicht zusammenhängende Requests hinweg, was das gesamte Modell „eine Session pro Request“ zerstört, auf dem dieses Muster basiert.

Der MissingGreenlet-Fehler erklärt

MissingGreenlet tritt auf, wenn SQLAlchemy I/O ausführen muss — ein implizites Lazy Load, ein Flush, ein Refresh — außerhalb des über greenlet überbrückten Async-Kontexts, was in der Praxis bedeutet, dass die Session (oder ihre zugrunde liegende Connection) bereits geschlossen wurde oder dass der Codepfad gar nicht innerhalb eines korrekt awaiteten Async-Aufrufs läuft. Drei Stellen, an denen das ständig auftaucht:

1. Zugriff auf eine Relationship, nachdem der Request bereits beantwortet wurde. Wenn ein Pydantic-Response-Modell oder ein Background-Task auf order.customer zugreift, nachdem der async with-Block von get_db verlassen wurde, ist die Session geschlossen und es gibt keine greenlet-Brücke mehr, die die implizite Query ausführen könnte.

2. Aufruf einer synchronen Serialisierungsbibliothek auf einem ORM-Objekt mit nicht geladenen Relationships. Manche Serializer (oder ältere Pydantic-Konfigurationen mit .from_orm() auf tief verschachtelten Modellen) berühren während der Serialisierung jedes Attribut, auch solche, die Sie nie explizit geladen haben — wenn eines davon außerhalb des aktiven Session-Kontexts ein Lazy Load auslöst, ist MissingGreenlet die Folge.

3. Direkte Verwendung eines Sync-Style-Aufrufs auf einer async Session oder Engine. Code, der aus einer synchronen SQLAlchemy-Codebasis kopiert wurde — db.query(...) statt await db.execute(select(...)), oder der Zugriff auf .scalars(), ohne vorher den execute-Aufruf zu awaiten — umgeht die Erwartungen des Async-Treibers vollständig und erzeugt denselben Fehler, weil der Sync-API-Pfad von Anfang an nie durch die greenlet-Brücke geführt wurde.

Die Lösung hat in jedem Fall dieselbe Form: Stellen Sie sicher, dass alles, was Daten aus der Datenbank braucht, passiert, solange die Session noch offen ist und innerhalb eines awaiteten Aufrufs, nicht danach.

Lazy-Load-Explosionen

Selbst wenn MissingGreenlet nicht ausgelöst wird — weil zum Beispiel lazy="select"-Relationships zufällig noch abgefragt werden, solange die Session technisch gesehen noch offen ist — tritt ein anderes Problem auf: eine N+1-Query-Explosion, die nun mit dem zusätzlichen Overhead eines vollständigen Async-Round-Trips pro Lazy Load bezahlt wird statt mit einem billigen synchronen.

@router.get("/orders")
async def list_orders(db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(Order))
    orders = result.scalars().all()
    return [
        {"id": o.id, "customer": o.customer.name}  # implicit lazy load, per order
        for o in orders
    ]

Bei 100 Bestellungen führt das 1 Query aus, um die Bestellungen zu holen, und dann innerhalb der List Comprehension bis zu 100 zusätzliche implizite Queries — eine pro o.customer-Zugriff — jede davon ein vollständiger Async-Round-Trip zur Datenbank. In synchronem SQLAlchemy ist das bereits ein Performanceproblem; in async SQLAlchemy ist es häufig zusätzlich ein Korrektheitsproblem, weil der Erfolg des Lazy Loads davon abhängt, ob der greenlet-Kontext der Session zum Zeitpunkt des Zugriffs aufgrund subtiler Timing-Effekte noch als aktiv gilt — genau die Art von Verhalten, die in einem schnellen lokalen Test funktioniert und unter realen Request-Mustern sporadisch scheitert.

Per Lifespan verwaltete Engine vs. Request-gescopte Session

Die Engine und die Session sind nicht dasselbe Objekt mit unterschiedlichen Namen — sie sind unterschiedliche Lebenszyklen für unterschiedliche Zwecke, und sie zu vermischen ist eine häufige Ursache für erschöpfte Connection-Pools.

Die Engine besitzt den eigentlichen Connection-Pool und sollte genau einmal beim Start der Anwendung erstellt und genau einmal beim Herunterfahren freigegeben werden — über FastAPIs lifespan. Das ist außerdem genau die Art von bereichsübergreifendem, domänenagnostischem Code, die in ein gemeinsames core/database.py gehört, wie in FastAPI Project Structure That Survives Growth beschrieben — die Router jeder Domäne importieren get_db von dort, und keiner von ihnen muss wissen, wie die Engine selbst erstellt wurde:

# app/main.py
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    "postgresql+asyncpg://user:pass@localhost/db",
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True,
)

@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await engine.dispose()

app = FastAPI(lifespan=lifespan)

Die Session ist leichtgewichtig, kurzlebig und über die zuvor gezeigte get_db-Dependency auf einen einzelnen Request begrenzt — sie leiht sich für die Dauer des Requests eine Connection aus dem Pool der Engine und gibt sie zurück, wenn die Session geschlossen wird. Pro Request eine neue Engine zu erstellen (statt pro App) ist der Fehler, der unter Last tatsächlich das Connection-Limit einer Datenbank erschöpft, weil jede Engine ihren eigenen Pool mitbringt und Pools, die nie freigegeben werden, ihre Connections unbegrenzt offen halten. pool_pre_ping=True lohnt sich als Standard in der Produktion — es prüft, dass eine gepoolte Connection nicht veraltet ist (etwa von der Datenbankseite oder durch den Idle-Timeout eines Load Balancers geschlossen), bevor sie an einen Request übergeben wird; man tauscht also einen kleinen Latenzaufschlag gegen die Vermeidung eines viel verwirrenderen „connection already closed“-Fehlers mitten im Request ein.

Eager-Loading-Strategien

Die dauerhafte Lösung sowohl für MissingGreenlet als auch für N+1-Explosionen ist dieselbe: Laden Sie explizit, was Sie brauchen, solange die Session offen ist, statt sich darauf zu verlassen, dass implizites Lazy Loading später schon irgendwie passiert.

selectinload führt eine zweite, separate Query für die zugehörigen Zeilen aus, gebündelt nach Primärschlüssel — der richtige Standard für One-to-Many-Relationships:

from sqlalchemy.orm import selectinload

result = await db.execute(
    select(Order).options(selectinload(Order.items))
)
orders = result.scalars().all()
# order.items is already loaded — no further query needed

joinedload lädt die zugehörigen Daten über ein SQL-JOIN in derselben Query — besser für Many-to-One- oder One-to-One-Relationships, bei denen die zusätzlichen Spalten pro Zeile günstig sind:

from sqlalchemy.orm import joinedload

result = await db.execute(
    select(Order).options(joinedload(Order.customer))
)

Für eine Relationship, die Sie in den meisten Read-Pfaden brauchen, sollten Sie erwägen, direkt in der Relationship-Definition lazy="raise" zu setzen — dadurch wird aus einem versehentlichen impliziten Lazy Load sofort an der exakten Zugriffsstelle eine laute Exception, statt eines MissingGreenlet mehrere Stack Frames entfernt vom eigentlichen Fehler:

customer = relationship("Customer", lazy="raise")

Damit tauschen Sie einen verwirrenden Laufzeitfehler gegen einen viel klareren an genau der Stelle, an der die fehlende Eager-Load-Option tatsächlich ergänzt werden muss.

Ein vollständiges, funktionierendes Setup

Setzen wir die Bausteine zusammen — Engine, Session-Factory, Dependency, Modell und Endpoint:

# app/core/database.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession

engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)

async def get_db():
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

# app/orders/models.py
class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"))
    customer: Mapped["Customer"] = relationship(lazy="raise")
    items: Mapped[list["OrderItem"]] = relationship()

# app/orders/router.py
@router.get("/orders/{id}", response_model=OrderOut)
async def get_order(id: int, db: AsyncSession = Depends(get_db)):
    result = await db.execute(
        select(Order)
        .options(selectinload(Order.items), joinedload(Order.customer))
        .where(Order.id == id)
    )
    order = result.unique().scalar_one_or_none()
    if order is None:
        raise HTTPException(404)
    return order

Beachten Sie .unique() auf dem Result, wenn Sie joinedload in einer Query verwenden, die eine Collection erzeugt — ohne dieses Detail kann ein Join, der Zeilen auffächert, doppelte Parent-Objekte zurückliefern, etwas, das man leicht übersieht, bis es in einer Response als unerklärliche Doppeleinträge auftaucht.

Async-SQLAlchemy-Code testen

Async-Datenbankcode korrekt zu testen bedeutet, jedem Test dieselbe Request-gescopte Session-Garantie zu geben, die auch die Produktion bekommt, ohne für jeden Unit-Test einen vollständigen HTTP-Client hochzufahren. Der sauberste Ansatz kapselt jeden Test in seine eigene Transaction und rollt sie anschließend zurück, sodass Tests niemals Zustand ineinander durchsickern lassen, unabhängig von der Ausführungsreihenfolge:

# tests/conftest.py
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession

@pytest_asyncio.fixture
async def db_session():
    async with engine.connect() as conn:
        await conn.begin()
        async with AsyncSession(bind=conn, expire_on_commit=False) as session:
            yield session
            await conn.rollback()

Jeder Test, der db_session verwendet, bekommt eine vollständig isolierte Sicht auf die Datenbank — Inserts aus einem Test sind für den nächsten unsichtbar, weil die äußere Transaction nach jedem Test zurückgerollt wird, unabhängig davon, ob der Test selbst commit() aufgerufen hat. Das ist deutlich schneller, als Tabellen zwischen Tests zu truncaten, und vermeidet eine ganze Klasse von testreihenfolgeabhängiger Fragilität, die auftaucht, sobald eine Test-Suite über eine Handvoll Dateien hinauswächst.

Für Endpoint-Tests kombinieren Sie das mit dem dependency_overrides-Muster aus FastAPI Dependency Injection: Patterns and Anti-Patterns — überschreiben Sie get_db, sodass dieselbe in eine Transaction gekapselte db_session yielded wird, damit ein HTTP-Level-Test über httpx.AsyncClient und ein direkter Test einer Service-Funktion identische Isolationsgarantien teilen.

Größe des Connection-Pools unter gleichzeitiger Last

pool_size und max_overflow sind keine kosmetischen Einstellungen — sie bestimmen direkt, wie viele gleichzeitige Requests überhaupt gleichzeitig Datenbankarbeit ausführen können, bevor spätere auf eine Connection warten müssen. Ein unter realer Parallelität zu klein dimensionierter Pool scheitert nicht laut; er macht einfach jeden Request etwas langsamer, weil Requests auf eine freie Connection warten, was leicht fälschlich der Datenbank selbst als Langsamkeit zugeschrieben wird, statt den Pool als Engpass zu erkennen.

engine = create_async_engine(
    DATABASE_URL,
    pool_size=20,       # connections kept open and ready
    max_overflow=10,    # additional connections allowed under burst load
    pool_timeout=30,    # seconds to wait for a connection before raising
    pool_recycle=1800,  # recycle connections older than 30 minutes
)

Ein vernünftiger Ausgangspunkt ist, den Pool so zu dimensionieren, dass er das typische gleichzeitige Request-Volumen pro Worker-Prozess bequem abdeckt, wobei zu beachten ist, dass bei mehreren Uvicorn-/Gunicorn-Workern jeder seine eigene Engine und damit seinen eigenen Pool bekommt — die tatsächliche Connection-Obergrenze der Datenbank ist also pool_size + max_overflow, multipliziert mit der Anzahl der Worker-Prozesse, nicht nur die Zahl, die in einer einzelnen Engine konfiguriert ist. pool_recycle ist speziell für verwaltete Datenbanken (RDS, Cloud SQL) relevant, die Connections, die auf ihrer Seite länger als einen bestimmten Idle-Schwellenwert offen bleiben, stillschweigend schließen — ohne diese Einstellung treten solche veralteten Connections als verwirrende Fehler mitten im Request zutage, die pool_pre_ping zwar abfängt, die sich durch Recycling aber vermeiden lassen, bevor die Kosten überhaupt entstehen.

Häufige Fallstricke

Fehler: die Session auf Modulebene statt pro Request erstellen. Eine geteilte Session über mehrere Requests hinweg zerstört Isolation und lässt Zustand zwischen unabhängigen Benutzern auslaufen. Lösung: Erstellen Sie die Session immer innerhalb der get_db-Dependency, auf genau einen Request begrenzt.

Fehler: eine Sync-Engine in eine ansonsten asynchrone App mischen. Ein synchroner SQLAlchemy-Aufruf innerhalb einer async def-Route blockiert den Event Loop genau wie in Why Your FastAPI Endpoint Blocks the Event Loop beschrieben — zusätzlich zu den Fehlern, die aus der Vermischung von Async- und Sync-API entstehen. Lösung: Verwenden Sie durchgängig create_async_engine und den asyncpg-Treiber — mischen Sie nicht synchrones und asynchrones SQLAlchemy in derselben Codebasis.

Fehler: auf Relationships in einem Pydantic-Response-Modell zugreifen, ohne sie eager zu laden. Das ist die häufigste Quelle für MissingGreenlet-Fehler in Produktion, weil es während der Serialisierung passiert, nachdem der eigene Code des Handlers bereits abgeschlossen ist. Lösung: Laden Sie jede Relationship, die Ihr Response-Modell berührt, explizit eager in der Query selbst.

Fehler: pro Request eine neue Engine erstellen. Jede Engine besitzt ihren eigenen Pool, daher erschöpft das unter echter Parallelität schnell die maximalen Connections der Datenbank. Lösung: Erstellen Sie die Engine einmal in lifespan und verwenden Sie sie für jede Session wieder.

Fehler: pool_pre_ping nicht setzen. Ein Connection-Pool kann Verweise auf Connections halten, die die Datenbank bereits geschlossen hat (Idle-Timeouts, Neustarts), was zu verwirrenden Fehlern mitten im Request führt. Lösung: Aktivieren Sie in Produktion pool_pre_ping=True, insbesondere hinter einem Load Balancer oder mit einer verwalteten Datenbank mit eigenem Idle-Timeout.

Best Practices für die Produktion

  • Eine Engine pro App, eine Session pro Request. Vermischen Sie diese beiden Lebenszyklen niemals.
  • Setzen Sie expire_on_commit=False, damit committete Objekte während der Response-Serialisierung nutzbar bleiben, ohne neue Lazy Loads auszulösen.
  • Laden Sie für jeden Response-Pfad explizit eager, mit selectinload für Collections und joinedload für einzelne zugehörige Objekte.
  • Erwägen Sie lazy="raise" für Relationships in leselastigen Endpoints, um stille Lazy-Load-Bugs in laute, sofort verwertbare Fehler zu verwandeln.
  • Committen oder rollen Sie in der Session-Dependency immer explizit zurück, verlassen Sie sich nie auf einen impliziten Commit — ein nicht committeter Schreibvorgang, der lokal wegen des Autoflush-Verhaltens „funktioniert“ hat, ist eine häufige Überraschung in der Produktion.

Fazit

MissingGreenlet ist keine mysteriöse Async-Inkompatibilität — SQLAlchemy meldet damit präzise, dass etwas die Datenbank noch gebraucht hat, nachdem der Async-Kontext der Session bereits beendet war, was fast immer ein Problem des Session-Lebenszyklus oder eine Lücke beim Eager Loading ist und kein Framework-Bug. Bringen Sie den Lifespan der Engine, den Request-spezifischen Scope der Session und die Ladestrategie Ihrer Relationships in Einklang, und diese Fehlerklasse verschwindet vollständig, statt von Fall zu Fall debuggt werden zu müssen.

Berühren Ihre Response-Modelle Relationships, die Sie nie explizit eager geladen haben? Falls Sie sich nicht sicher sind, ist es ein schneller, risikoarmer Weg, das herauszufinden, auf Ihren am stärksten genutzten Modellen für einen Tag in einer Staging-Umgebung lazy="raise" zu setzen — jedes stille Lazy Load wird dann zu einem Stack Trace, der genau auf den fehlenden Aufruf von selectinload oder joinedload zeigt.

Weitere Artikel