Database: Postgres

If your app needs or you prefer to use Postgres, Goribu will handle the Cloudflare side for you on every deploy and your handlers will be able to query it right away.

added in 0.9.4
On this page

Set up Postgres

This works a bit differently than other features since your database stays with your provider, such as PlanetScale, Neon or your own server. Your Worker reaches it through Hyperdrive, Cloudflare's connection pool and query cache for databases hosted elsewhere (Cloudflare Hyperdrive). To use it, add a database entry with type postgres to your existing config:

goribu.config.js
import { defineConfig } from "goribu/server";

export default defineConfig({
  name: "my-app",
  compatibilityDate: "2026-09-01",
  database: { type: "postgres" }, // Add this
});

Then put the connection strings in your env files. They never go in goribu.config.js, because that file ends up in your deployed code and it's not secure:

.env
# Your local or development database
POSTGRES_URL=postgres://postgres:postgres@localhost:5432/my_app
.env.production
# Your provider's direct (non-pooled) connection string
POSTGRES_URL=postgres://app:secret@db.example.com:5432/my_app?sslmode=require
  • D1 or Postgres: D1 needs no account elsewhere and Goribu creates it for you on Cloudflare. Choose Postgres when you already have a Postgres database or provider, or want to use Postgres. An app can use one or the other, not both.
  • Local database: pnpm dev connects straight to the database in .env.
  • Production database: The first deploy sets up Hyperdrive and every deploy updates it from .env.production. To change the password, edit the file and deploy again.
  • User and password: Both strings need them. A local Postgres that doesn't check passwords accepts any, such as postgres:postgres above.
  • Compatibility date: Postgres apps need 2024-09-23 or later. Goribu turns on the nodejs_compat flag that the driver needs.
  • Tables: Create tables with a migration before querying them.

Fresh and cached reads

Handlers get two Postgres.js clients. Goribu doesn't wrap them, so everything in the Postgres.js docs works as written. The only difference between the two is how fresh the results are:

ClientWhen to use it
req.postgresThe default. Writes, transactions, sign-in, sessions, permissions, billing, any read right after a write, and whenever you're unsure.
req.postgresCachedPublic pages where a minute-old result is fine, such as a homepage list, a product catalog, search results, or a stats dashboard.

Here a poll page reads the poll itself fresh, so a vote shows up immediately, and the "recent polls" sidebar from the cache:

src/routes/polls/[id].ts
import type { Handler } from "goribu/server";

type Poll = { id: number; title: string };

export const GET: Handler = async (req, res) => {
  // Fresh: this page must reflect the latest change.
  const [poll] = await req.postgres<Poll[]>`
    SELECT id, title FROM polls WHERE id = ${req.params.id}
  `;
  if (!poll) {
    return res.notFound();
  }

  // Cached: fine if a new poll appears a minute late.
  const recent = await req.postgresCached<Poll[]>`
    SELECT id, title FROM polls ORDER BY id DESC LIMIT 20
  `;

  return res.json({ poll, recent });
};
src/routes/polls/[id].js
export const GET = async (req, res) => {
  // Fresh: this page must reflect the latest change.
  const [poll] = await req.postgres`
    SELECT id, title FROM polls WHERE id = ${req.params.id}
  `;
  if (!poll) {
    return res.notFound();
  }

  // Cached: fine if a new poll appears a minute late.
  const recent = await req.postgresCached`
    SELECT id, title FROM polls ORDER BY id DESC LIMIT 20
  `;

  return res.json({ poll, recent });
};
  • How stale: Hyperdrive keeps a cached result for up to about 75 seconds and doesn't clear it when you write. A row you just inserted can take that long to show up through req.postgresCached.
  • Development is always fresh: pnpm dev connects both clients straight to your local database, so a wrong choice only shows up in production.

Write rows

Validate input, write through req.postgres, and turn a duplicate into a field error instead of a crash:

src/routes/signup.ts
import type { Handler } from "goribu/server";

