Blogs / Connection Pooling de Postgres en Python: Una Guía Completa

Connection Pooling de Postgres en Python: Una Guía Completa

Publicado
4 de septiembre de 2026
Autor
Faizan Nadeem
Etiquetas
PostgreSQL Python Backend Development
Filas de racks de servidores con puertas de malla, detrás de las cuales cables de red naranjas y verde azulado serpentean entre switches en un centro de datos con poca luz
Foto de Taylor Vick en Unsplash

“Solo agrega PgBouncer delante” es la respuesta automática a casi cualquier problema de conexiones de Postgres, y acierta con suficiente frecuencia como para que nadie se detenga a preguntar cuál de los tres modos de pooling de PgBouncer acaban de habilitar, o si el propio manejo de conexiones de su ORM ahora está compitiendo con el pooler en lugar de cooperar con él. Luego una migración añade una prepared statement, o una sesión establece un search_path, y algo que funcionó en todas las pruebas se rompe en producción con un error que ni siquiera apunta de forma obvia al pooler.

El problema real detrás de “demasiadas conexiones” es que una conexión de Postgres es costosa: cada una hace fork de un proceso backend completo con su propia sobrecarga de memoria, mientras que una aplicación web moderna en Python puede querer fácilmente miles de operaciones lógicas concurrentes contra la base de datos. El pooling existe para reconciliar esos dos hechos, pero el modo específico que elijas determina exactamente en qué comportamiento a nivel de sesión puede apoyarse tu aplicación. Si te equivocas a la vez en el modo y en el tamaño del pool, cambias un modo de fallo (too many connections) por otro más sutil (prepared statements rotas silenciosamente, o workers en cola por una conexión que ya está ociosa a dos configuraciones de modo de distancia).

Aprenderás:

  • Por qué una conexión directa de Postgres es lo bastante costosa como para necesitar pooling desde el principio
  • La diferencia real entre los modos de pooling session, transaction y statement de PgBouncer
  • Exactamente qué se rompe en transaction mode, y por qué
  • Cómo dimensionar un pool correctamente en lugar de adivinar un número redondo
  • Cómo los frameworks async de Python cambian las cuentas de dimensionamiento del pool frente a los síncronos
  • Cómo configurar PgBouncer para FastAPI y Django sin entrar en conflicto con el propio manejo de conexiones del framework
  • Cómo diagnosticar realmente un error de “too many connections” en lugar de simplemente aumentar el límite

Tabla de contenidos

  1. Por qué las conexiones de Postgres son costosas
  2. Los tres modos de pooling de PgBouncer
  3. Qué se rompe en transaction mode
  4. Dimensionar un pool correctamente
  5. Python async y el dimensionamiento del pool
  6. Configurar PgBouncer para FastAPI y Django
  7. Diagnosticar “Too Many Connections”
  8. Alternativas a PgBouncer que conviene conocer
  9. Errores comunes

Por qué las conexiones de Postgres son costosas

Postgres usa un modelo de un proceso por conexión: cada conexión nueva hace fork de un proceso backend dedicado, completo con su propia memoria para ejecutar consultas, buffers de ordenación y búsquedas en catálogos cacheadas. Eso es simple y robusto, pero significa que cada conexión cuesta memoria real y nada trivial (habitualmente varios megabytes por conexión ociosa, más bajo carga activa de consultas) y CPU real para hacer el fork en primer lugar. max_connections en postgresql.conf tiene por defecto 100 precisamente porque el servidor no escala con gracia más allá de unos pocos cientos de conexiones; empujarlo a los miles para igualar directamente la concurrencia de la aplicación suele degradar el throughput general en lugar de mejorarlo, porque el servidor pasa más tiempo haciendo cambios de contexto entre procesos backend que ejecutando consultas.

Mientras tanto, una aplicación web en Python bajo tráfico real puede tener cientos o miles de solicitudes concurrentes, cada una queriendo nominalmente una conexión a la base de datos. Conectar un volumen de tráfico equivalente a pestañas de navegador 1:1 con procesos backend de Postgres no escala: ese desajuste, no ningún bug específico, es toda la razón por la que existe el connection pooling. Un pooler se sitúa entre la aplicación y Postgres, mantiene abierto un número pequeño y fijo de conexiones backend reales, y multiplexa muchas más conexiones lógicas de cliente sobre ese pequeño pool.

