Blogs / FastAPI y Sesiones Async SQLAlchemy: Una Guía Completa

FastAPI y Sesiones Async SQLAlchemy: Una Guía Completa

Publicado
29 de agosto de 2026
Autor
Faizan Nadeem
Etiquetas
FastAPI SQLAlchemy Python PostgreSQL
Franjas abstractas de luz verde y código formando un túnel digital brillante
Foto de Logan Voss en Unsplash

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called es el error que recibe casi todo equipo que migra una app de FastAPI de SQLAlchemy síncrono a async, y rara vez está claro qué está indicando realmente. El traceback suele apuntar a una línea que parece completamente inocente: acceder a order.customer.name en una plantilla Jinja, o en un serializador de respuesta, lejos de cualquier llamada a base de datos que hayas escrito tú mismo. El instinto es tratarlo como algún bug oscuro de compatibilidad async. No lo es. Es SQLAlchemy diciéndote, con toda la precisión que puede, que intentaste ejecutar una consulta cargada de forma diferida fuera del contexto async que hace posible la carga diferida en primer lugar.

Hacer bien async SQLAlchemy en FastAPI no consiste en memorizar un mensaje de error: consiste en entender que el ciclo de vida de una sesión, el ciclo de vida de un engine y la estrategia de carga de una relación tienen que estar alineados entre sí, y que async hace que los puntos donde pueden desalinearse silenciosamente sean mucho menos tolerantes de lo que jamás fue SQLAlchemy síncrono. Esta publicación cubre el alcance de una sesión por solicitud hecho correctamente, qué dispara realmente MissingGreenlet, por qué relaciones que nunca consultaste explícitamente pueden hacer fallar una respuesta y dónde debería vivir el propio engine en relación con el lifespan de tu app.

Aprenderás:

  • Cómo delimitar correctamente una sesión async de SQLAlchemy por solicitud usando Depends y yield de FastAPI
  • Qué significa realmente MissingGreenlet y los tres lugares donde aparece con más frecuencia
  • Por qué acceder a un atributo de relación no cargado después de que termina el contexto de la sesión provoca una explosión de lazy-load
  • La diferencia entre un engine administrado por lifespan y una sesión delimitada por solicitud, y por qué no son el mismo ciclo de vida
  • Estrategias de eager loading (selectinload, joinedload) que previenen errores de lazy-load antes de que ocurran
  • Una configuración async completa y funcional que puedes copiar directamente en un proyecto

Tabla de contenidos

  1. Lo básico
  2. Delimitación de sesión por solicitud
  3. El error MissingGreenlet, explicado
  4. Explosiones de lazy-load
  5. Engine administrado por lifespan vs sesión delimitada por solicitud
  6. Estrategias de eager loading
  7. Una configuración completa y funcional
  8. Pruebas de código async con SQLAlchemy
  9. Dimensionamiento del pool de conexiones bajo carga concurrente
  10. Errores comunes
  11. Buenas prácticas de producción

Lo básico

Por qué async SQLAlchemy se comporta distinto que sync

La carga diferida de SQLAlchemy síncrono es tolerante casi por accidente: cuando accedes a order.customer y aún no se ha cargado, SQLAlchemy simplemente emite una nueva consulta síncrona en ese mismo momento y devuelve el resultado. Funciona porque una llamada bloqueante a base de datos dentro de un acceso a atributo de Python es invisible: Python no distingue entre “acceso rápido a atributo” y “acceso a atributo que secretamente hace I/O”.

Async SQLAlchemy no puede hacer eso en silencio, porque emitir una consulta requiere await, y no puedes usar await dentro de un acceso simple a atributo (__getattr__ no puede ser una corrutina de una forma que Python vaya a esperar implícitamente por ti). La solución real de SQLAlchemy es un puente llamado greenlet, que permite que ciertas llamadas con apariencia síncrona se ejecuten dentro de un contexto async cambiando a un greenlet que puede hacer await en tu nombre; pero ese puente solo existe dentro de los límites que SQLAlchemy configura para ello, específicamente mientras el contexto async de una sesión está activo. Sal de esos límites y MissingGreenlet es SQLAlchemy diciéndote que el puente ya no está ahí.

El engine, la sesión y la relación: tres ciclos de vida distintos