export const POST: Handler = async (req, res) => {
  const { email } = await req.body();
  if (typeof email !== "string" || !email.includes("@")) {
    return res.invalid({ email: "Enter an email address." });
  }

  try {
    const [user] = await req.postgres<{ id: number }[]>`
      INSERT INTO users (email) VALUES (${email.trim()}) RETURNING id
    `;

    return res.redirect(`/users/${user.id}`);
  } catch (error) {
    // users.email has a UNIQUE constraint.
    if ((error as { code?: string }).code === "23505") {
      return res.invalid({ email: "This email is already registered." });
    }
    throw error;
  }
};
src/routes/signup.js
export const POST = async (req, res) => {
  const { email } = await req.body();
  if (typeof email !== "string" || !email.includes("@")) {
    return res.invalid({ email: "Enter an email address." });
  }

  try {
    const [user] = await req.postgres`
      INSERT INTO users (email) VALUES (${email.trim()}) RETURNING id
    `;

    return res.redirect(`/users/${user.id}`);
  } catch (error) {
    // users.email has a UNIQUE constraint.
    if (error.code === "23505") {
      return res.invalid({ email: "This email is already registered." });
    }
    throw error;
  }
};
  • Error codes: Errors carry Postgres's own code, such as 23505 for a duplicate, 23503 for a missing foreign key, or 42P01 for a missing table (usually a migration that hasn't run). Check the code, not the message text, and rethrow anything else so it reaches your error page.

Transactions

Use req.postgres.begin() when several writes must all succeed or all fail, such as a poll and its options. It commits when the callback finishes and rolls everything back if it throws:

Inside a request handler
await req.postgres.begin(async (sql) => {
  const [poll] = await sql<{ id: number }[]>`
    INSERT INTO polls (title) VALUES (${title}) RETURNING id
  `;

  await sql`
    INSERT INTO options (poll_id, text)
    VALUES (${poll.id}, 'Yes'), (${poll.id}, 'No')
  `;
});
Inside a request handler
await req.postgres.begin(async (sql) => {
  const [poll] = await sql`
    INSERT INTO polls (title) VALUES (${title}) RETURNING id
  `;

  await sql`
    INSERT INTO options (poll_id, text)
    VALUES (${poll.id}, 'Yes'), (${poll.id}, 'No')
  `;
});
  • Decide as you go: Unlike a D1 batch, you can read a result and choose the next query inside the same transaction.
  • Fresh client only: Never write or open a transaction on req.postgresCached.

Queries and connections

Values you put in ${} are sent separately from the SQL text, so user input can't change your query. For dynamic table or column names and bulk inserts, use Postgres.js helpers such as sql(identifier) and sql(rows).

  • Don't close connections: Never call .end(). Hyperdrive keeps the real database connections open and shares them and Goribu creates the clients per request.
  • Parallel queries: A request can open six outbound connections at once: up to three for req.postgres, two for req.postgresCached, and one left for fetch(). Queries in a Promise.all beyond that wait their turn.
  • No session state: Each query may run on a different database connection, so SET, temporary tables, session locks, and LISTEN/NOTIFY don't carry over. Use SET only inside a transaction.
  • Sending arrays: Pass string and number arrays as they are. Wrap arrays of booleans, Dates, binary data (bytea), or bigints in sql.array(), such as sql.array([true, false]).
  • Reading arrays: Arrays come back as JavaScript arrays, with two exceptions. int8[] and numeric[] arrays hold strings, so large or precise numbers aren't rounded. Arrays of your own enum types come back as a single string, such as '{a,b}'.
  • Run near the database: Each uncached query travels from the Worker to your database and back. When a request runs several in a row, placement can run the Worker near the database and shorten every trip.
  • Other libraries: See Use an ORM or query builder.

Keep cached queries cacheable

Hyperdrive never caches a query that calls NOW(), CURRENT_DATE, or RANDOM(), because the result could change every time. Compute the value in JavaScript instead, and round it so many requests send the same value and share one cached result:

Inside a request handler
type Upcoming = { id: number; title: string };

// The day, not the millisecond, so a whole day of requests share the cache.
const today = new Date().toISOString().slice(0, 10);