Un pool a nivel de driver (el QueuePool de SQLAlchemy, asyncpg.create_pool(), CONN_MAX_AGE de Django) resuelve una versión más limitada del mismo problema: reutiliza conexiones dentro de un proceso de aplicación, lo cual ayuda a un script de un solo proceso pero no hace nada para el caso que realmente provoca “too many connections” en producción: docenas de pods de aplicación o procesos worker escalados de forma independiente, cada uno ejecutando su propio pool a nivel de driver, todos conectándose a la misma instancia de Postgres al mismo tiempo. Cien pods abriendo veinte conexiones a nivel de driver cada uno son dos mil backends reales de Postgres, sin importar lo bien ajustado que esté el pool de cada pod individual. Un pooler externo como PgBouncer se sitúa por debajo de todos esos pods como una capa compartida, que es el único lugar donde realmente puede acotarse el número total de conexiones de toda la flota.

Los tres modos de pooling de PgBouncer

PgBouncer es el pooler externo de facto para Postgres, y su comportamiento está definido casi por completo por una sola configuración: pool_mode.

Session pooling: un cliente mantiene la misma conexión al servidor durante toda la vida de su sesión, liberada solo al desconectarse. Desde el punto de vista de la aplicación, esto es funcionalmente idéntico a conectarse directamente a Postgres; cada característica a nivel de sesión (prepared statements, variables de sesión, advisory locks, LISTEN/NOTIFY) funciona exactamente como se espera. La contrapartida es que en realidad no resuelve demasiado el problema del número de conexiones: un cliente de larga duración sigue reteniendo una conexión real de Postgres durante todo el tiempo que esté conectado, que es exactamente aquello que el pooling se suponía que debía arreglar.

Transaction pooling: una conexión al servidor se asigna a un cliente solo durante la duración de una única transacción, y luego se devuelve inmediatamente al pool en el momento en que esa transacción hace commit o rollback. Este es el modo que realmente aporta el beneficio de escalado: cientos de conexiones de cliente ociosas pueden compartir un puñado de conexiones reales al servidor, porque cada una solo retiene una conexión al servidor durante la breve ventana en la que realmente está ejecutando una transacción. También es, con mucha diferencia, el modo desplegado con más frecuencia en producción precisamente por esta razón.

Statement pooling: el modo más agresivo, que libera la conexión al servidor después de cada statement individual, incluso dentro de una transacción. En la práctica se usa rara vez porque no soporta en absoluto transacciones de múltiples statements: un BEGIN en un statement y un COMMIT en el siguiente podrían caer en dos conexiones de servidor diferentes, lo que rompe directamente la semántica transaccional. La mayoría de los despliegues reales nunca tocan este modo.

; pgbouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20

Esa configuración acepta hasta mil conexiones simultáneas de cliente desde la aplicación, mientras abre en todo momento solo 20 conexiones reales a Postgres: la proporción que hace que valga la pena desplegar pooling desde el principio.

Qué se rompe en transaction mode

La velocidad de transaction mode proviene directamente de lo mismo que lo hace peligroso: como la conexión de servidor de un cliente puede cambiar entre transacciones, cualquier cosa que dependa de que el estado de sesión del lado del servidor sobreviva entre transacciones se rompe silenciosamente o se comporta de forma inconsistente.

Prepared statements. PREPARE, y cualquier funcionalidad de ORM o driver construida sobre ello (las conexiones persistentes de Django interactúan aquí, y asyncpg prepara statements por defecto), asume que el statement permanece preparado en el mismo backend en el que fue preparado. En transaction mode, la siguiente transacción puede caer en un backend completamente distinto que nunca vio ese PREPARE, lo que da como resultado errores de “prepared statement does not exist” que parecen intermitentes y dependientes de la carga, porque dependen de qué backend devuelva el pool en ese momento.