El tema recurrente detrás de casi cualquier bug de async SQLAlchemy en una app FastAPI es que tres cosas necesitan ciclos de vida compatibles, y es fácil desalinearlas: el engine (de larga duración, creado una vez al iniciar la app), la sesión (de corta duración, una por solicitud) y la carga de relaciones (debe ocurrir mientras la sesión que hará la carga siga abierta). Equivocarte con cualquiera de estas tres produce una variante distinta del mismo problema subyacente.

Delimitación de sesión por solicitud

El patrón correcto es una dependencia basada en yield, exactamente el patrón tratado de forma general en Inyección de dependencias en FastAPI: patrones y antipatrones: se abre una sesión cuando comienza una solicitud, y se garantiza su cierre cuando termina, independientemente de si el handler tuvo éxito o lanzó una excepción:

# 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

Aquí hay dos cosas fáciles de hacer mal. Primero, expire_on_commit=False importa específicamente en FastAPI: por defecto, SQLAlchemy expira todos los atributos cargados después de un commit, lo que significa que el siguiente acceso a un atributo vuelve a disparar una carga diferida; lo cual, como veremos a continuación, es exactamente la situación que produce MissingGreenlet si ese acceso ocurre después de que haya comenzado la serialización de tu respuesta. Establecerlo en False mantiene utilizables tras el commit los atributos ya cargados sin disparar una nueva consulta. Segundo, la sesión debe crearse dentro de la función de dependencia, no en el momento de importación del módulo: una sesión creada una vez y reutilizada entre solicitudes comparte silenciosamente estado (y, peor aún, conexiones) entre solicitudes no relacionadas, lo que rompe por completo el modelo de “una sesión por solicitud” del que depende este patrón.

El error MissingGreenlet, explicado

MissingGreenlet se dispara cuando SQLAlchemy necesita ejecutar I/O — una carga diferida implícita, un flush, un refresh — fuera del contexto async puenteado por greenlet, lo que en la práctica significa que la sesión (o su conexión subyacente) ya se cerró o que la ruta de código en realidad no se está ejecutando dentro de una llamada async esperada con await. Hay tres lugares donde esto aparece constantemente:

1. Acceder a una relación después de que la solicitud ya respondió. Si un modelo de respuesta de Pydantic o una tarea en segundo plano toca order.customer después de que el bloque async with de get_db haya terminado, la sesión está cerrada y ya no queda ningún puente de greenlet para hacer la consulta implícita.

2. Llamar a una librería de serialización síncrona sobre un objeto ORM con relaciones no cargadas. Algunos serializadores (o configuraciones antiguas de Pydantic usando .from_orm() en modelos profundamente anidados) tocan cada atributo durante la serialización, incluidos aquellos que nunca cargaste explícitamente; si alguno de ellos dispara una carga diferida fuera del contexto activo de la sesión, el resultado es MissingGreenlet.

3. Usar una llamada de estilo sync directamente sobre una sesión o engine async. Código copiado y pegado desde una base de código de SQLAlchemy síncrono — db.query(...) en lugar de await db.execute(select(...)), o acceder a .scalars() sin primero esperar con await la llamada a execute — elude por completo las expectativas del driver async y produce el mismo error, porque la ruta de la API sync nunca estuvo conectada al puente de greenlet desde el principio.

La solución en todos los casos tiene la misma forma: asegúrate de que todo lo que necesite datos de la base de datos ocurra mientras la sesión siga abierta y dentro de una llamada esperada con await, no después.

Explosiones de lazy-load

Incluso cuando MissingGreenlet no se dispara — porque, por ejemplo, relaciones lazy="select" casualmente son accedidas mientras la sesión aún sigue técnicamente abierta — aparece otro problema: una explosión de consultas N+1, ahora pagando además el coste de un round trip async completo por cada lazy load en lugar de uno sync barato.

@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
    ]

Para 100 pedidos, esto emite 1 consulta para obtener los pedidos y luego, dentro de la list comprehension, hasta 100 consultas implícitas adicionales: una por cada acceso a o.customer, cada una un round trip async completo a la base de datos. En SQLAlchemy síncrono esto ya es un problema de rendimiento; en async SQLAlchemy con frecuencia también es un problema de corrección, porque que la carga diferida funcione o no depende de detalles sutiles de temporización sobre si el contexto greenlet de la sesión sigue considerándose activo en el momento del acceso, que es exactamente el tipo de cosa que funciona en una prueba local rápida y falla de forma intermitente bajo patrones reales de solicitudes.

Engine administrado por lifespan vs sesión delimitada por solicitud

El engine y la sesión no son el mismo objeto con nombres distintos: son ciclos de vida distintos que cumplen propósitos distintos, y confundirlos es una fuente común de agotamiento del pool de conexiones.

