connecting
it is stock postgresql 18, so your existing client works unchanged. two things are worth ten minutes before you ship: what each part of the connection string means, and what a pooler in transaction mode quietly changes underneath it.
the connection string
Reveal it on the database’s page in the dashboard. Every reveal is written to the audit log, and because the password is stored encrypted rather than hashed you can reveal it again later instead of resetting it and updating everything that used it.
connection string
postgresql://r_3f9c2a17b4e0d85c:••••••••@<your-host>:5432/db_7a1e4c093b62df85?sslmode=require
r_3f9c2a17b4e0d85c — the role
A generated role name: the prefix r_ and sixteen hexadecimal characters. It is not your email address and it is not derived from anything you typed, which is the point — the credential says nothing about who you are.
the password
Generated with the role. Treat it as a secret: anything holding it can read and write that database directly, with none of the dashboard’s permission checks in the way.
<your-host> — the host
We do not publish a hostname pattern, because there is not one to publish: the host depends on which compute host your database was placed on. Yours is in the string you were given. Copy it from there rather than assembling one from a docs example, including this one.
5432 — the port
The usual Postgres port. Note that what is listening on it is the pooler, not Postgres itself — see the next section, which is the important one.
db_7a1e4c093b62df85 — the database name
The prefix db_ and sixteen hexadecimal characters. This is the surprise: if you named your database notes, the URL still says db_…. The friendly name is a label on our side — it is what the dashboard, the management api and sql over http all use — while the URL carries the opaque physical name. Renaming in the dashboard therefore does not invalidate a connection string.
?sslmode=require
Worth being precise about, because it is easy to over-read. require encrypts the connection so nothing on the wire is in the clear. It does not verify the server’s certificate, so it does not prove you are talking to the server you think you are. The pooler presents a self-signed certificate, which is why verify-full is not the default: it would require you to hold our CA, and briven does not assume you do.
if your threat model needs the server authenticated as well as the traffic encrypted, that is a fair ask and it is not something you can switch on yourself today — tell us and we will talk about it rather than let you assume require already does it.
everything goes through a pooler
Your connection string does not reach Postgres directly. It reaches PgBouncer, which is configured in transaction mode — a server connection is lent to you for the length of one transaction and then handed to somebody else. That is what lets five thousand client connections share a pool of twenty, and it is why an idle database holds no connections at all instead of paying for sockets nobody is using.
The cost is that anything you expect to persist between statements may not, because your next statement can land on a different server connection. Five things follow from that, and all five break real code.
1 · session SET does not stick
SET search_path = … run on its own is applied to whichever server connection happened to serve it, and your next query may not see it. Use SET LOCAL inside an explicit transaction, where the setting lives and dies with the transaction that needs it.
2 · LISTEN and NOTIFY do not work
Both depend on a session that stays yours, and in transaction mode no session does. If you were planning to use Postgres as your message bus, plan something else — this is not a setting you can turn on.
3 · server-side prepared statements are unreliable
This is the classic one, and the one you are most likely to hit. A statement prepared on one server connection is not there when your next call lands on another, and drivers surface that as errors like prepared statement “s0” already exists or does not exist. Prisma and node-postgres are the usual reporters. The honest advice is to turn prepared statements off in your driver — the settings for both are below.
4 · session-scope advisory locks misbehave
pg_advisory_lock is held by a session, so a lock you take is not reliably the lock you later release, and one you never explicitly release may outlive what you meant it to guard. If you need advisory locking, use the transaction-scoped variants so the lifetime matches what the pooler actually gives you.
5 · temporary tables do not survive between statements
A temp table belongs to a session too. Create it and use it inside one transaction, or use a CTE instead.
none of this applies inside a single explicit transaction. begin, do the work, commit — and for the length of that transaction you have one server connection to yourself and postgres behaves exactly as you remember it. the rule of thumb: if state has to survive past a commit, it has to live in a table.
the limits set on your role
Three brakes are applied to your role when the database is provisioned. They are there so one runaway query cannot take a compute host down for everyone on it.
applied at provisioning
statement_timeout 30s idle_in_transaction_session_timeout 60s CONNECTION LIMIT 20
A single statement is cancelled after 30 seconds. A transaction left open and idle is cut after 60 seconds — which is usually a bug holding locks, not a slow user. And the role may hold at most 20 connections at once; PgBouncer’s pool is the same size, and it will accept up to 5,000 client connections and queue them behind that pool rather than refusing them.
If a legitimate query needs more than 30 seconds — a large backfill, an index build — that is a real case and not something you can raise yourself today. Tell us rather than working around it with a loop of half-finished batches.
psql
Quote the whole string. An unquoted ? is a shell glob, and your shell will quietly discard everything after it — including sslmode.
bash
psql "postgresql://r_3f9c2a17b4e0d85c:your-password@<your-host>:5432/db_7a1e4c093b62df85?sslmode=require" # or keep it out of your shell history entirely export DATABASE_URL="postgresql://…?sslmode=require" psql "$DATABASE_URL"
Everything you know works: \dt to list tables, \d notes to describe one, \timing to see how long a query took.
prisma
The datasource is ordinary. The part that matters is the query parameter that turns prepared statements off — without it you will eventually see prepared statement already exists under concurrency, and it will look intermittent, which is the worst kind of bug to chase.
schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}.env
# pgbouncer=true tells prisma to stop using server-side prepared # statements, which is exactly what transaction pooling requires. DATABASE_URL="postgresql://r_…:password@<your-host>:5432/db_…?sslmode=require&pgbouncer=true&connection_limit=10"
Keep connection_limit comfortably under the role’s limit of 20, and remember it is per instance of your application — four containers at ten each is forty, which is over.
Migrations are worth a thought. Prisma Migrate takes advisory locks and issues DDL, which is exactly the kind of session-scoped work described above, so run migrations as a deliberate one-off step rather than on application boot.
drizzle and node-postgres
node-postgres does not use server-side prepared statements unless you ask for them, so the default path is already safe. Do not pass a name to a query — that is what opts you in.
typescript
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
// the pooler presents a self-signed certificate, which is what
// sslmode=require accepts. this is the node equivalent.
ssl: { rejectUnauthorized: false },
// stay under the role's connection limit of 20, per instance.
max: 10,
});
export const db = drizzle(pool);Everything else is ordinary Postgres: psycopg, sqlx, pgx, JDBC and the rest all connect with the same string. And if your code runs somewhere it cannot hold a socket open at all, skip the driver — sql over http covers the HTTP endpoint, which the Neon serverless client @neondatabase/serverless speaks unmodified.
rotating a credential after a leak
If a connection string reaches a public repository, a log line or a screenshot, rotate it. Rotation issues a new password for the role, so every copy of the old string stops working — including the ones you have forgotten about, which is the entire point.
Be straight about the state of this: there is no button for it in the dashboard today. It is available on the management api and through the cli, and both of those pages show the exact call. Plan for a short outage while you update whatever was using the old string.
an api key is a different credential from a connection string, and rotating one does nothing to the other. keys are created and revoked in the dashboard at api keys. revoking stamps the key revoked and keeps the row for the audit trail — there is no un-revoke, so plan to issue a new one.
if the connection is refused
Work down this list in order. In practice it is almost always the first two.
The string was truncated by your shell. An unquoted URL loses everything from the ? onwards. Quote it.
The database name was swapped for the friendly one. The URL needs the opaque db_… name. notes is only for the dashboard, the management API and the HTTP endpoint.
The password was retyped rather than pasted. Reveal it again in the dashboard and copy the whole string — that is what encrypted-at-rest storage buys you.
Your role is at its 20-connection limit. This shows up as connections that worked yesterday failing today after you scaled up. Lower the pool size in each instance, or reduce the number of instances.
An SSL error rather than a refusal. A client demanding a verified certificate will reject the pooler’s self-signed one. That is verify-full behaviour, not a broken server; use require, or the rejectUnauthorized: false shown above.
A query that hangs and then dies at thirty seconds. That is statement_timeout doing its job, not the network. Look at the query plan first.
Still stuck? Tell us, with the error text. These pages are written from the code that serves them, so if one of them is wrong it is a bug and it gets fixed like one.