Statements SET a nivel de sesión. SET search_path = tenant_42 fuera de una transacción persiste en la conexión de servidor, no en la sesión lógica del cliente: el siguiente cliente que reciba de vuelta esa misma conexión de servidor desde el pool hereda lo último que configuró el cliente anterior, a menos que se haya reiniciado explícitamente. Esto es un riesgo real y silencioso de fuga de datos multi-tenant si search_path (o cualquier configuración con alcance de sesión) se usa para aislar tenants. SET LOCAL dentro de una transacción tiene alcance de esa transacción y se restablece automáticamente al hacer commit, que es la versión segura de la misma idea bajo transaction mode.

Advisory locks. pg_advisory_lock() retenido fuera de una transacción persiste en la conexión hasta que se libera explícitamente, pero bajo transaction mode esa conexión puede entregarse a un cliente completamente distinto en el momento en que termina tu transacción, por lo que el lock o bien se libera antes de lo previsto o, peor aún, parece estar retenido por un cliente posterior no relacionado. Usa pg_advisory_xact_lock() en su lugar, que tiene alcance de transacción y siempre se libera limpiamente al hacer commit o rollback.

LISTEN/NOTIFY. Un LISTEN registrado en un backend no significa nada una vez que esa conexión se devuelve al pool y se entrega a otra persona: transaction mode es fundamentalmente incompatible con listeners de larga duración, porque no hay garantía de que el mismo backend, ni siquiera el mismo cliente, mantenga viva esa suscripción.

Tablas temporales. CREATE TEMP TABLE tiene alcance de sesión (la conexión backend física), no de la conexión lógica del cliente: una tabla temporal creada en una transacción puede no existir, o puede inesperadamente seguir existiendo con datos obsoletos, en cualquier backend en el que termine cayendo la siguiente transacción.

La regla unificadora: bajo transaction pooling, trata cada conexión como sin estado entre transacciones. Cualquier cosa que necesite sobrevivir entre statements debe tener alcance explícito dentro de una sola transacción (SET LOCAL, pg_advisory_xact_lock) o evitarse por completo (un LISTEN de larga duración, prepared statements a nivel de sesión): este es el núcleo práctico de por qué las configuraciones de pgbouncer fastapi y pgbouncer django salen mal de formas que parecen aleatorias hasta que sabes exactamente qué buscar.

Dimensionar un pool correctamente

El impulso de establecer default_pool_size para que coincida con las solicitudes concurrentes esperadas es incorrecto, y lo es por una razón específica y bien conocida: la propia guía de PostgreSQL (repetida por el proyecto PgBouncer y múltiples postmortems de producción) es que el tamaño óptimo del pool suele ser mucho menor de lo que sugiere la intuición, porque una conexión a la base de datos pasa la mayor parte de su tiempo “ocupado” esperando I/O, no consumiendo CPU; y un pool pequeño con una cola corta a menudo supera a un pool grande con contención, porque Postgres en sí tiene un número limitado de núcleos de CPU para ejecutar realmente las consultas.

Una fórmula inicial citada con frecuencia, adaptada de la guía de rendimiento de PostgreSQL, es:

pool_size = ((core_count * 2) + effective_spindle_count)

Para un servidor moderno con almacenamiento respaldado por SSD (tratando efectivamente el número de spindles como bajo), eso da un tamaño inicial razonable de pool para una sola base de datos en el rango de (cores * 2) + 1, ajustado hacia arriba solo después de medir el tiempo real de espera en cola, no adivinado preventivamente al alza. El verdadero ciclo de ajuste es: establece un tamaño de pool conservador, monitoriza la salida de SHOW POOLS de PgBouncer para ver clientes esperando una conexión, y aumenta el pool solo si el tiempo de espera es real y sostenido, en lugar de ser un artefacto de una consulta lenta reteniendo una conexión demasiado tiempo.

SHOW POOLS;
-- cl_waiting column: clients currently queued for a server connection

Un cl_waiting distinto de cero y creciendo persistentemente bajo carga normal es la señal real para aumentar el tamaño del pool o investigar consultas lentas que retienen conexiones demasiado tiempo, no una corazonada ni hacer coincidir el tamaño del pool con max_client_conn.

