Top 9 Temporary PostgreSQL Database Platforms (2026)
There are five distinct reasons you need a Postgres you plan to throw away, and they want five different things. A test run wants it in less time than it takes to notice. A pull-request preview wants it to look like production. A migration rehearsal wants it to be production, minus the consequences. A data-science scratchpad wants it to survive lunch and then vanish. And an agent that was asked to "just try the query" wants a database where the worst outcome of a creatively wrong answer is a wasted minute.
Most roundups in this category sort by logo. That is the wrong axis, because a temporary Postgres is not a product — it is an output, and there are exactly four factories that produce it. Once you know which factory you are standing in front of, every property you care about follows: how fast you get a connection string, whether there is real data behind it, what the isolation boundary actually is, who pays when you walk away, and whose decision it is that `pgvector` exists. Pick the factory first. The vendor shortlist falls out of it.
The four factories
Every temporary Postgres in existence comes out of one of these. The names are mine; the trade-offs are structural, not vendor-specific.
(a) A process on the machine doing the work. `pg_tmp`, Testcontainers, an embedded postmaster inside your test JVM, `initdb` into a tmpfs. Fastest to reach, nothing to sign up for, and the least representative thing on this page: it starts empty, it is usually tuned to be unsafe on purpose, and its idea of durability is a polite fiction. This is the right factory far more often than platform vendors would like you to conclude.
(b) A schema or a database inside one shared, long-lived server. `CREATE SCHEMA pr_4821`, or `CREATE DATABASE pr_4821 TEMPLATE seed_db`. Creation is a directory copy rather than a provision, so it is the cheapest per unit by a wide margin. The catch is tenancy: every tenant shares one buffer pool, one WAL writer, one fixed pool of autovacuum workers, one `max_connections`, and one blast radius. Cluster-global operations — installing an extension, `ALTER SYSTEM`, testing crash recovery — are not yours to perform.
(c) A copy-on-write branch of a real database. Storage-level branching, Aurora's fast cloning, a filesystem that can reflink. You get production-shaped data for roughly the cost of pointers, which makes this the only factory that can honestly answer "does this migration work on data that looks like ours". The data is real; the server is still shared infrastructure, so what you may install and how many connections you may open remain somebody else's policy.
(d) A whole dedicated database server, created for this use and destroyed after it. Strongest isolation available — its own configuration, its own superuser, its own crash. The only interesting questions are how fast it can be created, how cheap it is to hold, and whether anything reliably destroys it. Historically the answer to all three was "badly", which is why factory (b) exists at all.
Notice that (a) and (b) cannot give you real data unless you ship it there yourself, and (c) cannot give you a cluster you control. If your requirement spans both — real data and root — you are in factory (d), and the rest of the conversation is latency and cost.
Six things to grade, in this order
- Time to a usable connection string. Not time to an API 200 — time until a client can run `select 1` and get an answer. The gap between those two is where your flaky `sleep 5` lives.
- Real data, or an empty schema. The biggest divider on the list: an empty database can prove your SQL parses, and cannot prove your migration finishes.
- The isolation boundary. Four real units: a process as your own user, a role inside a shared cluster, a container sharing the host kernel, or its own kernel. Each has a specific list of things it structurally cannot contain.
- What happens to the bill and the disk when you walk away. Assume you will, because the paths that skip your cleanup code — cancellation, spot reclamation, OOM kill — are exactly the common ones.
- Extension support. `pgvector` and PostGIS are what people ask about; the interesting question is whether installing an untrusted C extension is permitted, and what it endangers if it is.
- Who cleans it up. The only axis here about ownership rather than engineering, and the one every team gets wrong at least once.
That last axis turns a technology choice into an operational problem. A resource is temporary when something that cannot forget owns its destruction: a job lifetime can own it, a reaper scoped to a label prefix can own it, and a `finally` block cannot, because `finally` does not run when the runner is reclaimed mid-test. Everything else here is a trade-off; this one is something you either built or did not.
The nine
Numbered for the headline, grouped by factory, ordered from least to most machinery. Three of the nine are not products you buy, which is intentional: in this category the unglamorous options win more arguments than comparison tables admit.
1. pg_tmp, or initdb into a tmpfs — factory (a)
What it is: a shell command that runs `initdb` into a RAM-backed directory, starts a postmaster on a unix socket, hands you a DSN, and reaps the cluster after an idle window. No daemon, no account, no network, no image pull. `pg_tmp` is the packaged version of a script most teams eventually write themselves, badly.
Genuinely best at: time to a connection string, with no dependencies worth the word. It is also the only entry where the database is unreachable by anything else on the machine, because with `listen_addresses` empty there is no TCP socket to misconfigure. For unit tests and most integration tests it beats every platform on this page, mine included.
Where it bites: it starts empty, so you pay your entire migration history plus your seed on every invocation — a cost that grows with the age of your project, not the size of your change. The dataset must fit in RAM, and the enforcement mechanism is the OOM killer. `fsync = off` makes it structurally unable to test the class of bug it is named after. The Postgres build is whatever your package manager shipped, so your `libc` collation can differ from production's and your `ORDER BY` can quietly disagree. Cleanup is a trap handler — you, and not on `SIGKILL`.
2. Embedded Postgres in your test process — factory (a)
What it is: a library that bundles real Postgres binaries and starts a postmaster as a child of your test process — the Zonky embedded-postgres family on the JVM, equivalents in the Node and Rust ecosystems, and at the human end, Postgres.app or a Homebrew install for a scratchpad you poke at by hand. Same factory as entry one, except the lifetime is bound to a process you already have rather than a shell script you maintain.
Genuinely best at: being a real postmaster with zero orchestration, on a laptop where Docker is a licensing conversation rather than a command. Binaries are cached after the first run, and cleanup is the one place it beats a shell wrapper: when the test process exits for any reason, its children go with it.
Where it bites: you have taken on a platform matrix as a build dependency, and the library decides which Postgres minor you get. One postmaster per test process means N postmasters when you fan out, each with its own shared buffers — which is how a parallel suite discovers the runner's memory ceiling. The empty-start problem is identical to entry one. And the human variant has its own trap: a scratch database installed as a login item is not temporary, it is a pet with a plausible origin story.
3. Testcontainers — factory (a), with a container boundary
What it is: a library that starts `postgres:16` in a container from inside your test code, waits for it properly, and tears it down afterwards. It is the default answer in this category and earns that with three pieces of real engineering: a random host port so two jobs on one runner cannot collide on 5432, a wait strategy instead of a sleep, and a reaper sidecar that holds an open socket to your test process and destroys the containers when that socket drops. A quit debugger, a `SIGKILL`, a reclaimed runner — all still cleaned up, which is exactly the part hand-rolled scripts skip.
Genuinely best at: being the version of factory (a) a team can maintain. The container pins the image, so everybody's Postgres minor and `libc` match — removing the collation divergence entry one quietly invites.
Where it bites: it needs a Docker-compatible daemon, and in CI that means a privileged runner or a nested-container arrangement you will come to know personally. Container start is still a cluster start, and it still starts empty. The boundary is a container, which is a polite suggestion to a shared kernel — adequate for your own test code, and not what I would put between a model-authored `DO` block and anything I cared about.
4. A Docker Compose service you keep running — factory (b)
What it is: `postgres:16` in your repo's compose file with a named volume, started once and left alive. Formally this is factory (b) at single-developer scale: one long-lived server, with every branch you check out as a tenant. It is on this list because it is overwhelmingly the most common way a developer actually gets a Postgres, and because the way it fails is instructive.
Genuinely best at: ceremony, of which it has none. It survives reboots, it matches the onboarding doc, and the connection string has not changed since 2022. For a scratchpad that needs to survive lunch this is a reasonable answer, and you should not feel bad about it.
Where it bites: a named volume is forever, and the database inside it accumulates the union of every migration you have run on every branch, including the two you abandoned. Switch to an older branch and your schema is from the future — which presents as a mystifying test error rather than as the obvious consequence of shared state. The honest reset is `docker compose down -v`, and nobody runs it, because it also deletes the seed that took twenty minutes to build. Who cleans it up: nobody, and that is this entry's defining property.
5. A shared dev server with a schema or database per pull request — factory (b)
What it is: one long-lived Postgres that your automation carves up — `CREATE DATABASE pr_4821 TEMPLATE seed_db` per pull request, or `CREATE SCHEMA pr_4821` plus a `search_path`. Creation is a directory copy of the template, not a provision, which makes this the cheapest way in existence to give forty concurrent pull requests a database each. One server to back up, one to monitor, one to pay for.
Genuinely best at: cost and creation latency simultaneously, which nothing else here manages. On Postgres 15+, issue it with `STRATEGY = FILE_COPY` and you also skip pushing the whole template copy through the WAL — which matters for a seed you intend to destroy anyway.
Where it bites: tenancy, specifically. Tenants share `max_connections`, so the forty-first ORM pool is the one that fails. They share `shared_buffers`, so one pull request's bulk load evicts everybody's working set. They share a fixed pool of autovacuum workers, so a test that churns a million rows makes the server's bloat somebody else's afternoon. `CREATE EXTENSION` is cluster-global and superuser-gated, so extension changes are a team event rather than a commit, and roles, `ALTER SYSTEM` and crash recovery cannot be tested at all. The blast radius of a mistake is the whole dev team — a `DROP SCHEMA public CASCADE` in a migration that assumed it owned `public` is the canonical way to learn this. Cleanup is a cron that drops by name prefix, which someone has to write and nobody dares make aggressive.
6. Neon branching — factory (c)
What it is: the reference implementation of storage-level copy-on-write branching, and the architecture most of this category is now imitating. Their docs describe compute separated from a storage layer that retains history, so a branch is created as a pointer rather than a copy, including branches taken from an earlier point in time. Compute is described as suspending when idle. Verify all of that against their current documentation; this is the part of the market that changes most.
Genuinely best at: what factories (a) and (b) cannot do at all — handing a pull request production-shaped data, cheaply enough to do per pull request rather than per quarter. If your migration review is currently someone reading a diff and saying it looks fine, this is the factory that replaces that with evidence. If you are already on Neon and need a per-PR database, you can stop reading roundups.
Where it bites: branches are cheap individually and unbounded in count, so the bill becomes a function of how many your automation forgot to delete — sprawl is this whole factory's characteristic failure mode, not one vendor's flaw. Connection-count behaviour routes through their pooler and is worth understanding before your matrix goes wide. And a branch lives in the vendor's tenancy, so the extension list is a decision they make and you consume.
7. Supabase branching — factory (c), with the environment attached
What it is: branching as a platform workflow rather than a database primitive. Per their documentation, a branch is tied to a git branch or pull request, the schema is applied from the migration files in your repo, and the rest of the platform — auth, storage, the functions — branches alongside the database. The unit is an environment that happens to contain a Postgres.
Genuinely best at: full-stack previews where a database on its own would be useless. If what you need to look at is a signed-in user clicking through a feature, a bare database is a third of the problem, and this is the only entry here that addresses the other two thirds.
Where it bites: you are adopting a platform to rent a temporary database, which is a fine trade if you were adopting it anyway and a strange one otherwise. Read their docs carefully on what a branch is seeded with, because schema-from-your-migrations and a copy of production data are different products, and only one lets you rehearse a migration honestly. Both are legitimate; they answer different questions.
8. Amazon RDS snapshot restore — factory (d), at enterprise weight
What it is: restore a snapshot, or restore to a point in time, into a brand-new instance. AWS documents this as producing a new instance rather than mutating the source, which is the correct shape for a rehearsal. Aurora additionally documents fast database cloning, which is copy-on-write and therefore really factory (c) wearing an enterprise badge. If your production data already lives in RDS, this is the entry you should evaluate first, and I say that as someone selling an alternative.
Genuinely best at: fidelity, with no argument available. Same engine build, same parameter group, same extension set, same data, same collation. When you need to reproduce a production incident — not approximate it — nothing else on this page is in the conversation. It is also the only entry whose access control your security team has already reviewed.
Where it bites: a restore is a provision, with provisioning's latency. AWS documents that a restored volume loads blocks lazily from the backup, so the first pass over cold data is slower than steady state — and a migration rehearsal is precisely a first pass over cold data, which makes any timing you take there an upper bound rather than a prediction. Cost is a full-size instance per rehearsal, billed until something deletes it, with deletion protection and final-snapshot settings designed to keep things alive. Cleanup is whatever Terraform or state machine you wrote, and half-finished restores are the most expensive leaked objects in this post.
9. PandaStack managed Postgres — factory (d), a microVM per database
This one is mine, so here is the disclosure and then the arithmetic. A managed database on PandaStack is a dedicated Firecracker microVM — Ubuntu 24.04, guest kernel 5.10 — running PostgreSQL 16 with a durable volume attached. Not a schema, not a role, not a tenant inside a storage engine. The native endpoint is TLS-only and SNI-routed at `<id>.db.pandastack.ai:5432`, there is an HTTP query broker for callers that cannot hold a socket open, and RAM comes in tiers: `1g`, `4g`, `16g`.
Now the number that matters, measured on our own fleet: a create takes 30 to 90 seconds. That is slower to first connection than every other entry here, and dramatically slower than a local `initdb`. There is no way to present that as a feature, so instead, exactly what the wait buys. Your own kernel: your own page cache, your own autovacuum workers, your own `max_connections`, and superuser — so `CREATE EXTENSION` is your decision, `ALTER SYSTEM` works, crash-recovery tests are runnable, and a backend that segfaults takes down a postmaster nobody else was using. A durable volume rather than a container layer. An endpoint reachable over TLS from anywhere without a tunnel. And point-in-time clone: a clone lands in a brand-new database id, reconstructed from the source's own archive, source never touched — the move that turns "I think this migration is safe" into a rehearsal with a timestamp on it.
Where it bites, beyond the create time. The RAM tier is fixed at create, because a snapshot-restored Firecracker VM cannot change its guest memory — guest sizing is baked into the snapshot, so a per-request memory figure is overridden to match it. The supported resize is therefore a clone into a different tier: a new database from the archive, not an `ALTER` on the running one. One database means one VM, so the capacity unit is memory and the rate card is flat — $0.054 per vCPU-hour and $0.0162 per GiB-hour, all classes, for as long as the database exists. Nothing expires it for you: a managed database is created persistent deliberately, because an idle reaper removing one would be destroying a durable volume. Your `DELETE` is the cost control. And if you want a database per test case, this is the wrong factory; entry one is the right one.
The nine, side by side
| Option | Factory | Time to a connection string | Real data? | Isolation boundary | Who removes it |
|---|---|---|---|---|---|
| 1. pg_tmp / initdb in tmpfs | (a) process | Fastest fresh cluster here | No — starts empty | A process, as your own user | A trap handler, and not on SIGKILL |
| 2. Embedded in-process Postgres | (a) process | A cluster start, no daemon | No — starts empty | A child process of your tests | Process exit, reliably |
| 3. Testcontainers | (a) process + container | A cluster start plus image pull | No — starts empty | Container, shared host kernel | The reaper sidecar — the best story here |
| 4. Docker Compose service | (b) shared server | Already running | Whatever has accumulated in it | Container, and one shared server | Nobody. Named volumes are forever |
| 5. Shared dev server, schema/DB per PR | (b) shared server | A directory copy of the template | Yes — whatever the template holds | A role. Not a security boundary | A cron that drops by name prefix |
| 6. Neon branching | (c) CoW branch | Pointer-shaped, per their docs | Yes — the reference implementation | Vendor tenancy + shared storage | Your automation, or the bill grows |
| 7. Supabase branching | (c) CoW branch | Environment-shaped, per their docs | Check: migrations, or real data | Platform tenancy | The PR lifecycle, per their docs |
| 8. RDS snapshot / PITR restore | (d) dedicated server | A provision, plus lazy block loads | Yes — identical to production | Its own instance, in your account | Your Terraform. Restores leak expensively |
| 9. PandaStack managed Postgres | (d) dedicated server | 30-90s, measured | Yes, via point-in-time clone | Its own kernel | Your DELETE. Nothing expires it |
Factory (a), twice, in one script
Before you shop, run this: the two local shapes side by side — the `pg_tmp` approach, and the Testcontainers approach written by hand so you can see which parts the library does for you. If it is good enough for your suite, and for unit tests and most integration tests it is, you have saved yourself a procurement conversation.
#!/usr/bin/env bash
# Factory (a) twice: a Postgres that exists for the length of one command.
# Part 1 is the pg_tmp shape. Part 2 is the Testcontainers shape, by hand.
set -euo pipefail
PGDATA=$(mktemp -d /dev/shm/pgtmp.XXXXXX) # /dev/shm is a tmpfs on most Linux
SOCK=$(mktemp -d)
CID=""
# ONE cleanup function, ONE trap. A second `trap ... EXIT` silently replaces
# the first, which is the most common bug in hand-rolled versions of this.
cleanup() {
pg_ctl -D "$PGDATA" -m immediate stop >/dev/null 2>&1 || true
rm -rf "$PGDATA" "$SOCK"
[ -n "$CID" ] && docker rm -f "$CID" >/dev/null 2>&1 || true
}
trap cleanup EXIT
# ---- 1. pg_tmp shape: initdb into RAM, unix socket, no account ------------
initdb -D "$PGDATA" -U postgres --auth=trust >/dev/null
# Durability is a liability in a cluster nothing is allowed to outlive.
cat >>"$PGDATA/postgresql.conf" <<'CONF'
fsync = off
full_page_writes = off
synchronous_commit = off
listen_addresses = '' # no TCP socket means no TCP to misconfigure
max_connections = 200 # default 100 / 8 workers with ORM pools = flakes
CONF
pg_ctl -D "$PGDATA" -o "-k $SOCK" -w start >/dev/null
psql -q -h "$SOCK" -U postgres -c 'show server_version'
echo "tmpfs DSN: postgresql:///postgres?host=$SOCK"
# pg_tmp wraps roughly the above and adds the part this script does badly: a
# reaper that stops the cluster after an idle window, so a run killed with
# SIGKILL does not park a postmaster on your tmpfs until the next reboot.
# ---- 2. Testcontainers shape, manually ------------------------------------
CID=$(docker run -d -e POSTGRES_PASSWORD=postgres -P postgres:16-alpine)
PORT=$(docker port "$CID" 5432/tcp | head -1 | cut -d: -f2)
# A wait strategy, not a sleep. pg_isready answers the only question that
# matters: is the postmaster accepting connections yet.
until docker exec "$CID" pg_isready -q -U postgres; do sleep 0.2; done
export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:$PORT/postgres"
psql -q "$DATABASE_URL" -c 'select count(*) from pg_extension'
# -P takes a random host port, so two jobs on one runner cannot collide on
# 5432. Testcontainers does that, the wait strategy, and a reaper sidecar that
# holds a socket to your test process and kills the container when it drops.
# That reaper is the single best piece of engineering in this whole category.Two details carry the script. `max_connections` is raised explicitly, because the default divided by your parallel worker count, times your ORM's pool size, is the arithmetic behind the flakiest failure in testing: a suite that passes alone and fails in parallel, intermittently, looking exactly like a race in your own code. And the single `trap` exists because two `trap ... EXIT` lines do not compose — the second replaces the first, and the resource you forgot is always the expensive one.
Factory (d) in practice: create, rehearse, clone, destroy
The reason to accept a 30-to-90-second create is the rehearsal it makes possible: real data, a cluster you own, and a clone pinned to a moment in time that leaves the source untouched. Here is that loop — with the teardown in a `finally`, which, as established, is not a cleanup strategy on its own.
import os
import subprocess
from pandastack import Client
pds = Client() # reads PANDASTACK_API_KEY
# Factory (d): one dedicated Postgres server for one migration rehearsal.
# This call blocks for 30-90s because it provisions a machine and bootstraps a
# cluster. Everything after it is boring, which is the point of the wait.
db = pds.databases.create(size="4g", label="rehearsal-pr-4821")
os.environ["PGSSLMODE"] = "require" # the endpoint is TLS-only anyway
os.environ["DATABASE_URL"] = db["connection_url"]
print(db["id"], "->", db["connection_url"]) # <id>.db.pandastack.ai:5432
before = None
try:
# Real data, then the migration that scares you. Superuser is yours here,
# so extensions, ALTER SYSTEM and a deliberate crash are all on the table.
subprocess.run(
["pg_restore", "--no-owner", f"--dbname={db['connection_url']}", "prod-anon.dump"],
check=True,
)
subprocess.run(
["psql", db["connection_url"], "-v", "ON_ERROR_STOP=1",
"-f", "migrations/0042_orders_not_null.sql"],
check=True,
)
# The forensic move: clone the database as it was earlier into a NEW id.
# target_time must be at least a couple of minutes in the past, the source
# is never touched, and size= is how you "resize" - a clone into a tier.
before = pds.databases.clone(
db["id"],
target_time="2026-10-06T09:30:00Z",
label="rehearsal-pr-4821-before",
size="1g",
)
print("pre-migration copy:", before["id"])
finally:
# The bill runs until this loop does, and this loop does not run when the
# runner is reclaimed. Write the reaper too; see the next section. Note the
# clone is in here as well: a forgotten clone is a whole second database.
doomed = [db["id"]] + ([before["id"]] if before else [])
for victim in doomed:
pds.databases.delete(victim)Two things there are worth copying whichever factory you end up in. The clone goes to a new id rather than rewinding the source, so what you were rehearsing against survives for a second attempt — an in-place restore destroys the evidence you are about to want. And the delete is an explicit call rather than a TTL, which is honest about where the responsibility sits: nothing expires a managed database here, because the only thing an expiry could do is delete a durable volume, and no platform should do that on a timer it inferred.
Extensions are a tenancy question, not a feature checkbox
People read "supports pgvector" as a yes/no column. It is really a question about who owns the cluster, and the answer differs per factory in a way that decides whether a whole category of work is possible at all.
In factory (a) you are superuser on a cluster you built thirty seconds ago, so you can install whatever your local Postgres build has packaged — and nothing more, which is where the laptop-versus-production divergence sneaks in. In factory (b) extension installation is cluster-global and superuser-gated: `CREATE EXTENSION` in your pull request's schema changes the server everybody else is using, so a PostGIS version bump stops being a commit and becomes a scheduling problem. In factory (c) the list is the vendor's, inherited by every branch; you can use what they ship and you cannot add to it.
Factory (d) is the only one where the answer is simply yours, including the case the other three cannot touch: an untrusted C extension. A C extension is not a plugin in a sandbox — it is code loaded into the postmaster's address space, running as the OS user that owns your data directory. On a shared cluster that is a tenancy incident waiting for a volunteer; on a dedicated server inside a microVM it is a contained experiment, because the boundary underneath is a kernel you are the only tenant of and the worst case is one VM you were deleting anyway. That is also the argument for giving an agent its own database rather than a role in yours: when a model decides the answer involves `CREATE EXTENSION` and a shell escape, you want the blast radius to be disposable.
The two failure modes that make teams distrust temporary databases
Neither of these is a vendor's fault, and both of them are why the phrase "ephemeral database" gets an eye-roll in some teams. They are worth naming because the fix for each one is specific.
The empty schema that passes a test production would fail
This is the failure that discredits factories (a) and (b), and it is not subtle once you list it out. A migration that adds a `NOT NULL` column with no default succeeds instantly against zero rows and takes a table lock proportional to row count against two hundred million. `CREATE INDEX` without `CONCURRENTLY` is free on an empty table and is an outage on a real one. A backfill written as a single `UPDATE` passes your suite and then holds a transaction open long enough to make autovacuum useless. A unique constraint you are adding is satisfiable in your seed and violated by four rows that have existed since 2021.
Then there is the planner, which you cannot seed your way out of. Postgres chooses plans from statistics, and on a table with a hundred rows a sequential scan is genuinely the right choice — so your carefully added index is never exercised, your `EXPLAIN` output is fiction, and an N+1 that will saturate a connection pool in production is invisible. You can make a seed adversarial enough to catch constraint and lock problems, and you should. You cannot make a small seed teach you what the planner does at scale. That is the gap factories (c) and (d) exist to close, and the only reason I think a slower create can be worth paying for: a rehearsal on production-shaped data is a different kind of evidence than a green suite.
The "temporary" server that is now in the runbook
Somewhere in your organisation is a database that has been temporary since 2019. You can identify it by its hostname, which contains the word `new` or the digit `2` — telling you exactly what happened to its predecessor. Its password is in a wiki page, a Terraform variable, and one person's shell history. Nobody configured backups because it was temporary. And at some point a production service started reading from it by accident, which is why nobody is allowed to delete it now.
The mechanism is always the same: a temporary resource whose destruction nobody owns becomes permanent the moment anything else depends on it, and things depend on whatever is reachable and stable. So the countermeasures are about reachability and ownership, not technology. Give lifetime to something that cannot forget — a job's lifetime, a pull-request-closed webhook, a TTL at create time, a reaper scoped to a label prefix you control — and write that reaper in the same pull request as the provisioning code, because a reaper written later is a reaper written never. Have it skip anything younger than about fifteen minutes so it cannot race a provisioning database, and have it ask whether the run is still active rather than inferring liveness from age. And never give a temporary database a DNS name a human can type from memory, because the moment it has one, somebody puts it in a config file.
What to actually pick
- A test suite, unit or most integration: factory (a). Entry 1 if you want it fastest, entry 3 if a team has to maintain it. Add `CREATE DATABASE ... TEMPLATE` per worker so the seed is paid once per run, not once per test.
- A pull-request preview a human will click through: factory (c). Entry 6 for a database, entry 7 for the environment around it — the database alone is usually a third of the preview.
- A migration rehearsal: factory (c) or (d), never (a). If your data already lives in RDS, entry 8 is the highest-fidelity answer available and you should use it. If you want a cluster you can also break, entry 9.
- A data-science scratchpad: honestly, entry 4, unless the dataset is large enough that you want a memory tier — in which case entry 9 and a `DELETE` on your calendar.
- An agent that was asked to just try the query: factory (d). Not because agents are malicious, but because the cheapest way to make a creatively wrong answer harmless is for the thing it was wrong inside to be disposable and alone.
- Any of the above, at team scale: whatever you pick, the reaper ships in the same pull request. That sentence is the whole post.
Frequently asked questions
Does my test suite actually need a real Postgres?
Usually yes, and the reason is not purity — it is that your query planner is not a mock. The moment a test asserts anything about behaviour rather than syntax, you are depending on a specific implementation: a window function's frame semantics, how `ON CONFLICT` interacts with a partial unique index, what a `citext` comparison does under your collation, whether a `CHECK` constraint fires before or after a trigger, what `NOW()` means inside a transaction. SQLite, an in-memory fake and an ORM-level stub each have their own answers to those, and none of them are Postgres's. A suite built on a fake tests your code against a database you do not deploy. The honest counterargument is scope. Pure unit tests over functions that never touch SQL should not have a database at all, and wiring one in makes them slower without making them better — if your suite's slowest portion is provisioning databases for tests that only exercise arithmetic, cut those tests off the database entirely. But for anything that composes SQL, a real postmaster from factory (a) costs you a cluster start and removes an entire class of bug that only appears in production. The place to be ruthless is the data, not the engine: use real Postgres, keep the dataset tiny, and be clear-eyed that a tiny dataset cannot tell you anything about plans or lock duration.
Why does a dedicated database server take 30 to 90 seconds when a local initdb is nearly instant?
Because they are doing genuinely different amounts of work, and it is worth separating the parts. A local `initdb` writes a data directory to a tmpfs and starts one process as you; nothing is provisioned, nothing is routed, nothing is durable. A PandaStack managed database boots a dedicated Firecracker microVM, attaches a durable volume, bootstraps a PostgreSQL 16 cluster, generates and verifies per-database credentials, and publishes a TLS endpoint that is SNI-routed so a client anywhere can reach it by name. Our measured range for that whole sequence is 30 to 90 seconds, and most of it is Postgres bootstrapping and reaching a genuinely ready state rather than the VM itself — a sandbox restored from a baked snapshot on the same platform creates with a p50 of 179 ms, which tells you where the time is not going. So the calculus is simple: if you need a database per test case, the create cost dominates everything and factory (a) wins outright. If you need one database per pull request, per rehearsal, or per agent session, you amortise a one-time minute against a workload measured in minutes or hours, and the things you get — superuser, your own kernel, a durable volume, point-in-time clone — are not purchasable at any speed in the other factories.
Schema per pull request, or a whole database?
Start with a database, not a schema, and only drop to a schema if the arithmetic forces you. A schema per pull request is cheaper to create and looks tidy, but it leaks in ways that are hard to debug later: migrations that assume they own `public` will either fail or quietly modify the wrong namespace, extensions are installed per-database rather than per-schema so you cannot vary them, sequences and types shared across schemas produce cross-talk that presents as flakiness, and any code with a hard-coded schema name silently escapes its own tenant. A database per pull request fixes all of that for the cost of one `CREATE DATABASE ... TEMPLATE`, which on Postgres 15+ you should issue with `STRATEGY = FILE_COPY` when the template is large, since the default `WAL_LOG` strategy pushes the whole copy through the write-ahead log. Two caveats either way. `CREATE DATABASE ... TEMPLATE` refuses to run while another session is connected to the template, so seed before the workers start and never during. And neither choice gives you isolation in the security sense — both tenants share `max_connections`, `shared_buffers`, the WAL writer and the autovacuum worker pool, so a noisy pull request is still everybody's problem. When that starts happening weekly, you have outgrown factory (b) and the next step is a server per unit.
How do I stop temporary databases from leaking and running up a bill?
Accept first that your cleanup code will not run on the paths that matter. Cancellation, spot reclamation and OOM kill all skip your `trap`, your `finally` and your after-hook, and those three are responsible for most leaks I have seen. So the rule is to make lifetime belong to something that cannot forget. Best case, you do not own it at all: a container in your CI provider's service block dies with the job, enforced by the provider, and Testcontainers' reaper holds a socket to your test process so even a `SIGKILL` gets cleaned up. For anything created through an API, you need a reaper, and it needs four properties: scoped to a label prefix you own so it can never touch something it did not create, aware of whether the owning run is still active rather than guessing from age, refusing to delete anything younger than about fifteen minutes so it cannot race a provisioning database, and shipped in the same pull request as the provisioning code. Then add the observability that makes leaks visible: a count of live temporary databases on a dashboard, alerted on the count rather than the bill, because the bill tells you a month late. On PandaStack nothing expires a managed database — it is created persistent on purpose, since an idle reaper removing one would be destroying a durable volume — so the `DELETE` is yours and the rate card ($0.054 per vCPU-hour, $0.0162 per GiB-hour) runs until you call it.
Can I put real production data in a temporary database?
Technically yes, in factories (c) and (d), and that capability is the main reason to use them. Legally, that is a question for someone other than your test harness, and you should ask it before you build the pipeline rather than after a branch of your customer table turns up in a preview environment with a public hostname. The practical framing I use: a copy of production data inherits every obligation the original has — the same retention rules, the same access controls, the same breach reporting, the same regional constraints — and it inherits them in an environment specifically designed to be created casually by automation and forgotten. That asymmetry is the whole risk. If the answer is yes with conditions, the conditions are usually: anonymise or synthesise the columns that carry the obligation, do it in a job between production and the temporary copy rather than hoping every consumer remembers, keep the copy's lifetime short and owned by the reaper you already wrote, and keep its endpoint unreachable by anything that is not the job. If the answer is no, you are not out of luck: a seed built to be adversarial — real row counts, real cardinalities, real skew, fake values — gets you most of the migration and lock-duration findings without carrying anybody's personal data into a disposable environment.
Keep reading
- Top 8 throwaway Postgres platforms — The sibling roundup, sorted by mechanism rather than by factory: container, copy-on-write branch, or dedicated instance, with a different cast and the per-pull-request teardown question in more depth.
- Top 5 scratch Postgres platforms for CI — The narrow version of this post: a Postgres a CI job creates, hammers and destroys — including the argument that most suites should buy nothing at all.
- Cloning production data for testing — How to make factory (c) and (d) rehearsals honest: what to anonymise, where to do it, and how to keep a clone's lifetime short.
- Running untrusted Postgres extensions — The extensions section taken further: why a C extension is code in your postmaster's address space, and what a kernel boundary underneath it changes.
- PandaStack managed Postgres — PostgreSQL 16 in a dedicated microVM: RAM tiers, TLS-only SNI routing, the HTTP query broker, and the point-in-time clone endpoint used above.
Related posts
- Testcontainers, and what comes after it
Testcontainers solved a genuine problem: tests against a real Postgres instead of a mock. The limits show up later, when the suite gets parallel and the CI runner is itself a container.
- Run AI-Agent DB Migrations in an Isolated microVM
You let a model generate a migration. It's confident. It says DROP TABLE users; -- oops. Run that against a disposable Postgres inside a microVM, not your real database.
- Testing LLM-Generated Database Migrations Safely in a Sandbox
An AI agent writing a migration is one hallucinated DROP TABLE away from ruining your night. Apply it to a disposable Postgres VM first, diff the schema, and throw the VM away.
- Best Database-as-a-Service Platforms (2026)
Every team outsources database ops to somebody eventually — the question is which isolation model, backup story, and pricing curve you're signing up for. Here's an honest walk through the major managed-database options in 2026, including where PandaStack's dedicated-VM-per-database model fits and where it doesn't.
- The best load testing platforms in 2026
The hard part of load testing was never generating requests. It is generating them from enough machines, without saturating the generator, and reading a percentile that means what you think it means.
More in Ephemeral databases · See Ephemeral Postgres databases on PandaStack
49ms p50 cold start. Fork, snapshot, and scale to zero.