Skip to main content
Test mode is a supported way to run Pgrust as a disposable test server — the database your test suite spins up, hammers, and throws away. It gives you three things:
  1. Durability off. Crash recovery is abandoned by contract: if the machine dies, you delete the data directory and start over. In exchange, every commit returns without waiting on disk.
  2. Memory-first storage with graceful spill. Data lives in RAM as much as possible and spills to disk only under memory pressure — without the hard capacity cliff of a ramdisk.
  3. Ephemeral databases, managed by the server. Seed a template once, and every test worker gets a private clone just by connecting to a name like tdb_myapp_tpl__w3. The database is minted on first connection and reaped automatically once it goes idle — no CREATE DATABASE, no teardown code, no cleanup sweeps in your harness.
All of it is enabled by a single launch flag:
Test mode is a launch profile, not a fork of the engine. Your tests exercise the same binary and the same query, WAL, and checkpoint code paths as production — the whole point of testing against real Pgrust instead of a mock. The profile changes configuration: the knobs PostgreSQL itself documents as non-durable settings, plus Pgrust’s ephemeral-database lifecycle worker.
Availability: the --profile test flag, ephemeral databases, and copy-on-write template clones (file_copy_method = clone) require v0.3-beta or newer. On v0.2, you can still apply the non-durable settings from the profile’s expansion manually as -c flags (omit file_copy_method and the pgrust.* settings) and clone databases yourself with CREATE DATABASE ... TEMPLATE.

Quick start

Three moves: start with the test profile, seal a template, connect by name.
1

1. Spin up the database with --profile test

--profile test is the only server configuration you need: it applies the non-durable preset, arms the janitor (database prefix tdb_), and enables minting for every role. No config files to edit.
Linux only: for millisecond clones of large templates, put the data directory on a reflink-capable filesystem (XFS or btrfs) — see the filesystem recipe. macOS needs nothing; APFS clones natively.
2

2. Create your template, then seal it

Build the template like any ordinary database — createdb, run your migrations, load seed data — then make it clonable with one call:
Sealing freezes the template and marks it clone-ready. Re-run this step only when your migrations change.
3

3. Connect with a tdb_ name to get a clone

Any connection to tdb_<template>__<token> materializes a fresh clone of that template on first use — invent a token (worker id, test id, uuid), connect, done. Every connection using the same name shares the same clone. When the last connection closes and the grace period passes, the server drops it for you: no teardown code.

The --profile test launch flag

pgrust --profile test is macro-expansion into -c arguments at that argv position — nothing more. It expands to:
Precedence follows from the mental model: a later explicit -c overrides a profile value (pgrust --profile test -c fsync=on runs with fsync=on), and profile values override postgresql.conf, exactly as -c does. The same settings ship as a config file in the Pgrust repository if you prefer include-managed configuration — the flag and the file are kept in sync by a test. The profile enables minting for every role (mint_roles = *): a server launched with --profile test is disposable by declaration, so gating who may mint would be friction without protection. On servers that arm the janitor without the profile — durable dev or preview boxes — the GUC’s own default ('', minting off) keeps the fail-closed posture; see the configuration reference. Unknown profile names fail at startup with the list of known profiles (currently just test).
With fsync = off, an OS or machine crash can corrupt the cluster. Test mode’s contract is that the data directory is disposable: your harness recreates it (or restores a cached post-initdb copy) rather than ever trusting crash recovery. Never point test mode at data you care about.

The filesystem recipe

You might reach for tmpfs here — don’t. tmpfs has no copy-on-write clone support (which would defeat fast template clones), and a ramdisk hard-caps at its allocation, so “spilling to disk” means swap thrashing. The key insight: once fsync is off, an ordinary on-disk filesystem already behaves like an in-memory filesystem with disk spill. Every write lands in the kernel page cache and returns immediately; writeback happens lazily in the background, and only memory pressure forces pages out. Reads of recently written test data are cache hits. That is exactly “in memory as much as possible, spill to disk when needed” — implemented by the kernel, with no capacity cliff. So this layer is just a filesystem choice:
Nothing to do. APFS — the default filesystem — supports copy-on-write clones natively and already satisfies both properties. Put your test data directory anywhere.

Ephemeral databases

This is the piece that removes database lifecycle code from your harness entirely. It has four parts: templates you seal, names that mint databases on connection, a janitor that reaps them, and an optional warm pool.

Templates and sealing