Python async y el dimensionamiento del pool

Los frameworks síncronos (vistas clásicas de Django, Flask con un servidor WSGI con threads) asignan aproximadamente un proceso worker o thread a una solicitud en vuelo, por lo que la matemática de conexiones se parece a workers * pool_size_per_worker. Los frameworks async cambian esto de raíz: un único worker async puede mantener cientos de solicitudes lógicas concurrentes en vuelo, cada una queriendo potencialmente una conexión a la base de datos al mismo tiempo, todo multiplexado sobre muchos menos threads del SO.

Esto significa que el pool de conexiones a nivel de aplicación de una app async (asyncpg.create_pool(), o el pool del motor async de SQLAlchemy) necesita dimensionarse independientemente de cuántas solicitudes concurrentes pueda sostener el servidor ASGI, y debería situarse bastante por debajo de max_client_conn en PgBouncer, ya que PgBouncer es una segunda capa de pooling por encima de él, no un reemplazo.

import asyncpg

pool = await asyncpg.create_pool(
    dsn="postgresql://user:pass@pgbouncer-host:6432/mydb",
    min_size=5,
    max_size=20,
    statement_cache_size=0,  # required under PgBouncer transaction mode
)

statement_cache_size=0 no es opcional bajo PgBouncer en transaction mode: asyncpg prepara y cachea statements por conexión física por defecto, que es exactamente el comportamiento que se rompe cuando el backend subyacente puede cambiar entre llamadas. Deshabilitarlo intercambia algo de sobrecarga por consulta por corrección bajo pooling; omitir este paso es la causa más común de errores de asyncpg contra PgBouncer del tipo “funciona en local, se rompe bajo carga”.

Configurar PgBouncer para FastAPI y Django

Para FastAPI con el motor async de SQLAlchemy, apunta el motor al puerto de PgBouncer (habitualmente 6432) en lugar de directamente al 5432 de Postgres, y deshabilita el propio cacheo de statements de SQLAlchemy por la misma razón que con asyncpg arriba:

from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    "postgresql+asyncpg://user:pass@pgbouncer-host:6432/mydb",
    pool_size=10,
    max_overflow=5,
    connect_args={"statement_cache_size": 0},
)

Para Django, CONN_MAX_AGE controla el propio comportamiento de conexiones persistentes de Django, y necesita cooperar con PgBouncer en lugar de duplicarlo: ejecutar la persistencia de conexiones de Django sobre PgBouncer en transaction mode significa dos capas de pooling independientes tomando decisiones sobre las mismas conexiones. Establecer CONN_MAX_AGE = 0 (el valor predeterminado histórico) permite que cada solicitud abra y cierre limpiamente su conexión lógica, dejando todo el trabajo real de pooling a PgBouncer por debajo:

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "HOST": "pgbouncer-host",
        "PORT": "6432",
        "NAME": "mydb",
        "DISABLE_SERVER_SIDE_CURSORS": True,  # required under transaction mode
    }
}

DISABLE_SERVER_SIDE_CURSORS importa por la misma clase de razón que statement_cache_size=0 arriba: el soporte de cursores del lado del servidor de Django para .iterator() asume una conexión backend estable a lo largo de múltiples llamadas de fetch, algo que transaction mode no garantiza.

Diagnosticar “Too Many Connections”

FATAL: too many connections for role o remaining connection slots are reserved casi nunca significa que la solución sea aumentar max_connections. Recorre este orden en su lugar:

  1. Comprueba pg_stat_activity para ver qué está manteniendo realmente abiertas las conexiones. SELECT state, count(*) FROM pg_stat_activity GROUP BY state;: un recuento grande de idle in transaction significa que el código de la aplicación está abriendo transacciones y no cerrándolas, algo que ninguna cantidad de pooling arregla por sí sola.
  2. Comprueba si PgBouncer realmente está en la ruta. Es común añadir PgBouncer para un servicio y que otro servicio, un cron job o una herramienta de analytics se conecte directamente al puerto 5432 de Postgres y se salte por completo el pooler.
  3. Comprueba el pool mode frente al uso real. Session mode bajo alta concurrencia reintroduce exactamente el problema que el pooling pretendía resolver; si las conexiones no se están devolviendo con rapidez, transaction mode combinado con arreglar sesiones largas de idle in transaction es casi siempre la solución real.
  4. Solo entonces considera aumentar max_connections, y hazlo sabiendo que cada slot adicional tiene un coste real de memoria en el servidor Postgres, no uno puramente de configuración.

