Skip to main content

Architecture

What pgagroal actually does between your application and PostgreSQL.

Two halves of every connection

Every connection through pgagroal has two halves that do not map one-to-one:

  • -- A client connection: a TCP socket from your application to pgagroal on port 6432.
  • -- A backend connection: a TCP socket from pgagroal to PostgreSQL on port 5432, drawn from a pool.

The asymmetry is the point. You can have hundreds of client connections sharing tens of backend connections. PostgreSQL only sees the backend side, so its load is determined by the pool size, not by how many clients are connected.

Five client connections sharing two backend connections through pgagroalAppAppAppAppApppgagroal:6432PostgreSQL:54325 client connections2 backend connections
N clients : M backends, with N typically much greater than M

Where state lives

pgagroal is deliberately thin. Almost no state lives in the pooler; it routes bytes between client and backend. The location of state is what determines what survives a backend swap under transaction pooling.

StateWhere it livesSurvives a backend swap?
Tables, rows, indexesPostgreSQL on diskYes
Open transactionA specific backend sessionN/A — pgagroal never swaps mid-transaction
Session settings (SET), prepared statements, temp tables, LISTENA specific backend sessionNo (under transaction pipeline)
Authentication, current user, current databaseA specific backend session, established at loginYes — pgagroal only swaps backends for the same user/database
Pool membership, idle/active countspgagroal process memoryLost on pgagroal restart

The reason transaction pooling breaks session-scoped features is structural, not a pgagroal limitation: that state lives on a specific backend, and the pool may hand you a different one on the next transaction.

A query under the session pipeline

In session or performance mode, the client owns one backend for the whole duration of the client connection.

Sequence diagram: client lifecycle under the session pipelineClientpgagroalPostgreSQLTCP connect + authacquire backend from poolqueryresultmore queries on the same backenddisconnectDISCARD ALL; return to pool
One client connection holds one backend for its entire lifetime.
  1. Client opens TCP to pgagroal on 6432.
  2. pgagroal authenticates the client.
  3. pgagroal pulls a backend from the pool for the requested user and database. If none is available, it opens a new one (up to MAX_CONNECTIONS) or waits up to blocking_timeout for one to free up.
  4. From this point until the client disconnects, every byte is proxied between the same client socket and the same backend socket. Queries, results, transactions — all on one backend.
  5. Client disconnects. pgagroal runs DISCARD ALL on the backend to clear session state and returns it to the pool.

From the application's point of view, this is indistinguishable from a direct PostgreSQL connection, except that the connection-establishment cost is paid once per pool slot rather than once per client.

A query under the transaction pipeline

In transaction mode, the client only holds a backend for the duration of one transaction.

Sequence diagram: client lifecycle under the transaction pipeline, with a backend swap between transactionsClientpgagroalPostgreSQLTCP connect + authTRANSACTION 1 — borrow Backend ABEGINqueryresultCOMMITBackend A returned to pool— client idle, no backend held —TRANSACTION 2 — borrow Backend B (different connection)BEGINqueryresultCOMMITBackend B returned to pooldisconnect (no backend held)
One client, two transactions, two different backend connections. Session state from Transaction 1 does not exist on Backend B.
  1. Client opens TCP and authenticates as above.
  2. Client issues a statement. pgagroal borrows a backend from the pool, attaches it to this client, and forwards the statement.
  3. pgagroal proxies the transaction in both directions until it sees COMMIT or ROLLBACK.
  4. Backend is detached from the client and returned to the pool. The client connection stays open with no backend attached.
  5. Next statement from the client may be served by a different backend. The new backend has no memory of the previous one's session state.
  6. Client disconnects. No backend is held at this point, so nothing needs to be returned.

Steps 4 and 5 are where the connection ratio comes from. A client that sits idle between transactions holds no backend, so other clients can use that capacity. This is also why long transactions defeat the model: a backend held for ten seconds is unavailable to other clients for ten seconds.

The pool: warmup, validation, recycling

The pool is not a fixed set of pre-opened connections. pgagroal grows and shrinks it within configured bounds, and optionally tests connections before handing them out.

Sizing bounds

Each database+user pair has an initial_size, min_size, and max_size. The container exposes a single ceiling via MAX_CONNECTIONS. See Configuration for the env vars and defaults.

Validation

pgagroal can verify a backend is alive before handing it out (foreground), on a timer in the background (background), or not at all (off, the default). Foreground validation costs a round-trip per acquire; background validation amortizes the cost but allows a small window where a stale connection can be handed out.

Idle timeout and recycling

Backends idle in the pool past their timeout are closed to free PostgreSQL resources. A connection that has served many transactions can also be recycled to release backend memory.

Failure handling

If PostgreSQL restarts, pgagroal's pooled connections become invalid. The next acquire will reconnect transparently (typically within 60s); existing in-flight clients see the underlying disconnect.

Queueing under load

When all backend connections are in use, new requests do not fail immediately. They wait.

  • -- A client requesting a backend (at connect time in session mode, or per-transaction in transaction mode) blocks until either a backend becomes available or blocking_timeout is reached.
  • -- Application latency increases — visibly so during sustained pool saturation.
  • -- PostgreSQL load does not increase proportionally. It is doing the same work as before; the pooler is queueing the surplus on the client side.
  • -- This is expected behavior. The pool size is the throttle. If you do not want the throttle, raise MAX_CONNECTIONS within your capacity budget.

A small amount of queueing is normal and expected in a healthy system.

Queueing protects PostgreSQL from concurrency it cannot serve. Removing the queue (by oversizing the pool) does not increase throughput — it just moves the contention from the pool to PostgreSQL itself, where it is harder to reason about. See Performance and capacity for sizing math, or Troubleshooting for what sustained queueing looks like in practice.

What pgagroal is not

Worth stating explicitly, because pooler products vary in scope:

  • -- Not a query rewriter. Statements pass through unchanged.
  • -- Not a load balancer in the read-replica sense. The container connects to one backend address; failover and replica routing are configured separately.
  • -- Not a query cache. Identical queries hit PostgreSQL every time.
  • -- Not a TLS terminator that decrypts and re-encrypts to change protocols. It can carry TLS end-to-end (in session or transaction pipelines), but it does not transform traffic.

The thinness is deliberate. It keeps the per-statement overhead small enough that a pooler is worth using even for workloads where the connection ratio is not the primary motivation.

See also: Concepts for what each pipeline preserves, Choosing your connection path for whether to put a pooler in front at all, or Observability for inspecting pool state at runtime.

Run pgagroal

docker pull elevarq/pgagroal:1.0.0