A template is an ordinary database you build once — migrations, seeds, extensions, ALTER DATABASE ... SET settings — then seal:
Sealing is executed by the server and is exactly the manual recipe, in the order that is easy to get wrong by hand: a whole-database VACUUM (FREEZE, ANALYZE) inside the template, then ALTER DATABASE myapp_tpl WITH IS_TEMPLATE true ALLOW_CONNECTIONS false. Both flags are stock Postgres (template0 ships exactly this way): IS_TEMPLATE makes the database clonable and drop-protected, and ALLOW_CONNECTIONS false makes it immutable, which lets the server skip redundant flush work on later clones. The freeze half prevents anti-wraparound autovacuum from ever touching the template, and the ANALYZE bakes planner statistics into every clone — important because test mode runs with autovacuum = off, so clones never get analyzed on their own. The function requires owner-or-superuser and returns ordinary SQL errors; the manual statements remain valid if you prefer them. Minted databases inherit the template’s contents and its ALTER DATABASE ... SET / ALTER ROLE ... IN DATABASE ... SET settings (stock CREATE DATABASE ... TEMPLATE drops those; the mint path copies them).
Avoid __ in template names: the first __ in a database name splits template from token, so a template whose own name contains __ can never be selected by the mint grammar.

Mint-on-connect

Any connection to a nonexistent database whose name matches the ephemeral grammar mints it from a template, transparently, during connection startup:
This is the only mint form — the template is always in the name. The rules:
  • The token is your harness’s choice: a worker id, a uuid, a run id. The first __ splits template from token (tdb_a__b__c selects template a, token b__c). The whole database name must fit the standard 63-byte identifier limit.
  • Every connection using the same name shares the same database — connection pools work, commits are real, visibility across connections is real.
  • The first connection with a new name pays the mint (about a millisecond from the warm pool; tens of milliseconds for a cold clone of a typical test template). Everyone else pays nothing.
  • Minting is post-authentication and gated by pgrust.ephemeral_db_mint_roles. Under --profile test every role may mint. An unauthorized role connecting to a missing database gets the stock “does not exist” error, indistinguishable from a typo; an authorized role connecting to a prefix-matching name without the __ separator gets a clear FATAL naming the required form.
  • tdb_spare_<digits> is the warm pool’s reserved namespace and never mints.

The janitor

A background worker owns every database under the configured prefix:
  • Reap on idle. A minted database with zero connections continuously for the grace period (15s under the profile) is dropped, batched and checkpoint-amortized. Disconnecting is teardown.
  • Startup sweep. Ephemeral databases do not survive a server restart: on the first janitor cycle after startup, every prefix-matching non-template database is dropped, unconditionally — leftovers from crashed runs and pre-existing matches alike. The log names every drop.
  • Pinning. SELECT pgrust_pin_database('tdb_myapp_tpl__w3') exempts a database from reaping — useful to inspect a failure or hold a long provisioning window; pgrust_unpin_database releases it. Pins live in server memory and do not survive a restart.
  • Templates are never touched. Anything with IS_TEMPLATE true is invisible to reaping and the sweep.
The prefix is a namespace-ownership contract. Setting pgrust.ephemeral_db_prefix hands the janitor the whole matching namespace: any non-template database named under it is ephemeral by definition and will be dropped — including a pre-existing database that happens to match, at the next server start. Never arm a prefix that overlaps databases you want to keep; ALTER DATABASE ... RENAME out of the prefix is the escape hatch.

The warm pool

For high-churn suites — a fresh database per test — set pgrust.ephemeral_db_pool_size = N (up to 4096). The janitor then keeps up to N pre-minted spares warm per template you mint from and satisfies mint requests by a catalog-only rename instead of a clone, with no checkpoints on the connect path — handout latency is around a millisecond. The pool is usage-keyed: the first mint of a template registers it for pooling (that first mint is served cold), and the janitor keeps its spares topped up in the background thereafter. Several templates pool concurrently, sharing a global 4096-spare ceiling round-robin when it binds. Spares are unconnectable until handed out, are replenished in background ticks, and drain per template if that template is dropped, rebuilt, or unsealed.

Automatic mint machinery

You don’t configure any of this, but it explains the server log lines:
  • Simultaneous cold mints are batched — many requests share one transaction and one checkpoint pair, and directory copies fan out across worker threads.
  • Templates with a relation count at or above pgrust.ephemeral_db_wal_log_threshold (the profile sets 50) clone via CREATE DATABASE ... STRATEGY wal_log, which requests no checkpoints at all; smaller templates keep the file-copy path, where the copy-on-write clone is what makes them fast.
  • After every successful mint, a one-shot prewarm worker connects to the new database and exits, off the connecting client’s critical path, so catalog pages and the relcache init file are already warm when the first real session arrives. Warm-pool spares get this at replenish time. pgrust.ephemeral_db_prewarm = off disables it.
  • The janitor periodically vacuums the shared catalogs that mint/drop churn touches (pg_database, pg_shdepend, pg_db_role_setting), keeping mint cost flat over long runs even with autovacuum = off.

Configuration reference

All ephemeral-database settings, with their out-of-the-box defaults (the values --profile test applies are noted): The SQL surface is three builtin functions, present in every database with no install step (stock PostgreSQL does not have them): The janitor is posture-independent: it runs fine on a durable, fsync = on server — dev boxes and preview environments can arm pgrust.ephemeral_db_prefix and a role allowlist without the rest of the test profile. The non-durable settings and the janitor are separate opt-ins that compose.

