Database pools
Introduction
A pool can front a PostgreSQL database instead of an HTTPS origin. Callers send SQL over HTTP to the pool's own hostname and get rows back. Every query competes for the pool's concurrency limit, runs as a Postgres role you defined, and lands in the access log attributed to the key that ran it.
Callers hold a short-lived key to a pool, not a database credential, and every query is subject to the pool's concurrency limit. Offboarding someone is revoking one grant.
Choose the backend when you create the pool. It cannot be changed afterwards. Every plan includes database pools; concurrency, queue, and request limits are the same as for an HTTP pool on that plan.
The endpoints
A database pool serves query at the pool hostname itself. Structure and job
results are the other two paths. There is no origin behind it, so any other
path returns 404.
POST https://{pool}.nthpool.cloud
GET https://{pool}.nthpool.cloud/structure
GET https://{pool}.nthpool.cloud/__nth/jobs/{jobId}On a verified custom hostname these become
https://warehouse.acme.com and so on, which is the address a data team
would actually circulate.
Every request needs X-Nthpool-Key, exactly like the proxy.
Pool keys still carry a read/write access level, the same field as on an HTTP
pool. On a database pool that field does not cap HTTP methods: POST / is how
every query arrives. Read-only is the role you created, plus the session
default_transaction_read_only guard. A writes-allowed key does not get a
write role in v1. There is none. See Access.
Running a query
curl https://warehouse.nthpool.cloud \
-H "X-Nthpool-Key: $NTHPOOL_KEY" \
-H "content-type: application/json" \
-d '{
"queries": [
{
"sql": "select country, count(*) from orders where created_at > $1 group by 1",
"params": ["2026-01-01"]
}
],
"schema": "analytics",
"maxRows": 500,
"waitMs": 10000
}'{
"results": [
{
"columns": [{ "name": "country", "type": "text" }, { "name": "count", "type": "int8" }],
"rows": [{ "country": "US", "count": 1420 }, { "country": "CA", "count": 233 }],
"rowCount": 2,
"capped": false,
"durationMs": 412,
"rowFormat": "object"
}
],
"durationMs": 412
}| Field | Meaning |
|---|---|
queries |
1–5 statements, pipelined, with per-statement error isolation. results[i] is queries[i]. One statement is still this array. |
schema |
Optional. Sets search_path to this schema once, before the first statement. Simple identifier only (analytics, not analytics, public). |
maxRows |
Row cap per statement, clamped to the pool's setting. |
waitMs |
How long you will hold the request open. Default 30s, max 120s. |
rowFormat |
object (default) or array. |
Rows come back as objects by default so each value is keyed by column name.
rowFormat: "array" is the compact positional form when you already have
columns and want a smaller payload.
Parameters are bound, not interpolated
params accepts strings, numbers, booleans, and null. They are sent as
untyped text and Postgres infers the type from context, so cast when the
inference would be wrong:
{ "queries": [{ "sql": "select * from events where day = $1::date", "params": ["2026-01-01"] }] }For JSON or arrays, serialize the value yourself and cast it: $1::jsonb.
One statement, structurally
Each sql string runs through the extended query protocol, which executes
exactly one statement. Semicolon soup in one string is rejected by Parse, not
filtered by us.
Every call is queries. One statement or five, same body and the same
{ results, durationMs } response. That is one connection: each statement
is its own BEGIN READ ONLY … SET LOCAL … statement … COMMIT, flushed
as a pipeline. A failing statement does not break the others —
results[i] is either the success shape or a per-item db_error
(index, and name if that item had one). The HTTP status is 200 when
the connection ran; the client interprets results. Connect failures are
still 502/503. Transaction-mode poolers allow this; statement-mode
PgBouncer does not, and we do not target that.
{
"queries": [
{ "sql": "SELECT 1 AS n", "params": [] },
{ "sql": "SELECT id FROM users WHERE id = $1", "params": [1], "name": "user" }
]
}{
"results": [
{ "columns": [{ "name": "n", "type": "int4" }], "rows": [{ "n": 1 }], "rowCount": 1, "capped": false, "durationMs": 4, "rowFormat": "object" },
{ "name": "user", "columns": [{ "name": "id", "type": "int8" }], "rows": [{ "id": 1 }], "rowCount": 1, "capped": false, "durationMs": 3, "rowFormat": "object" }
],
"durationMs": 8
}{
"results": [
{ "columns": [{ "name": "n", "type": "int4" }], "rows": [{ "n": 1 }], "rowCount": 1, "capped": false, "durationMs": 12, "rowFormat": "object" },
{ "error": "db_error", "sqlstate": "42601", "message": "syntax error", "index": 1 },
{ "columns": [{ "name": "n", "type": "int4" }], "rows": [{ "n": 3 }], "rowCount": 1, "capped": false, "durationMs": 9, "rowFormat": "object" }
],
"durationMs": 40
}Max 5. An empty array, leftover sql, or leftover top-level params is a
400. Bind parameters on each queries item. name labels the matching
result and, when repeated with the same SQL, is a prepared statement.
Duplicate names are fine; order is the contract. maxRows and
maxResultBytes apply per statement.
Transactions you start yourself, cursors, COPY, and LISTEN are not
supported. A database pool is not a session; see what this is not.
Long queries: one endpoint, two timings
You state how long you are willing to wait. Finish inside waitMs and you get
rows. Outlast it and you get a handle to the same, still-running query —
it is never restarted.
{
"jobId": "job_9f2c…",
"status": "running",
"pollUrl": "https://warehouse.nthpool.cloud/__nth/jobs/job_9f2c…"
}Send waitMs: 0 (or X-Nthpool-Async: 1) to never wait at all. A batch
becomes one job for the whole request, not one job per statement. Attach a
webhook destination and the result is delivered to you when it
lands, signed, with no polling. The job inlines detail.results when it
fits the body cap; otherwise resultUrl is the same job GET.
Handles require Async to be enabled on the pool, under Concurrency. With it
off, a query that outlasts waitMs keeps running until the pool's request
timeout rather than turning into a handle, and waitMs: 0 is refused with
async_disabled.
waitMs measures the query, not the queue. On a busy pool a request waits
for a concurrency slot first, so the longest a call can take is the pool's queue
timeout plus waitMs. Size your client's own timeout for the sum, or send
waitMs: 0 and never hold a connection at all.
MCP clients apply their own tool-call timeout, so a synchronous query is
bounded by whatever the client allows. Send waitMs: 0, end the turn, and read
the result from the handle later.
Reading a stored result
GET /__nth/jobs/{jobId}?rowFormat=arrayA completed job is the same body as the sync 200 (results plus durationMs).
The read hits storage, never the database, and takes no concurrency slot.
Stored results expire on the pool's result TTL — one hour by default, deliberately shorter than the async job TTL. A query result is a table extract, not a side effect of one.
Caps
capped: true means the pool stopped the result short. cappedReason
says which ceiling fired:
max_rows: pool row cap (maxRows)max_result_bytes: pool byte ceiling (maxResultBytes)
A SQL LIMIT alone does not raise those ceilings. New pools default to
10,000 rows and 8 MiB; raise the pool limits (or narrow the select list) when
you need more.
affectedRows is the size of the result Postgres produced before those
ceilings trimmed the payload. It can be larger than rowCount when the byte
ceiling wins first.
Reading the structure
GET /structure
GET /structure?schema=analytics
GET /structure?schema=analytics&table=events
GET /structure?schema=analytics&table=events&include_column_details=1The payload follows the filters. With no query string you get the schemas the
role can see and table names. schema= lists tables in that schema. schema=
and table= together list column names and types for that table.
include_column_details=1 adds nullability, defaults, and comments, and only
applies when table is set.
If the catalog scan hits a row or byte ceiling, the response sets capped
and cappedReason (max_rows or max_result_bytes), the same reasons a
query result uses. Narrow with schema= or table= rather than raising a
limit.
Structure answers are cached briefly and shared across everyone on the team. Add
refresh=1 after a migration to bypass the cache.
Setting up the role
The Postgres role is the security boundary. nthbouncer decides which role you connect as, for how long, and how many at once — the database decides what that role may read. nthbouncer does not inspect your SQL, so the role's grants are the only enforcement.
Create a role with exactly the access you intend to expose:
CREATE ROLE nthbouncer_ro LOGIN PASSWORD '…';
GRANT CONNECT ON DATABASE analytics TO nthbouncer_ro;
GRANT USAGE ON SCHEMA public TO nthbouncer_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO nthbouncer_ro;
ALTER ROLE nthbouncer_ro SET default_transaction_read_only = on;
ALTER ROLE nthbouncer_ro SET statement_timeout = '30s';The last two lines are defence in depth. The pool sets both on every session anyway — but a role that is read-only in the database stays read-only even if we have a bug.
GRANT USAGE ON SCHEMA is the line people forget. Without it the role can log
in and see nothing, and every query reports that the relation does not exist.
Test connection counts the tables your role can see so you find that in
seconds rather than in a support thread.
v1 is read-only. There is no write credential slot and no flag for one.
Password encryption
The role must use scram-sha-256. md5 and cleartext passwords are refused,
deliberately — md5 is a broken password hash, and cleartext hands your password
to anything that can terminate the connection. If your server still defaults to
md5:
SET password_encryption = 'scram-sha-256';
ALTER ROLE nthbouncer_ro PASSWORD '…';Connecting
The database must be reachable from the public internet over TLS. That covers Neon, Supabase, PlanetScale, RDS and Aurora with a public endpoint, Redshift with public accessibility, and anything else you can dial from outside your network.
Connections are always TLS. The default mode is Verify certificate: the server certificate must chain to a trusted CA and match the hostname. Direct Amazon RDS and Aurora instance endpoints are recognized automatically and trust the AWS RDS certificate authority. RDS Proxy uses public ACM certificates and verifies against the normal public trust store (no RDS CA override).
Require SSL encrypts the connection without verifying the certificate. Use it when a private or custom CA is in the way. Uploading your own CA is not supported yet.
A database with no public address cannot be reached. Use a public read replica or a bastion host.
Why the host must be a hostname
The host field takes a bare public hostname; a bare IP address is rejected. The connection is verified against the certificate presented for the host you entered, and managed Postgres certificates name hostnames, not addresses. If you only have an address, point a hostname you control at it and use that.
Test connection
Every database pool starts in needs connectivity and offers Test connection, which opens a real connection and reports which stage failed: DNS or firewall, TLS, authentication, a missing database, or a role that connected but cannot see anything.
It also reports the role it authenticated as, whether the read-only guard took effect, and how many tables that role can see.
Which engines work
Anything that speaks the PostgreSQL wire protocol: PostgreSQL itself, Aurora PostgreSQL, Redshift, Timescale, Materialize, Greenplum, CockroachDB.
You never tell us which one it is. The query path is identical on all of them, and the engine is detected from the server at connect time. Only schema introspection differs, and an engine we do not recognize degrades that one feature with a clear message rather than breaking the pool.
MySQL is not supported.
Limits
| Setting | Default | Ceiling |
|---|---|---|
| Statement timeout | 30s | 10m |
| Max rows | 10,000 | 50,000 |
| Max result bytes | 8 MiB | 16 MiB |
| Result TTL | 1h | 24h |
The byte ceiling can stop a result before the row cap does. Wide jsonb rows
exhaust the byte budget long before the row count does.
Both caps are enforced while rows accumulate, and statement_timeout is set on
the server for every query — so a runaway query is stopped by the database
itself, not only by us dropping the connection.
Errors
Failures carry the Postgres SQLSTATE, which is the field stable enough to act
on:
{
"error": "db_error",
"sqlstate": "42501",
"message": "permission denied for table customers",
"hint": "grant SELECT on the table to the role"
}| Status | Meaning |
|---|---|
400 |
The statement is wrong — syntax, unknown column, bad cast. |
403 |
The database refused it: read-only violation (25006) or missing privilege (42501). |
429 / 504 |
The pool is at capacity, or no slot came free in time. |
502 |
The database is unreachable, or the connection failed. |
504 |
The query outlived the pool's request timeout and was cancelled. |
A 403 is recorded as its own outcome in your logs, with the full statement
text. A rejection there is an attempt to escape read-only mode, and the
statement is the only useful evidence of what was tried.
Cancelled queries are cancelled at the database, not just dropped. Without that, a runaway query would keep burning your database after the caller was gone and the slot had already been released.
From an agent
Connect once as in Agents. The grant scopes the agent to
specific pools, start_pool_session mints a short-lived key, and the agent gets
pool_schema, pool_query, get_query_result, and pool_count_rows. See
MCP server for the tool list.
Results render as TSV in tool output rather than JSON — measurably fewer tokens for the same rows.
pool_count_rows exists so an agent can size a table before querying it instead
of fetching rows to find out how many there are.
The two access levels differ in what they reach. Read gets the schema and whole-table counts. Session adds queries and filtered counts — filtered, because a count you can narrow at will can be repeated to reconstruct the rows it counts, which is the same access a query has by a longer route. The consent screen says which is which before anyone approves it.
Logs
Database queries appear in Logs with requestKind: query
and their own columns: the statement(s) (truncated; numbered when the
request sent more than one), how many statements ran, duration, rows
returned, whether it was capped, and the key that ran it.
That last column is the point. "Who ran what against production" is answerable here because the pool knows who is asking — which is precisely what a shared connection string cannot tell you.
What this is not
Not a replacement for your application's connection string. Nothing here is meant to sit between an app and its primary database. That needs connection reuse and sub-millisecond overhead, and it is what pgbouncer, RDS Proxy, and Hyperdrive are for. This is for agents, analysts, and scheduled analytical work: low query rate, long duration, high value per query.
Not a connection pooler. One connection per query, opened and closed. The concurrency limit is the pool; the TCP connection is disposable. That is also why a session's state can never leak to the next caller — there is no next caller on that connection.
Not a BI tool. No saved queries, no charts, no scheduling. Webhook delivery is the integration point; your cron calls it.
Concurrency is the reason this exists. Twelve agents each deciding to run a heavy aggregation against the read replica at 9am is the failure the pool prevents — and a connection pooler would not help, because multiplexing twelve connections down to four still runs twelve expensive queries.