const events = await req.postgresCached<Upcoming[]>`
  SELECT id, title FROM events WHERE starts_on >= ${today} ORDER BY starts_on
`;
Inside a request handler
// The day, not the millisecond, so a whole day of requests share the cache.
const today = new Date().toISOString().slice(0, 10);

const events = await req.postgresCached`
  SELECT id, title FROM events WHERE starts_on >= ${today} ORDER BY starts_on
`;

Use an ORM or query builder

Goribu doesn't include an ORM, but you can bring your own. How depends on what the library needs.

Libraries built on Postgres.js

Some libraries, such as Drizzle, can run on top of a Postgres.js client. Hand them req.postgres or req.postgresCached, and you keep everything above: shared connections, the connection limits, and the fresh or cached choice:

src/routes/posts.ts
import type { Handler } from "goribu/server";
import { drizzle } from "drizzle-orm/postgres-js";
import { posts } from "../db/schema";

export const GET: Handler = async (req, res) => {
  // A public list, so a minute-old result is fine.
  const db = drizzle({ client: req.postgresCached });
  const recent = await db.select().from(posts).limit(20);

  return res.json(recent);
};
src/routes/posts.js
import { drizzle } from "drizzle-orm/postgres-js";
import { posts } from "../db/schema";

export const GET = async (req, res) => {
  // A public list, so a minute-old result is fine.
  const db = drizzle({ client: req.postgresCached });
  const recent = await db.select().from(posts).limit(20);

  return res.json(recent);
};
  • Same rules: Writes and transactions go through a client built on req.postgres, never req.postgresCached.
  • One per request: Create the library's client inside the handler, like the example. Goribu's clients only live for one request.

Libraries with their own driver

Other libraries connect with a different driver, such as pg. Give them a Hyperdrive connection string from req.env, locally and in production:

  • req.env.POSTGRES.connectionString: Caching off, like req.postgres.
  • req.env.POSTGRES_CACHED.connectionString: Caching on, like req.postgresCached.

In TypeScript, add both bindings to your handler's Env type, because req.env isn't typed for them by default:

Inside a route file
import type { Handler } from "goribu/server";

type PostgresEnv = {
  POSTGRES: { connectionString: string };
  POSTGRES_CACHED: { connectionString: string };
};

export const GET: Handler<Record<string, unknown>, PostgresEnv> = async (req, res) => {
  const connectionString = req.env.POSTGRES.connectionString;
  // Create your own driver's client from connectionString here.
  return res.json({ ok: true });
};
Inside a route file
export const GET = async (req, res) => {
  const connectionString = req.env.POSTGRES.connectionString;
  // Create your own driver's client from connectionString here.
  return res.json({ ok: true });
};
  • You manage the client: Goribu's connection limits don't apply to it, so follow your library's guide for Cloudflare Workers and Hyperdrive.
  • Check Workers support: Not every Node.js driver runs on Workers. Goribu already turns on nodejs_compat.

Schema changes

Keep changing tables through Goribu migrations, so deploy applies them in the right order. If your ORM generates SQL for a schema change, put that SQL in a new migration file instead of running the ORM's own migrate command.

Rules for you and your agent

A checklist for reviewing a change, by you or a coding agent. New apps include the same list in their AGENTS.md.

  1. Use req.postgres unless the read is public or a listing and may be a minute stale, when req.postgresCached is more suitable.
  2. Never write or open a transaction on req.postgresCached.
  3. Build SQL only with tagged templates. sql.unsafe is for trusted, static SQL only.
  4. In cached queries, avoid NOW(), CURRENT_DATE, and RANDOM(). Compute the value in JS and pass it as a parameter.
  5. Never call .end().
  6. Change schema only by adding a migration. Never edit an applied one.
  7. Renames, type changes, and drops use expand and contract across two deploys.
  8. Keep no session state: SET only inside a transaction; no LISTEN/NOTIFY or temp tables.
  9. Handle constraint violations by err.code, not by message text.

Migrations

Postgres uses the same migration files and commands as D1. Create the next file and write Postgres SQL below its comment header:

