# PostgreSQL Connection Pooling on Cloud Run: The Problem Nobody Warns You About

Cloud Run scales horizontally. That's the point. Under load, it spins up new instances automatically — each one handling concurrent requests, each one maintaining its own connection pool to your Cloud SQL PostgreSQL instance.

Here's what that looks like in numbers.

You configure your SQLAlchemy pool with `pool_size=5` and `max_overflow=5` — ten connections per instance. Sounds reasonable. At 10 Cloud Run instances under moderate load, that's 100 connections. At 50 instances during a traffic spike, that's 500. PostgreSQL's default `max_connections` is 100. At 20 instances you've already exceeded it. New connections get rejected. Requests fail.

This isn't a Cloud Run bug. It's the expected behaviour of two systems — one that scales horizontally without limit, one that has a hard connection ceiling — combined without accounting for the interaction.

* * *

## Why It's Worse Than It Looks

The math above assumes every instance is using its full pool. In practice it's more unpredictable. Cloud Run instances don't scale down immediately. Idle instances sit around holding open connections they're not using. Your effective connection count at any given moment is `max_connections_per_instance × live_instances` — and live instances includes the idle ones.

Cloud SQL's connection limits vary by machine tier. A `db-g1-small` (the default for dev environments that get accidentally promoted to prod) caps at around 25 connections. A `db-custom-4-15360` caps at around 4,000. If you're not sure what tier your instance is, you're probably not sure what your connection ceiling is.

```sql
-- Check your current connection usage and limit
SELECT
  count(*) as active_connections,
  max_conn as connection_limit
FROM pg_stat_activity, (SELECT setting::int AS max_conn FROM pg_settings WHERE name = 'max_connections') AS limits
GROUP BY max_conn;
```

Run this during a load test. The number is usually higher than expected.

* * *

## The Fix: Pool Sizing That Accounts for Horizontal Scale

The formula isn't `pool_size = how_many_feels_right`. It's:

```plaintext
max_pool_size_per_instance = floor(db_max_connections / max_cloud_run_instances)
```

Leave headroom for Cloud SQL's internal processes (~3 connections) and for admin access during incidents. A practical formula:

```plaintext
usable_connections = db_max_connections - 5
max_pool_per_instance = floor(usable_connections / max_cloud_run_instances)
```

For a `db-custom-2-7680` with 500 max connections and 20 max Cloud Run instances:

```plaintext
usable_connections = 495
max_pool_per_instance = floor(495 / 20) = 24
```

Set `pool_size` below that ceiling with headroom for overflow:

```python
# config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    db_pool_size: int = 5
    db_max_overflow: int = 10
    db_pool_timeout: int = 30
    db_pool_recycle: int = 1800   # recycle connections every 30 minutes

settings = Settings()
```

```python
# db/pool.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from config import settings

engine = create_async_engine(
    settings.database_url,
    pool_size=settings.db_pool_size,
    max_overflow=settings.db_max_overflow,
    pool_timeout=settings.db_pool_timeout,
    pool_recycle=settings.db_pool_recycle,
    pool_pre_ping=True,   # verify connections before using them
)

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

```python
# dependencies.py
from db.pool import AsyncSessionLocal
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionLocal() as session:
        yield session
```

`pool_pre_ping=True` adds a lightweight `SELECT 1` before each connection is used. It catches stale connections that Cloud SQL dropped on its end — without it, a connection that's been idle for Cloud SQL's timeout period returns an error on the first query instead of reconnecting cleanly.

`pool_recycle=1800` proactively replaces connections older than 30 minutes. Cloud SQL drops idle connections after a configurable timeout (default 10 minutes). Recycling before that point prevents the errors that `pool_pre_ping` catches — it's the belt to `pre_ping`'s suspenders.

* * *

## The Better Fix: PgBouncer

Pool sizing helps. It doesn't solve the fundamental problem — Cloud Run's horizontal scaling means your connection count grows with your instance count, and at high scale, even a small pool per instance adds up.

PgBouncer sits between your application and Cloud SQL and maintains a fixed pool of server-side connections regardless of how many application instances connect to it. A hundred Cloud Run instances can each open 10 connections to PgBouncer; PgBouncer maintains 50 actual connections to PostgreSQL.

On GCP, the cleanest way to run PgBouncer is as a Cloud Run sidecar or a dedicated Cloud Run service:

```yaml
# cloud-run-service.yaml (simplified)
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: pulsecart-api
spec:
  template:
    spec:
      containers:
        - image: gcr.io/your-project/pulsecart-api
          env:
            - name: DATABASE_URL
              value: postgresql+asyncpg://user:pass@localhost:5432/pulsecart
        - image: edoburu/pgbouncer
          env:
            - name: DATABASE_URL
              value: postgresql://user:pass@/cloudsql/your-project:region:instance/pulsecart
            - name: POOL_MODE
              value: transaction
            - name: MAX_CLIENT_CONN
              value: "1000"
            - name: DEFAULT_POOL_SIZE
              value: "50"
```

`POOL_MODE=transaction` is the right mode for stateless APIs — PgBouncer assigns a server connection for the duration of a transaction only, not the entire client session. This gives the highest multiplexing efficiency for request-per-transaction workloads like FastAPI.

The tradeoff: `transaction` mode doesn't support session-level features like `SET LOCAL`, advisory locks, or `LISTEN/NOTIFY`. If you use any of these, use `session` mode instead and accept lower multiplexing efficiency.

* * *

## Cloud SQL Auth Proxy

If you're connecting to Cloud SQL via the Auth Proxy (which you should be — it handles IAM auth and TLS termination), the proxy itself doesn't pool connections. It's a pass-through. All the pooling logic above still applies; the proxy just sits between your application and Cloud SQL handling the authentication layer.

Run the Auth Proxy as a sidecar alongside your application container, not as a separate service — the connection overhead between containers on the same Cloud Run instance is negligible, and it avoids the network hop of a separate service.

* * *

## The Monitoring You Need

Set this up before you need it:

```python
# Add to your /health endpoint
from sqlalchemy import text

@app.get("/health")
async def health(db: AsyncSession = Depends(get_db)):
    try:
        result = await db.execute(text(
            "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()"
        ))
        active_connections = result.scalar()
        return {"status": "ok", "db_connections": active_connections}
    except Exception:
        return JSONResponse(status_code=503, content={"status": "degraded"})
```

Expose connection count in your health endpoint. Set a Cloud Monitoring alert when it approaches 80% of your `max_connections`. At 80% you have time to react. At 100% you're already dropping requests.

* * *

## Summary

Cloud Run + PostgreSQL without connection management is a scaling time bomb. The failure doesn't happen at low traffic — it happens exactly when you can least afford it.

Size your pool to account for horizontal scaling. Add `pool_pre_ping` and `pool_recycle`. Monitor connection count as a first-class metric. Add PgBouncer when the math stops working in your favour.

The problem is predictable. The fix is straightforward. The only reason it surprises teams is that nobody mentions it until production is on fire.