Leer la salida de EXPLAIN ANALYZE para encontrar qué consultas específicas están tardando demasiado y reteniendo conexiones más tiempo del debido es un paso natural a continuación aquí; consulta la guía para leer planes de consulta de Postgres para esa mitad del diagnóstico.

Alternativas a PgBouncer que conviene conocer

PgBouncer es la recomendación por defecto porque es maduro, ligero y está bien documentado, pero es single-threaded por instancia (ejecuta varias instancias detrás de SO_REUSEPORT para escalar en múltiples núcleos) y conviene saber qué más existe. Odyssey, construido en Yandex, y PgCat, escrito en Rust, ambos ofrecen pooling multi-threaded listo para usar junto con balanceo de carga entre réplicas de lectura: algo realmente útil si un único proceso de PgBouncer está limitado por CPU bajo alta rotación de conexiones. Los proveedores de Postgres gestionado (RDS Proxy, el pooler integrado de Supabase, el pooler de Neon) cada vez incluyen más a menudo un equivalente al transaction-mode pooling directamente en la plataforma, lo cual vale la pena revisar antes de levantar una instancia autogestionada de PgBouncer: las mismas compensaciones de modo de esta publicación siguen aplicando, solo que configuradas a través del panel del proveedor en lugar de un archivo pgbouncer.ini.

Errores comunes

Error: establecer el tamaño del pool de PgBouncer igual a max_client_conn. Esto derrota por completo el propósito del pooling: todo el beneficio proviene de un pool pequeño del lado del servidor que atiende a un número mucho mayor de conexiones de cliente. Solución: dimensiona el pool del servidor a partir del número de núcleos y del tiempo de espera medido, independientemente de cuántos clientes puedan conectarse.

Error: usar transaction mode con estado de sesión sin proteger. SET, advisory locks y prepared statements se comportan mal silenciosamente. Solución: usa SET LOCAL y pg_advisory_xact_lock, y deshabilita el cacheo de statements del lado del cliente.

Error: ejecutar dos capas de pooling independientes sin coordinarlas. El CONN_MAX_AGE de Django o el pool del motor de SQLAlchemy compitiendo con el propio pool de PgBouncer por debajo produce un agotamiento de conexiones confuso y difícil de reproducir. Solución: mantén modesto el pool a nivel de aplicación y deja que PgBouncer haga el trabajo pesado de multiplexación.

Error: aumentar max_connections como primera respuesta a errores de conexión. Esto trata el síntoma y añade presión real de memoria en el servidor. Solución: averigua qué está reteniendo realmente las conexiones mediante pg_stat_activity antes de tocar el límite.

Error: olvidar que los pools de conexiones async y PgBouncer se apilan, no se reemplazan entre sí. Establecer un max_size demasiado alto en el pool de asyncpg por encima de un pool de PgBouncer ya dimensionado solo mueve el punto de encolado sin arreglarlo. Solución: dimensiona modestamente el pool de la aplicación y confirma que PgBouncer, y no la app, es la capa que realmente absorbe los picos de conexiones.

Cierre

El connection pooling no es una única decisión de sí o no: es una elección entre tres contratos genuinamente distintos sobre qué estado de sesión sobrevive entre statements, y la velocidad de transaction mode es inseparable de su falta de estado. Hacer esto bien significa hacer coincidir el pool mode con aquello de lo que realmente depende tu aplicación, dimensionar el pool a partir del número de núcleos y del tiempo de espera medido en lugar de un número redondo adivinado, y tratar los pools de conexiones async y PgBouncer como dos capas cooperativas en lugar de duplicados entre sí.

¿Tu tamaño actual de pool es un número que mediste, o un número que simplemente parecía seguro?

Más artículos