Harness recipes

Per-worker (the default recommendation). Each parallel test worker connects to tdb_myapp_tpl__<worker-id>_<run-id>; within a worker, keep using your framework’s transaction-rollback isolation per test. Rollback composes inside an ephemeral database — this is deliberately not a rollback replacement, and rollback is faster than any clone for the tests it can serve. Per-test. Tests that commit, use multiple connections, or are serialized by shared state get a fresh token each. Size the warm pool for your burst rate. Per-run e2e. Browser-test tiers where one app server holds one DATABASE_URL: mint one database per run (tdb_myapp_tpl__e2e_<run-id>), pin it if provisioning happens long before the first connection, and unpin at teardown — or don’t; the janitor gets it either way. Multiple baseline states. Seal several templates (myapp_bare, myapp_seeded) and pick per connection: tdb_myapp_bare__w1 vs tdb_myapp_seeded__w1. One server serves both. On any crash: recreate, don’t recover. If the server or machine crashes, delete the data directory and re-run the suite setup (or restore a cached copy of the post-initdb directory). Never rely on crash recovery of a test-mode cluster.
Do not make template tables UNLOGGED expecting extra speed — measured slower under test mode. The non-durable settings already bank the WAL savings; unlogged tables only add per-clone init-fork copying.

Troubleshooting

Errors a harness can branch on:
  • Stock database ... does not exist — the connecting role is not in pgrust.ephemeral_db_mint_roles, or the name genuinely doesn’t match the grammar (unauthorized roles get the stock error by design). Under --profile test every role may mint, so check the name’s form.
  • Immediate FATAL cannot mint ... the pgrust ephemeral-db janitor is not running — the janitor never registered or exited. Check that pgrust.ephemeral_db_prefix is set (it requires a restart to change) and look in the server log for a disabled-until-restart warning.
  • FATAL naming the required tdb_<template>__<token> form — an authorized role connected to a prefix-matching name without the __ separator, or with an empty template or token side.
  • timed out waiting for ephemeral database ... to be minted (60s) — the janitor is present but wedged or saturated. Check the server log for contained janitor errors and whether a huge mint burst outran the warm pool.
  • mint request table is full — sized to be unreachable; report it as a bug.
  • Mint refused although the template exists — is it sealed? Run SELECT pgrust_seal_template('<name>'); both flags (IS_TEMPLATE true, ALLOW_CONNECTIONS false) are required.
  • A database vanished mid-debug — the grace period expired. Pin it next time, or raise pgrust.ephemeral_db_grace.
  • A pre-existing tdb_* database vanished at server start — the startup sweep drops every prefix-matching non-template database; the log names each one. Rename databases out of the prefix (or pick a different prefix) to keep them.
  • Everything gone after restart — by design; ephemeral databases are disposable.

FAQ

Queries are executed by the same engine either way — --profile test expands to configuration, and query execution, WAL, checkpointing, and crash-handling code are identical to a production launch. What changes is the durability contract (the non-durable settings) plus an opt-in lifecycle worker that mints and drops databases under one name prefix. Isolation between minted databases is real Postgres isolation: separate tables, sequences, LISTEN/NOTIFY channels, and advisory locks, with none of the gotchas of rollback-based test isolation — commit hooks fire, deferred constraints enforce, multiple connections see each other.
Two reasons: tmpfs cannot reflink, so copy-on-write clones degrade to full copies, and a ramdisk’s fixed allocation turns “spill to disk” into swap thrashing. With fsync = off, a normal filesystem’s page cache gives you the in-memory behavior for free, with graceful writeback under pressure.
No. The non-durable settings and the janitor are independent. You can run --profile test and still issue your own CREATE DATABASE t_42 TEMPLATE tpl / DROP DATABASE t_42 WITH (FORCE) — file_copy_method = clone makes those clones copy-on-write too. Conversely, a durable server can arm just the janitor for dev or preview environments. The mint machinery exists so your harness doesn’t have to manage any of that.
Yes. The loopback-image recipe needs only mount privileges (a privileged container or a host-prepared mount). If you can’t get a reflink filesystem in CI, everything still works on ext4/overlayfs — clones become fast in-kernel copies, so you lose the O(1)-in-template-size property but keep the durability behavior, the memory-first behavior, and the whole ephemeral-database lifecycle.
Yes — add --profile test to the container command (see Run Pgrust with Docker for how server arguments are passed), on an image version that includes it (v0.3-beta or newer). For the copy-on-write clone path, the data volume must live on a reflink-capable filesystem on the host (APFS/XFS/btrfs).
A database is only reaped after it has had zero connections continuously for the whole grace period, so an active pool keeps it alive. If your pool holds idle connections open, the database stays; teardown happens when the pool closes. Every connection using the same name shares the same database, so pooled connections compose naturally with per-worker minting.