Reference
Postgres
Goribu's other database option is Postgres, built on Postgres.js and using Cloudflare's Hyperdrive. req.postgres is Postgres.js sql, not a wrapper around it. So everything you know from the Postgres.js docs applies directly. Queries are tagged templates: anything you interpolate is auto-parameterized, and rows come back as plain objects.
export async function GET(req, res) {
const [poll] = await req.postgres`SELECT id, title FROM polls WHERE id = ${req.params.id}`;
if (!poll) return res.notFound();
return res.render(PollPage, { poll });
}Declare the database in goribu.config.js, and only there. Do not add a hyperdrive entry to wrangler.jsonc; Goribu synthesizes the POSTGRES binding for dev and deploy, and declaring it twice is an error.
export default {
database: {
type: "postgres",
url: process.env.POSTGRES_URL,
hyperdriveId: process.env.HYPERDRIVE_ID,
},
};The two values do two jobs. POSTGRES_URL is a direct connection used to run migrations before the Worker ships (DDL doesn't belong in a pool). HYPERDRIVE_ID is the runtime binding the Worker queries through Hyperdrive's pool. In dev, just set POSTGRES_URL in .env and Wrangler talks to your local Postgres directly. In production, create the Hyperdrive config yourself in the Cloudflare dashboard as Goribu does not auto-provision it and put its ID plus the production POSTGRES_URL in .env.production. The migration and runtime users can differ: the migrator needs DDL permission, the runtime user usually does not.
Migrations
You cannot query a table that does not exist, so schema comes first. Migrations live in migrations/ at the project root. The CLI reads database.type and scaffolds a Postgres-flavored file:
npx goribu migration:create create_polls_tableup(sql) receives the same tagged template as req.postgres, so there is no second API to learn:
export async function up(sql) {
await sql`
CREATE TABLE polls (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`;
}Migration files are JavaScript today, even in TypeScript projects. They still work normally with a TypeScript app because Goribu's CLI imports them directly when you run migrate or deploy; Postgres only sees the SQL you execute. Keep the CLI-generated .js extension for now. Native .ts migration files are planned, but need CLI loader support before they can be generated safely.
Apply pending migrations locally with npx goribu migrate (it reads database.url, resolved from POSTGRES_URL, and is idempotent). Goribu tracks applied migrations in a _migrations table it creates on first run. You never run production migrations by hand: npx goribu deploy runs them, connecting directly to Postgres, not through Hyperdrive, before the new Worker goes live, reading POSTGRES_URL from .env.production. For a one-off against a different database, override with --postgres-url=…. If type is "postgres" but no URL is available, the deploy errors before doing anything.
npx goribu migrate:rollback reverses the last migration by calling its down(sql). Generated migrations leave down commented out on purpose as an empty down would succeed silently, delete the migration's row, and let the next migrate try to re-apply against a schema that already exists. A migration with no active down throws a clear message instead. Treat rollback as a local tool; in production a DROP COLUMN rollback can discard user data, so the recovery path is almost always a new forward migration.
Writing queries
Tagged templates auto-parameterize every interpolation, so there is no params array to maintain:
// Many rows
const polls = await req.postgres`SELECT id, title FROM polls ORDER BY created_at DESC`;
// Insert returning the new id
const [{ id }] = await req.postgres`INSERT INTO polls (title) VALUES (${title}) RETURNING id`;
// Transaction — the inner sql is transaction-scoped
await req.postgres.begin(async (sql) => {
const [{ id }] = await sql`INSERT INTO polls (title) VALUES (${title}) RETURNING id`;
await sql`INSERT INTO options (poll_id, text) VALUES (${id}, 'Yes')`;
await sql`INSERT INTO options (poll_id, text) VALUES (${id}, 'No')`;
});Interpolation is safe only through the template. sql.unsafe and the row-streaming API, helpers like sql.array, type extensions and JSON helpers are all in the Postgres.js docs and apply unchanged to req.postgres.
Going deeper
Type handling
req.postgres is configured with fetch_types: false to keep cold starts fast. Built-in scalars like int, text, timestamp, bool, json, uuid parse correctly without it. Array columns are the practical exception: a text[] of ['a','b'] comes back as the raw string '{a,b}' rather than a parsed array (enum and custom-domain columns are technically affected too, but rarely a problem). If a route needs parsed arrays, instantiate Postgres.js yourself from req.env.POSTGRES.connectionString with fetch_types: true and manage its lifecycle with sql.end().
Connections and lifecycle
Goribu creates the client lazily on first use, disposes it after the response and lets Hyperdrive pool connections across requests so most queries skip the TCP and TLS handshake. Never call .end() from a handler as the lifecycle is automatic. Each request gets up to 5 concurrent connections (max: 5); Workers caps total concurrent external connections, and 5 leaves headroom for fetch() calls in the same handler.
Zero-downtime schema changes
Production migrations run before the new Worker ships, so for a few seconds the previously-deployed Worker serves traffic against the new schema. A naïve column rename breaks it:
// The old Worker still reads `title`, which no longer exists → 500s.
export async function up(sql) {
await sql`ALTER TABLE polls RENAME COLUMN title TO question`;
}Expand and contract across two deploys instead. First add the new column and copy the data, leaving the old; ship a Worker that writes both and reads the new one with a fallback:
export async function up(sql) {
await sql`ALTER TABLE polls ADD COLUMN question TEXT`;
await sql`UPDATE polls SET question = title`;
}Then, once nothing reads title, drop it in a second migration. Renames, incompatible type changes, dropping a read column and adding a NOT NULL column without a default all need this. Additive changes like a nullable or defaulted column, a new table, a new index, are safe one-shots (use CREATE INDEX CONCURRENTLY on a large table to avoid locking writes). Solo projects can accept the short window; apps with users should split risky changes.
In tests
There is no special test mode. A handler under test queries whatever POSTGRES binding you provide, so point it at a disposable test database (a local Postgres, or one per CI run) and migrate it the same way you migrate dev. Using req.postgres with no binding does not silently no-op. Every access throws the missing-binding error below, so a misconfigured test fails loudly instead of hiding bugs.
Missing binding
Using req.postgres with no Postgres database declared throws immediately:
[goribu] req.postgres was used but no Hyperdrive binding was found. Expected
env.POSTGRES. Add `database: { type: 'postgres', url: process.env.POSTGRES_URL,
hyperdriveId: process.env.HYPERDRIVE_ID }`. Goribu will synthesize the
env.POSTGRES Hyperdrive binding for dev and deploy.Add the database entry to goribu.config.js and restart the dev server.