El engine posee el pool de conexiones real y debe crearse exactamente una vez, al iniciar la aplicación, y desecharse exactamente una vez, al apagarse, mediante el lifespan de FastAPI. Esto también es exactamente el tipo de código transversal y ajeno al dominio que debe vivir en un core/database.py compartido, como se expone en Estructura de proyecto FastAPI que sobrevive al crecimiento: el router de cada dominio importa get_db desde ahí, y ninguno necesita saber cómo se construyó el propio engine:

# 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)

La sesión es barata, de corta vida y está delimitada a una sola solicitud mediante la dependencia get_db mostrada antes: toma prestada una conexión del pool del engine durante la duración de la solicitud y la devuelve cuando la sesión se cierra. Crear un nuevo engine por solicitud (en lugar de uno por app) es el error que realmente agota el límite de conexiones de una base de datos bajo carga, porque cada engine trae su propio pool, y los pools que nunca se desechan mantienen sus conexiones abiertas indefinidamente. Vale la pena usar pool_pre_ping=True por defecto en producción: valida que una conexión del pool no esté obsoleta (cerrada desde el lado de la base de datos, o por el timeout de inactividad de un balanceador de carga) antes de entregársela a una solicitud, intercambiando un pequeño coste de latencia por evitar un error mucho más confuso de “connection already closed” apareciendo en mitad de la solicitud.

Estrategias de eager loading

La solución duradera tanto para MissingGreenlet como para explosiones N+1 es la misma: cargar explícitamente lo que necesitas mientras la sesión está abierta, en lugar de depender de que la carga diferida implícita ocurra después.

selectinload emite una segunda consulta separada para las filas relacionadas, agrupada por clave primaria: es la opción por defecto adecuada para relaciones one-to-many:

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 trae los datos relacionados mediante un JOIN SQL en la misma consulta: es mejor para relaciones many-to-one o one-to-one donde las columnas extra por fila son baratas:

from sqlalchemy.orm import joinedload

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

Para una relación que necesitas en la mayoría de las rutas de lectura, considera establecer lazy="raise" en la propia definición de la relación: convierte una carga diferida implícita accidental en una excepción inmediata y ruidosa en el punto exacto de acceso, en lugar de un MissingGreenlet varias capas de stack lejos del error real:

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

Esto intercambia un error confuso en tiempo de ejecución por uno mucho más claro, justo donde realmente hace falta añadir la opción de eager-load que falta.

Una configuración completa y funcional

Uniendo las piezas — engine, fábrica de sesiones, dependencia, modelo y 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

Observa .unique() en el resultado al usar joinedload en una consulta que produce colecciones: sin eso, un join que expande filas puede devolver objetos padre duplicados, un detalle que es fácil pasar por alto hasta que aparece como entradas duplicadas sin explicación en una respuesta.

Pruebas de código async con SQLAlchemy

Probar correctamente código async de base de datos significa dar a cada prueba la misma garantía de sesión delimitada por solicitud que tiene producción, sin levantar un cliente HTTP completo para cada prueba unitaria. El enfoque más limpio envuelve cada prueba en su propia transacción y hace rollback al final, de modo que las pruebas nunca filtren estado entre sí sin importar el orden de ejecución:

# 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()

Cada prueba que usa db_session obtiene una vista completamente aislada de la base de datos: las inserciones hechas en una prueba son invisibles para la siguiente, porque la transacción externa hace rollback después de cada prueba independientemente de si la propia prueba llamó a commit(). Esto es significativamente más rápido que truncar tablas entre pruebas, y evita toda una clase de inestabilidad dependiente del orden de pruebas que aparece cuando una suite crece más allá de unos pocos archivos.

Para pruebas a nivel de endpoint, combina esto con el patrón dependency_overrides de Inyección de dependencias en FastAPI: patrones y antipatrones: sobrescribe get_db para que haga yield del mismo db_session envuelto en transacción, de modo que una prueba a nivel HTTP con httpx.AsyncClient y una prueba directa de función de servicio compartan garantías de aislamiento idénticas.

Dimensionamiento del pool de conexiones bajo carga concurrente

pool_size y max_overflow no son ajustes cosméticos: determinan directamente cuántas solicitudes concurrentes pueden estar haciendo trabajo de base de datos al mismo tiempo antes de que las siguientes empiecen a esperar por una conexión. Un pool demasiado pequeño bajo concurrencia real no falla ruidosamente; solo hace que cada solicitud sea un poco más lenta mientras espera su turno por una conexión libre, algo que es fácil atribuir por error a que la base de datos es lenta en sí misma en lugar de al pool como cuello de botella.

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
)

