FastAPI Lifespan Error: async generator function didnt yield
Decorate your lifespan function with @asynccontextmanager and place exactly one yield statement between startup and shutdown logic.
Root Cause Analysis
This error occurs when Python initializes a FastAPI application configured with a modern lifespan context manager, but the lifespan generator function completes or returns without executing a yield statement, or yields more than once.
1. Missing @asynccontextmanager Decorator
In FastAPI 0.93+, on_event('startup') was deprecated in favor of the lifespan parameter. The lifespan function must be decorated with @contextlib.asynccontextmanager. If the decorator is omitted, FastAPI attempts to enter the raw async generator and raises RuntimeError: async generator function didn't yield.
2. Returning Early Before the Yield Statement
An unexpected return statement or early exception in startup initialization prevents the code from reaching the yield barrier.
3. Multiple Yield Statements
Async context managers must yield exactly once (separating startup from shutdown). Multiple yields raise RuntimeError: async generator function yielded more than once.
4. Async Generator vs Standard Generator Confusion
Using @contextmanager (synchronous) instead of @asynccontextmanager on an async def lifespan function.
Reproduction Code (MCVE)
import inspect
# Simulating a broken lifespan manager that lacks a yield statement
async def broken_lifespan_handler():
print('Initializing database...')
# Missing yield statement
def validate_lifespan(handler):
if not inspect.isasyncgenfunction(handler) and not hasattr(handler, '__aenter__'):
raise RuntimeError("async generator function didn't yield: lifespan context manager must yield exactly once")
validate_lifespan(broken_lifespan_handler)
Solution 1: Use @asynccontextmanager with Single yield Statement
Wrap startup and shutdown routines inside @asynccontextmanager with an explicit yield separating them.
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
# --- STARTUP LOGIC ---
print('Lifespan: Database connection pool opened.')
yield {'db_pool': 'connected'}
# --- SHUTDOWN LOGIC ---
print('Lifespan: Database connection pool closed.')
# Test entering lifespan context
import asyncio
async def test_lifespan():
async with lifespan(None) as state:
print(f'App running with state: {state}')
asyncio.run(test_lifespan())
Solution 2: Protect Startup Code with try...finally Block
Use try/finally inside the lifespan generator to ensure cleanup runs even if shutdown errors occur.
from contextlib import asynccontextmanager
@asynccontextmanager
async def robust_lifespan(app):
print('Resource initialized.')
try:
yield
finally:
print('Resource cleanup guaranteed in finally block.')
A common mistake is forgetting that app: FastAPI must be accepted as the first argument in async def lifespan(app: FastAPI):. Even if you don't inspect app, the parameter is required by Starlette's signature protocol. Edge cases occur when using state variables: yield a dictionary or attach variables to app.state during startup for global accessibility. Contrast this error with RuntimeError: Event loop is closed, which occurs during pytest teardown if loops are closed prematurely.