Terminal
pnpm exec goribu migrate:new create posts
migrations/0001_create-posts.sql
-- create-posts
-- Migrations are forward-only; undo a change with a new migration.

CREATE TABLE posts (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  tags TEXT[] NOT NULL DEFAULT '{}',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Apply pending migrations to your local database and see what has run:

Terminal
pnpm exec goribu migrate
pnpm exec goribu migrate:status
  • Production: Deploy applies pending migrations before uploading the new Worker. Add --remote to run either command against the database in .env.production.
  • Long migrations are fine: Migrations connect straight to your database, not through Hyperdrive, so its 60-second query limit doesn't apply to a big index or backfill.
  • All or nothing per file: If a statement fails, the whole file is rolled back and the error names the file and the Postgres error code.
  • No double runs: If two deploys start together, the second waits for the first and then finds nothing left to apply.
  • Live apps: Old code keeps running while migrations apply, so add new columns first and remove old ones in a later release. See Deploys.
  • Forward-only: Undo a change with a new migration, such as DROP TABLE or ALTER TABLE … DROP COLUMN, as with D1. To start your local database over, recreate it with dropdb and createdb, then run migrate again.
  • No seed command: migrate:seed doesn't work with Postgres yet. Load test data with psql "$POSTGRES_URL" -f migrations/seed.sql.
  • Inspect data: Use psql "$POSTGRES_URL" where D1 apps use goribu d1.

Statements outside a transaction

A few statements, such as CREATE INDEX CONCURRENTLY, which builds an index without locking writes, can't run inside a transaction. Put one alone in its own migration and make the directive its first line:

migrations/0002_add-posts-title-index.sql
-- goribu:no-transaction
-- add-posts-title-index

CREATE INDEX CONCURRENTLY IF NOT EXISTS posts_title_idx ON posts (title);
  • One statement per file: A second statement in the same file fails.
  • Safe to repeat: If the run is interrupted, the statement may run again, so write it with IF NOT EXISTS. A failed CREATE INDEX CONCURRENTLY can leave a broken index behind; remove it with DROP INDEX CONCURRENTLY IF EXISTS before retrying.

Where connection strings live

Goribu reads two keys from your env files: POSTGRES_URL, and optionally POSTGRES_MIGRATE_URL.

CommandReads
pnpm dev, migrate, migrate:status.env, with .env.local overriding it, or .dev.vars if it exists.
pnpm run deploy, and migrate or migrate:status with --remote.env.production only, never your local files.
  • Separate migration user: If POSTGRES_MIGRATE_URL is set, migrations use it instead. This lets your app connect as a user that can read and write data but not change tables.
  • Not uploaded as a secret: Deploy strips both keys before uploading the rest of .env.production. The connection string is stored only in Hyperdrive, which never shows the password again.
  • Not in your code: req.env.POSTGRES_URL exists under pnpm dev, but not in production, so never read it in application code.
  • Written literally: ${OTHER} isn't expanded. Quote a value containing #, which otherwise starts a comment.

Deploy

For a Postgres app, pnpm run deploy also:

  1. Checks that POSTGRES_URL is in .env.production, before changing anything.
  2. Creates or updates two Hyperdrive configs, <name>-postgres with caching off and <name>-postgres-cached with caching on. Cloudflare tests the connection first, so a wrong password stops the deploy and leaves the live app untouched.
  3. Applies pending migrations directly to your database.
  4. Uploads the Worker connected to both configs.

If anything fails before the upload, the previous version keeps serving, and you can safely run deploy again.

  • Use the direct connection string: Providers often offer a "pooled" string too, usually with -pooler in the host or port 6543 or 6432. Hyperdrive already pools connections, and a second pooler can break queries and migrations, so deploy warns about it.
  • TLS: Add ?sslmode=require to encrypt the connection. verify-ca and verify-full stop the deploy, as certificate checks need the manual setup described under private database.
  • Deploying from CI: Browser login already covers Hyperdrive. If you deploy with an API token instead, add Hyperdrive: Edit to its permissions, since deploy creates and updates the Hyperdrive configs.
  • pnpm check: Doesn't inspect Postgres (yet). It only describes what deploy will do.

Providers

Every provider works the same way: copy its direct connection string, not the pooled one, into .env.production, then run pnpm run deploy.

Neon

  1. In the Neon console, open Connect, turn off Connection pooling and copy the connection string. Its host must not contain -pooler.
  2. Put it in .env.production as POSTGRES_URL. Keep sslmode=require.
  3. Optionally, put a Neon development branch's string in .env for local work.

The same string works for migrations, so you don't need POSTGRES_MIGRATE_URL.

PlanetScale Postgres

This setup uses two database users, so a bug or a leaked password in your app can't drop or change your tables:

  • The default user that PlanetScale gives you can do everything. But for better security, only migrations will use it.
  • A new app user can read and write rows, but not change tables. Your Worker connects as this one.
  1. Create the app user. Connect as the default user, for example from PlanetScale's console, and run:
PlanetScale console
-- A user your app can log in as.
CREATE ROLE app_user LOGIN PASSWORD 'choose-a-strong-password';

-- Read and write rows in every table, but not change the tables themselves.
GRANT pg_read_all_data, pg_write_all_data TO app_user;
  1. Copy the direct connection string for your database, on port 5432, not 6432, which goes through PlanetScale's pooler.

  2. Put two versions of it in .env.production, one per user:

.env.production
# The Worker connects as the app user.
POSTGRES_URL=postgres://app_user:<app password>@<host>:5432/<database>?sslmode=require

# Migrations connect as the default user, so they can create and change tables.
POSTGRES_MIGRATE_URL=postgres://<default user>:<default password>@<host>:5432/<database>?sslmode=require
  1. Run pnpm run deploy. Migrations run as the default user, then the Worker goes live connected as app_user.

Public database on a VPS

  1. Turn on TLS in Postgres and allow connections from the internet, or at least from Cloudflare's IP ranges, in pg_hba.conf and your firewall.
  2. Put postgres://<user>:<password>@<host>:5432/<database>?sslmode=require in .env.production. sslmode=require encrypts the connection without checking the certificate, so the self-signed certificate that Postgres packages on Ubuntu and Debian usually set up works as is. Keep it: without it, your password and data could cross the internet unencrypted.

Private database on a VPS

Hyperdrive can reach a database with no public address through a Cloudflare Tunnel or Workers VPC. Goribu doesn't automate that network setup:

  1. Follow Cloudflare's guide to connect Hyperdrive to a private database and create two Hyperdrive configs: one with caching off and one with caching on.
  2. Add their IDs to the config:
Configuration field
database: {
  type: "postgres",
  hyperdriveId: "<id of the caching-off config>",
  hyperdriveCachedId: "<id of the caching-on config>",
},
  1. Still put POSTGRES_URL (or POSTGRES_MIGRATE_URL) in .env.production, as seen from the machine you deploy from, because migrations connect to the database directly.
  2. Deploy from a machine that can reach the database.

With your own IDs, deploy checks that each config has the right caching setting and stops otherwise. It never changes where they point.

Limits

Check Cloudflare's Hyperdrive pricing and limits for current numbers. At the time of writing:

  • Queries: The Workers Free plan allows 100,000 Hyperdrive queries per day, cached or not. Paid plans have no daily cap.
  • Configs: Each Postgres app uses two Hyperdrive configs. A Free account can have 10 and a Paid account 25.
  • Database connections: Each config opens up to about 20 connections to your database on Free and about 100 on Paid. Make sure your database allows enough for both, plus migrations.
  • Query time: A query through Hyperdrive can run for at most 60 seconds. Migrations aren't limited.

Not supported yet

  • Seed command: migrate:seed.
  • A query command: A Postgres version of goribu d1. Use psql "$POSTGRES_URL".
  • Postgres in pnpm check.
  • D1 and Postgres together: An app declares one database.

Goribu also leaves these to you or your libraries: ORMs and query builders, typed rows, read replicas, creating the database itself, and JavaScript migrations.