Un punto de partida razonable es dimensionar el pool para cubrir cómodamente tu volumen típico de solicitudes concurrentes por proceso worker, recordando que si ejecutas múltiples workers de Uvicorn/Gunicorn, cada uno obtiene su propio engine y por tanto su propio pool: el techo real de conexiones de la base de datos es pool_size + max_overflow, multiplicado por el número de procesos worker, no solo el número configurado en un engine. pool_recycle importa específicamente para bases de datos administradas (RDS, Cloud SQL) que cierran silenciosamente conexiones mantenidas abiertas más allá de cierto umbral de inactividad de su lado; sin él, esas conexiones obsoletas aparecen como fallos confusos a mitad de solicitud que pool_pre_ping detecta, pero cuyo coste puede evitarse reciclando la conexión antes de llegar a ese punto.

Errores comunes

Error: crear la sesión en el ámbito del módulo en lugar de por solicitud. Una sesión compartida entre solicitudes rompe el aislamiento y filtra estado entre usuarios no relacionados. Solución: crea siempre la sesión dentro de la dependencia get_db, delimitada a una sola solicitud.

Error: mezclar un engine sync en una app por lo demás async. Una llamada sync de SQLAlchemy dentro de una ruta async def bloquea el event loop exactamente como se describe en Por qué tu endpoint FastAPI bloquea el event loop, además de que el desajuste entre API async/sync produce sus propios errores. Solución: usa create_async_engine y el driver asyncpg de forma consistente; no mezcles SQLAlchemy sync y async en la misma base de código.

Error: acceder a relaciones en un modelo de respuesta de Pydantic sin cargarlas de forma anticipada. Esta es la fuente más común de errores MissingGreenlet en producción, porque ocurre durante la serialización, después de que el propio código del handler ya terminó. Solución: haz eager-load explícito de cada relación que tu modelo de respuesta vaya a tocar, en la propia consulta.

Error: crear un nuevo engine por solicitud. Cada engine posee su propio pool, así que esto agota rápidamente el máximo de conexiones de la base de datos bajo cualquier concurrencia real. Solución: crea el engine una sola vez, en lifespan, y reutilízalo para cada sesión.

Error: no establecer pool_pre_ping. Un pool de conexiones puede conservar referencias a conexiones que la base de datos ya cerró (timeouts por inactividad, reinicios), produciendo fallos confusos a mitad de solicitud. Solución: habilita pool_pre_ping=True en producción, especialmente detrás de un balanceador de carga o una base de datos administrada con su propio timeout de inactividad.

Buenas prácticas de producción

  • Un engine por app, una sesión por solicitud. Nunca confundas ambos ciclos de vida.
  • Establece expire_on_commit=False para que los objetos confirmados sigan siendo utilizables durante la serialización de respuestas sin disparar nuevas cargas diferidas.
  • Haz eager-load explícito para cada ruta de respuesta, usando selectinload para colecciones y joinedload para objetos relacionados individuales.
  • Considera lazy="raise" en relaciones usadas en endpoints con mucha lectura para convertir bugs silenciosos de lazy-load en errores ruidosos y accionables de inmediato.
  • Haz siempre commit o rollback explícitamente en la dependencia de sesión, nunca dependas de un commit implícito: una escritura no confirmada que “funcionó” localmente debido al comportamiento de autoflush es una sorpresa común en producción.

Cierre

MissingGreenlet no es una incompatibilidad async misteriosa: es SQLAlchemy informando con precisión que algo necesitó la base de datos después de que el contexto async de la sesión ya hubiera terminado, lo que casi siempre es un problema de ciclo de vida de sesión o una brecha de eager-loading más que un bug del framework. Si alineas el lifespan del engine, el alcance por solicitud de la sesión y la estrategia de carga de tus relaciones, esta clase de error desaparece por completo en lugar de tener que depurarse caso por caso.

¿Tus modelos de respuesta están tocando relaciones que nunca cargaste explícitamente con eager-loading? Si no estás seguro, establecer lazy="raise" en tus modelos más usados durante un día en un entorno de staging es una forma rápida y de bajo riesgo de averiguarlo: cada lazy load silencioso se convierte en un stack trace que apunta exactamente a la llamada selectinload o joinedload que falta.

Más artículos