Top 6 Burner Postgres Platforms for Pull Requests (2026)
I build PandaStack, an open-source Firecracker microVM platform, and the most expensive database anyone has ever shown me belonged to nobody. It was a Postgres instance labelled `pr-412`, sixteen gigabytes of RAM, still running, still holding a copy of production's users table. Pull request 412 was merged in March. The engineer who opened it had moved teams. The cleanup job that was supposed to delete it had been switched off eleven months earlier, after it deleted a database that turned out not to be a pull-request database, and nobody re-enabled it because nobody could remember which one that had been.
That is the genre in one paragraph. The database was not a mistake, it was an artefact of a correct process whose last step had no owner. Ephemeral is not a property of a database. It is a property of your cleanup job, and a cleanup job that has been disabled since last spring is not a property of anything.
A burner database for a pull request is a genuinely different requirement from a scratch database for a test run, in ways that change which platform is correct. A test-run database lives for ninety seconds inside one process, and the process that made it can kill it. A pull-request database lives as long as the review does — a human-scheduled interval: days, with gaps, with force-pushes in the middle, and two different consumers that both need the same credentials.
A pull-request database is not a scratch database
Every honest comparison of disposable Postgres ends up recommending a tuned container plus a template database, because for unit and most integration tests it genuinely wins. For the pull-request case that recommendation fails on a structural property rather than a performance one. Five things a pull-request database has to do that a scratch database does not:
- It outlives the process that created it. A `trap ... EXIT` is not a lifecycle, it is a convenience. The database has to be there tomorrow, when the reviewer finally looks.
- It serves two consumers with one credential. The preview application reads a connection string from its environment and opens many short connections. A human reproducing the reviewer's complaint opens exactly one, from a laptop, over the public internet, with psql. Both need to work, and the second one is the requirement that eliminates half the options.
- It gets written to repeatedly, by CI, over days. A force-push is a new commit on the same pull request, and the same database is still sitting there holding the schema of the history that used to be on that branch.
- The next run has to find it again. Which means it needs a name you can reconstruct from the pull request, rather than an id you printed once into a job log that has since been rotated out of retention.
- Something has to delete it, and that something cannot be the job that created it, because that job exited eight days ago with a green tick.
The second property is the one that quietly decides the shortlist, because a pull-request database is half of a preview environment — see preview environments for the application side — and a preview environment whose database is unreachable from a laptop is a demo nobody can debug.
Read that list against Testcontainers and the mismatch is immediate: it is excellent at every property of a test database and structurally incapable of the first two, because its entire design premise is that the database dies with the process. That is not a criticism. It is a scope boundary, and the reason this post's shortlist is not the same shortlist as the test-suite one.
Four questions, in this order
1. How you name it, and find it again
The pull request number is the only durable handle you have. Not the branch name: branches get renamed mid-review, they contain slashes and uppercase letters that most DNS-shaped identifiers reject, and the same name can be reused months later by someone who did not know. Not the commit SHA, which changes on every push and would give you a fresh database per push, which is the inverse of what you want. Not the author, obviously. The number is an integer, it is monotone, it is in every webhook payload, and it is in the URL the reviewer is looking at.
What varies between platforms is where that handle is allowed to live, and there are only three answers. Some name the object for you — the branch is called whatever the git branch is called — which is convenient right up to a rename. Some give you a label or metadata field you control. Some give you nothing, so you maintain your own table mapping pull requests to database identifiers, which is a new source of truth that can disagree with reality.
Grade a platform on one thing: is lookup-by-pull-request a single call, and does it survive a rename? A list-and-match is fine at pull-request cardinality — tens of open PRs, not millions — but match on an exact prefix contract rather than a substring, or the day somebody opens pull request 41 you will delete the database belonging to 412.
2. What it costs while the pull request sits in review
This is the axis the marketing pages are quietest about, because the honest answer for most products is a sentence about per-branch compute that nobody wants printed next to a free tier. There are three idle-cost shapes and they differ by an order of magnitude.
- Storage-only when idle. The branch's compute scales to zero and you pay for the delta it holds. This is the shape that makes per-PR databases feel free, and it is the strongest argument for a copy-on-write branching product in this specific use case.
- A fixed per-branch charge. Cheap individually, and then you multiply by the number of pull requests your team keeps open, which for most teams is a number they have never looked at.
- A whole server, billed by the hour, awake. Honest, predictable, and the most expensive of the three by a wide margin. This is my own platform's shape, and I will do the arithmetic on it below rather than around it.
The multiplication is the part people skip. Forty open pull requests, each open an average of nine days, is roughly three hundred and sixty database-days a month. Whatever your per-day figure is, multiply it by that, and then notice that the orphaned ones are not in the count because nobody counted them.
3. How the schema and the data get in
Three mechanisms, and they are not interchangeable. You migrate from empty, you branch from an existing database, or you restore a dump. Migrating from empty is cheap, deterministic, reviewable, and tells you precisely nothing about whether your migration completes against production cardinality. Branching from a real database is the only one that rehearses the migration honestly, and it is also the one that puts a copy of production data into an environment whose access-control story is a preview URL. That is a legal question before it is a configuration flag, and it does not stop being one because the database is called `pr-412`.
The pull-request-specific wrinkle is the second arrival. On the first CI run the schema arrives once. On the fourteenth run it has to arrive again, against a database that already has a schema — and if the author rewrote a migration file during review, which is a normal thing to do in response to review comments, then running pending migrations is a no-op against a schema that no longer matches the tree. Your pull request now passes its tests against a schema that will never be deployed anywhere. Fingerprint the migration directory, store the fingerprint in the database, and rebuild from empty when it changes. There is a version of this in the code below.
One platform-specific constraint worth stating because it is mine: on PandaStack you cannot shell into a managed database VM. The guest-control routes refuse managed sandboxes outright, which is a deliberate hardening after a customer used a root shell in a database VM to run a cryptocurrency miner. So the schema arrives over the wire, through psql or your migration tool, and a multi-gigabyte `pg_restore` is network-bound from your runner. If your seed is enormous, branch or clone it rather than restoring it.
4. Who deletes it
There are four deletion triggers and exactly one of them is reliable.
- The creating job's own teardown. Dies with the job. Covers the happy path and the cancelled run, covers nothing that happens after the job exits — which for a pull-request database is the entire interesting window.
- The merge webhook. Fires on merge. Does not fire when the pull request is closed without merging, which is a large fraction of pull requests, and does not fire when the workflow file is itself the thing that is broken.
- A time-to-live. The only trigger that works with nobody watching, and the one that deletes the database a reviewer is three minutes into demoing. Check what your platform's TTL actually measures before you lean on it: on mine it is an idle timer rather than a wall clock, and a managed database is created persistent, which the reaper skips entirely — so there is no idle TTL and no lifetime cap on it at all. A TTL is a good backstop where you have one, and never a primary.
- A reconciler against the current set of open pull requests. This is the right answer. It is also the one that requires you to define open correctly, read it fresh, and fail closed when the read fails.
The reconciler's definition problem is where good implementations go wrong. A pull request can be merged, closed, converted to draft, retargeted at a different base branch, or come from a fork. Drafts are open. Fork PRs are open. Anything you cache about openness is wrong by the time you act on it, so read the live list every time — and fail closed, because the reaper that deletes forty live databases after one bad token is the reaper that gets switched off for eleven months. Which is how we got here.
The six
Qualitative for everyone except my own platform, because I am not going to invent somebody else's benchmark. This layer of the market changes every quarter — branching semantics, idle billing and retention defaults in particular — so verify behaviour against each vendor's current documentation before you commit a workflow to it.
1. Neon — branching as the primitive
The product that defined the shape. Compute is separated from storage, a branch is a copy-on-write pointer into the storage layer rather than a copy of bytes, and creating one is fast enough that doing it per pull request is the intended use rather than an abuse. For the four questions it answers the first three well: branches are named, the name is yours, data arrives by inheriting the parent's, and idle compute suspends so the nine-day review window is mostly storage. Strongest fit for the pull-request case of anything on this list.
Check yourself: how branch limits and retention windows interact with your plan, and what a branch costs once it has accumulated a large write delta — a branch is cheap because it shares, and a branch your migration rewrote half of shares less. The cleanup question is still yours. The platform will happily hold four hundred branches for you, and the fact that each one is cheap is exactly why nobody notices.
2. Supabase branching
The distinguishing feature here is that the branch is not only a database. It is a Postgres branch wired into the rest of the project — auth, storage, the generated API, the keys your preview app needs — provisioned from the repository, which for a pull request is the thing you actually wanted. If your application talks to Supabase rather than to Postgres, a bare database branch leaves you assembling the other half by hand, and this does not.
The trade is coupling: the lifecycle is the project's, the migration flow is the one it expects, and cost and retention semantics are the platform's rather than yours. Check how a preview branch is torn down, what the idle charge is while a pull request sits, and whether the branch's data is seeded or inherited on your plan — that last one is the difference between rehearsing a migration and not.
3. PlanetScale-class branching, with deploy requests
Worth including as a shape rather than only as a product, because it contributes the one idea the others do not: the schema change is itself a reviewable object. A branch has a schema diff, the diff gets requested and approved, and the promotion to production is a distinct operation with its own gate. That maps onto the pull-request lifecycle more exactly than anything else in this list, since it makes the database change and the code change two halves of one review.
Two caveats, both yours to verify. This lineage grew up MySQL-first, so if you need Postgres specifically, confirm what the current Postgres offering supports — branching semantics, extensions, the deploy-request flow — against today's documentation rather than against an article. And the deploy-request model has opinions about schema changes that are correct and occasionally inconvenient; teams that love it call them guardrails, and teams that do not use the same word.
4. Docker Compose or Testcontainers in CI — the DIY baseline
For test runs this is the right answer far more often than vendors would like, and I have said so in print. For pull-request databases it fails the first two properties on the list and no tuning fixes it: the container's lifetime is the job's lifetime, so it cannot be there when the reviewer arrives, and a runner-local port is not an endpoint.
There is a real variant: Compose on a long-lived host, a box that keeps a `pr-412` container alive between runs behind ingress you operate. People do this and it works. Be clear what you signed up for — Postgres upgrades, disk growth, a backup story for data you promised was disposable, the ingress and its TLS, and a noisy-neighbour problem where a migration on one pull request's container stalls another's test run because they share a kernel and a page cache. Compare that against a managed option's monthly bill, not against zero.
5. A managed Postgres instance per pull request, via a cloud provider API
Call the provider's API, get a real managed instance, point the preview app at it, delete it on merge. The appeal is that it is the same product your production database is, which means the extension set, the version, the parameter group and the failure modes are the ones you already know. If your reason for a pull-request database is rehearsing a migration that must behave exactly as it will in production, this is the only option on the list that is tautologically faithful.
The costs are the ones you would guess, plus one you might not. Provisioning is minutes, so you create early in parallel with the build, or you wait. Idle cost is a whole instance by the hour, which makes the nine-day review window the dominant line item. Restoring a snapshot of production into it is both the only sensible way to get real data in and a compliance event you should have a written answer for. And the one people miss is quotas: instance counts, subnet IP exhaustion and storage limits are per-account, so the day you hit one is the day every pull request fails at once with an error nobody on the team has seen before.
6. PandaStack managed databases
Mine, so here are the mechanics rather than adjectives — the product page is managed Postgres. A managed database is a dedicated Firecracker microVM running PostgreSQL 16 plus a durable volume, created with `persistent: true` so the idle reaper never touches it, and pinned to the host that holds its volume. Creation takes 30 to 90 seconds, because the call blocks until PostgreSQL is actually accepting connections rather than until a row exists. You get `postgres://pandastack:<pw>@<id>.db.pandastack.ai:5432/pandastack` over TLS, routed by SNI, plus a pooled URL through the guest's pgbouncer for workloads that open many short connections. The RAM tier is chosen at create time with `size`, one of `1g`, `4g` or `16g`, which selects the template.
Thirty to ninety seconds is slow next to a copy-on-write branch, and the reason is architectural rather than fixable. A branching product forks a storage layer and attaches compute that is already warm. I boot a server: a microVM comes up, PostgreSQL initialises against a durable volume, and nothing reports ready until a real connection succeeds. For a database that will exist for nine days, a minute is paid once on the first CI run and never again, because every later run finds the same database. For a database per test it is disqualifying, and I say so in the test-suite version of this comparison.
What the architecture buys back is the two properties the pull-request case actually needs. The endpoint is stable and public for the database's whole life, so the preview application's environment variable and the reviewer's psql command keep working across every force-push without rotation. And `POST /v1/databases/{id}/clone` with a `target_time` in RFC3339 — at least two minutes in the past, because the archive trails live writes — restores into a brand new database id while leaving the source untouched, which means the reviewer who asks what this looked like before the backfill gets a database rather than an argument. Passing a different `size` to the same call is also the supported resize path, since a snapshot-restored microVM cannot be resized in place.
There is also a `POST /v1/databases/{id}/branch` route that reflink-copies a running parent's live volume during a brief pause window on the parent's own host — seconds rather than a minute, at the cost of landing on the same host as the parent. It is REST-only today; the Python SDK's databases surface does not expose it yet, so the clone path is what the examples below use.
| Option | Provisioned by | Time-to-ready shape | Idle cost while in review | Schema and data arrive by | Cleanup story |
|---|---|---|---|---|---|
| Neon branching | Branch API or git integration | Seconds — storage pointer, warm compute | Storage delta; compute suspends | Inherited from the parent branch | Yours. Branches are cheap, so nobody notices four hundred |
| Supabase branching | Project branch from the repo | Seconds to a minute for the whole branch | Per-branch, platform-defined | Repo migrations, or inherited, per plan | Tied to the project's branch lifecycle |
| PlanetScale-class | Branch plus a deploy request | Seconds; promotion is a separate gated step | Per-branch | Inherited, with a reviewable schema diff | Branch deleted on promotion or close |
| Compose / Testcontainers | The CI job itself | Seconds, but only inside that job | Zero — it does not survive the job | Migrations or a dump, every run | Automatic, by dying. Which is also why it fails here |
| Cloud managed instance | Provider API per pull request | Minutes | A whole instance, by the hour | Snapshot restore or migrations | Your webhook, your reconciler, their quotas |
| PandaStack databases | POST /v1/databases with a label | 30-90s, blocks until Postgres accepts connections | Runs and bills 24/7 at the committed rate | psql over the wire, or clone from a parent | Your reconciler. DELETE is irreversible and immediate |
The nine-day arithmetic, on my own platform
Doing this to myself rather than to a competitor, because it is the number that decides whether this is the right tool for your pull requests. PandaStack bills $0.054 per vCPU-hour and $0.0162 per GiB-hour, the same rate for every class, with no per-request charge — the whole rate card is on pricing. Memory is charged on committed gibibyte-hours; CPU is charged on CPU-seconds actually burned, so an idle PostgreSQL doing checkpoints and autovacuum sits near the memory floor rather than near its burst ceiling.
So a `1g` pull-request database costs about one and a half cents an hour in memory, call it thirty-nine cents a day, which is about three and a half dollars across a nine-day review. Forty of those open at once is fifteen or sixteen dollars a day, and that is a number a team can look at without flinching. A `16g` database is sixteen times the memory figure: roughly six dollars and twenty cents a day, fifty-six dollars across the same nine days. The `pr-412` in my opening paragraph was a `16g`, and it had been running since March.
Be clear what that means structurally: a managed database here is a persistent sandbox that runs and bills continuously. There is an idle auto-suspend sweep in the agent — it hibernates a database with no non-platform client activity, and the proxy wakes it on the next connection so the customer sees only a slower first query — and it is deliberately off by default, because a database that suspends and fails to wake is worse than one that never suspends. `POST /v1/databases/{id}/wake` exists. The arithmetic above assumes none of it. Plan for the awake number.
The orphan, mechanically
Nobody intends to leave a database running for seven months. It happens through four specific gaps, and naming them is more useful than exhorting people to be tidier.
- The pull request was closed, not merged. Your teardown is wired to the merged event, as every example in every blog post is, and roughly a third of pull requests never merge. The database for the approach you abandoned outlives the approach you shipped.
- The workflow that deletes it is in the branch. The pull request that broke CI cannot run the job that cleans up after the pull request that broke CI. This one is almost funny until you notice it selects specifically for the pull requests that went worst.
- The handle was lost. Somebody created it by hand during an incident, named it something sensible at the time, and your reconciler's prefix contract does not match it. It is now permanently out of scope of every automated check you own, which is the same thing as permanent.
- The reaper is off. It deleted something it should not have, once, and the fix was to disable it rather than to fix its matching rule, because disabling it took one minute and fixing it would have required someone to be confident about which databases were in scope.
Underneath all four is the real reason, which is organisational rather than technical: a pull-request database has no owner and no budget line. It is charged to an engineering lump sum nobody reads line by line, so the feedback loop has no edge that touches a person. Nothing in the system is unhappy about `pr-412`. The bill goes up by a rounding error per month, forever, and rounding errors are precisely the signal organisations are built to ignore. The fix is not vigilance, it is printing the number once a week where a human sees it — three stale burner databases, eleven dollars a month is the smallest intervention that works, because it converts an invisible rounding error into a sentence.
Doing it on PandaStack: two jobs, not one
The shape that survives contact with reality is two jobs rather than one: a per-event job that is idempotent and finds-or-creates, and a scheduled reconciler that answers to the live set of open pull requests. The first is what your pull-request workflow calls. The second is what actually controls your bill. Both use the Python SDK's databases surface — `pip install pandastack`, and set `PANDASTACK_API_KEY` in the environment.
#!/usr/bin/env python3
"""One burner Postgres per pull request: find-or-create, migrate, publish.
Runs on every CI event for the PR, so it must be idempotent: the second,
fifth and forty-first run on PR 412 all have to land on the same database.
The handle is the PR number, because it is the only identifier that survives
a force-push, a branch rename and a retarget.
"""
import hashlib
import json
import os
import pathlib
import subprocess
from pandastack import Client
PR = os.environ["PR_NUMBER"] # from the CI event, not from git
LABEL = f"pr-{PR}" # the one handle you get to choose
SIZE = os.environ.get("PR_DB_SIZE", "1g") # 1g | 4g | 16g
client = Client() # PANDASTACK_API_KEY from the environment
def find_by_label(label):
"""GET /v1/databases surfaces `label`, `size` and `status`; there is no
server-side filter, so the lookup is list-and-match. Fine at
pull-request cardinality -- you have tens of these, not millions."""
for db in client.databases.list():
if db.get("label") == label and db.get("status") != "failed":
return db["id"]
return None
db_id = find_by_label(LABEL)
if db_id is None:
# 30-90s. A real PostgreSQL server is initialising inside a fresh
# microVM; create() polls until it reports running WITH credentials.
db = client.databases.create(label=LABEL, size=SIZE, timeout=240)
first_run = True
else:
db = client.databases.get(db_id)
if db.get("status") in ("hibernated", "paused"):
client.databases.wake(db_id) # 202, then poll
if db.get("status") != "running" or not db.get("connection_url"):
db = client.databases.wait_until_ready(db_id, timeout=240)
first_run = False
db_id = db["id"]
url = db["connection_url"] # postgres://...@<id>.db.pandastack.ai:5432/...
pooled = db.get("pooled_connection_url") or url # pgbouncer on :6432
# --- the force-push problem -------------------------------------------------
# A reused database carries the schema of whatever history USED to be on this
# branch. If the author rewrote a migration, "run pending migrations" is a
# no-op against a schema that no longer matches the tree. Fingerprint the
# migration directory and store the fingerprint IN the database -- there is
# no mutable metadata field on a managed database, and that is fine: the
# database is the right place for a fact about the database.
digest = hashlib.sha256()
for f in sorted(pathlib.Path("db/migrations").rglob("*.sql")):
digest.update(f.name.encode())
digest.update(f.read_bytes())
fingerprint = digest.hexdigest()[:16]
def psql(sql: str) -> str:
out = subprocess.run(
["psql", url, "-At", "-v", "ON_ERROR_STOP=1", "-c", sql],
capture_output=True, text=True, check=True,
)
return out.stdout.strip()
psql("CREATE TABLE IF NOT EXISTS _pr_meta (k text PRIMARY KEY, v text NOT NULL)")
previous = psql("SELECT v FROM _pr_meta WHERE k = 'migrations'")
if first_run or previous != fingerprint:
if not first_run:
# History was rewritten under us. Rebuilding from empty is the only
# honest answer; a half-migrated PR database is how you get a review
# that passes on a schema nobody will ever deploy.
print(f"migrations changed ({previous or 'none'} -> {fingerprint}); rebuilding")
psql("DROP SCHEMA public CASCADE; CREATE SCHEMA public")
psql("CREATE TABLE _pr_meta (k text PRIMARY KEY, v text NOT NULL)")
subprocess.run(["npm", "run", "migrate"], check=True,
env={**os.environ, "DATABASE_URL": url})
# fingerprint is a sha256 hex prefix, so interpolating it is safe.
psql("INSERT INTO _pr_meta VALUES ('migrations', '%s') "
"ON CONFLICT (k) DO UPDATE SET v = excluded.v" % fingerprint)
# --- publish ---------------------------------------------------------------
# Two consumers. The preview app gets the pooled URL (many short-lived
# connections). The human who will reproduce the bug on their laptop gets
# the direct one, in the PR comment, because they are going to want psql.
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
fh.write(f"database_id={db_id}\n")
fh.write(f"database_url={pooled}\n")
fh.write(f"psql_url={url}\n")
print(json.dumps({"id": db_id, "label": LABEL, "reused": not first_run}))
Three details matter more than the rest. The lookup is by label rather than by a stored id, so the job is stateless and a lost log does not lose the database. The migration fingerprint lives in the database itself, because there is no mutable metadata field on a managed database — and the database is the correct home for a fact about the database anyway. And the two published URLs are deliberate: pooled for the application, direct for the human, same credentials, both stable for the database's whole life because the SNI-routed hostname is derived from the database id. Now the job that matters.
#!/usr/bin/env python3
"""Delete the burner databases whose pull requests are no longer open.
This is the job that actually keeps the bill honest. The teardown step in the
merge workflow is a nice-to-have: it does not run when the PR is closed
without merging, when the workflow file itself is what broke, or when the
runner is SIGKILLed. So the authority is not an event -- it is the current
set of open pull requests, read fresh, every hour.
Two safety properties worth copying:
* fail closed. If the GitHub call errors or returns an implausibly empty
list, delete nothing. A reaper that deletes everything after an API blip
is how teams end up disabling their reaper for eleven months.
* only touch names you own. The `pr-<n>` prefix is the contract. A
database called "analytics-staging" is never in scope, whatever happens.
"""
import os
import re
import sys
import requests
from pandastack import Client
REPO = os.environ["GITHUB_REPOSITORY"] # "org/repo"
GH = {"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
"Accept": "application/vnd.github+json"}
PR_LABEL = re.compile(r"^pr-(\d+)$")
DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
# Authority: every open PR, including drafts, including ones from forks.
open_prs: set[str] = set()
page = 1
while True:
r = requests.get(f"https://api.github.com/repos/{REPO}/pulls",
headers=GH, params={"state": "open", "per_page": 100, "page": page},
timeout=30)
r.raise_for_status()
batch = r.json()
if not batch:
break
open_prs.update(str(pr["number"]) for pr in batch)
page += 1
client = Client()
burners = {m.group(1): db for db in client.databases.list()
if (m := PR_LABEL.match(db.get("label") or ""))}
# Fail closed. "Zero open PRs and forty burner databases" is far more likely
# to be a broken token than a very productive weekend.
if burners and not open_prs:
sys.exit("refusing to reap: 0 open PRs reported, which is not plausible")
stale = {n: db for n, db in burners.items() if n not in open_prs}
print(f"{len(burners)} burner databases, {len(open_prs)} open PRs, {len(stale)} stale")
for number, db in sorted(stale.items(), key=lambda kv: int(kv[0])):
size = db.get("size", "?")
print(f"{'would delete' if DRY_RUN else 'deleting'} pr-{number} ({db['id']}, {size})")
if not DRY_RUN:
client.databases.delete(db["id"]) # irreversible, which is the point
The fail-closed check is not decoration. Every reaper that ever got disabled was disabled after a run in which an upstream read failed and the code interpreted an empty result as permission. Treat an empty open-pull-request list as a bug in your token, because it almost always is, and keep the run in dry-run mode until you have watched its output across a couple of weeks of real merges. Then flip `DRY_RUN=0` and leave it alone.
The honest limits
- Thirty to ninety seconds per create, and I am not going to argue it away. A copy-on-write branch is faster by an order of magnitude because it forks a storage layer; I boot a real PostgreSQL server and block until it answers. For a database that lives for the length of a review this is a one-time cost on the first run and genuinely does not matter. For a database per test it is disqualifying.
- A microVM per database is more RAM per database than a shared-storage branching product, and that is the whole trade. Forty pull-request databases on a branching product share one storage layer and suspend their compute; forty on mine are forty running PostgreSQL servers with forty kernels, and you pay the committed memory for all of them. You are buying isolation and you are paying for isolation.
- The cleanup job is still yours to write, and the platform will not quietly rescue you. A managed database is created `persistent: true`, and the idle reaper skips persistent sandboxes before it looks at anything else — no idle TTL, no maximum lifetime, nothing standing between a merged pull request and next month's invoice. That is deliberate, because you do not want your review database vanishing mid-demo, and it means the reconciler is unavoidably yours. I have given you one; it is forty lines; nothing on my side will run it for you.
- Point-in-time cloning needs the archive to have caught up. The target instant must be at least two minutes in the past and no later than the last archived WAL segment. For incident forensics two minutes is nothing; for a clone of the state ten seconds ago it is a refusal, and you should expect it rather than retry into it.
- You cannot shell into a managed database VM, by design. The guest-control routes refuse managed sandboxes after a customer used a root shell in one to mine cryptocurrency. Everything arrives over the wire, so a large seed is network-bound from your runner, and anything you would normally fix with a quick `psql -f` on the box is a round trip instead.
- Capacity is a thing that can run out. These are real VMs on real hosts, databases are admitted against committed memory rather than overcommitted, and a create that cannot be placed is a 503 you now have to handle in CI. That is a failure mode a shared-storage product mostly does not have.
The pick, by how long your pull requests live
If your pull requests are open for hours and your review culture is fast, take the copy-on-write branching product. Idle-suspending compute means the review window is nearly free, branch creation is fast enough to be invisible, and inheriting the parent's data solves the seed question outright. Neon for a bare Postgres, Supabase if your application talks to Supabase rather than to Postgres, a deploy-request model if you want the schema change itself reviewed.
If your pull requests are open for days and the thing you are reviewing is a migration that must behave identically in production, take a real server. A managed instance from your cloud provider if faithfulness to production is the entire point and minutes of provisioning plus hourly idle cost are acceptable. Mine — ephemeral databases — if you want that plus a kernel boundary per database, a stable public TLS endpoint for the whole review, point-in-time cloning when the review turns into an incident, and a per-day figure you can compute from two numbers.
And if what you actually needed was a database for a test run rather than for a pull request, go back to the container. It is faster than all six of these and the right answer more often than anyone selling a platform will tell you.
Whichever you pick, write the reconciler first. Not the creation job — anyone can write the creation job, it is a POST. Write the thing that lists what exists, compares it to the pull requests that are actually open, and prints the difference with a dollar figure attached. A system that can create pull-request databases and cannot enumerate them is not an ephemeral-database system. It is a slow, expensive way of accumulating copies of your production data under names that refer to work nobody remembers.
Frequently asked questions
How do I make sure a per-pull-request database gets deleted when the PR closes?
Do not rely on an event. Wire the obvious teardown to your merge workflow by all means, but treat it as an optimisation, because it will not fire in the three cases that matter: a pull request closed without merging, a pull request whose own broken workflow file is what prevents the cleanup job from running, and a runner that gets SIGKILLed. The reliable mechanism is a scheduled reconciler that reads the live set of open pull requests from your forge's API and deletes every burner database whose pull request is not in that set. Three properties make it safe. First, a prefix contract — the reconciler only ever considers databases whose label matches something like `pr-<number>`, so a hand-made database called `analytics-staging` is permanently out of scope whatever happens. Second, fail closed: if the API read errors, or returns an implausibly empty list, delete nothing and exit non-zero. An empty open-pull-request list is almost always a broken token, and a reaper that interprets it as permission is a reaper that will be disabled by lunchtime and stay disabled for a year. Third, run it in dry-run mode for a couple of weeks first, with the output going somewhere a human reads, and only then let it delete. Add a time-to-live as a backstop if your platform supports one, but never as the primary mechanism, because a TTL eventually deletes the database a reviewer is mid-demo on — and check what the TTL measures before you trust it. On PandaStack it is an idle timer rather than a wall clock, and a managed database is created persistent, which the idle reaper skips before it evaluates anything, so there is no TTL and no lifetime cap on it whatsoever. That is the correct design for a database a human is reviewing against, and it means the reconciler is not optional.
Should each pull request get a database branch, a whole database, or just a schema?
It depends on which of three things you are reviewing, and the usual mistake is picking by cost rather than by what the review has to prove. A schema inside a shared server is the cheapest and is adequate when the pull request only touches application code and the database is incidental; it fails the moment the change touches an extension, a role, a server parameter or anything cluster-wide, because those are not schema-scoped and your pull request will silently affect everyone else on that server. A copy-on-write branch of a real database is the right default for application changes that need realistic data, and the only option whose idle cost stays small across a long review. A whole dedicated server is what you need when the thing under review is the database itself: a major version upgrade, an extension, a parameter change, a migration whose lock behaviour you want to observe under something resembling production cardinality, or anything where you need to be able to break the server and not care. For the pull-request case specifically there is a fourth consideration that overrides all of this: whatever you pick has to be reachable from a preview application and from a human with psql, for days. That requirement alone eliminates a schema in a server your preview environment cannot route to, and eliminates a container that lives inside a CI job.
Why does a PandaStack managed database take 30 to 90 seconds when a Neon branch is nearly instant?
Because they are doing different work, and the difference is architectural rather than an optimisation gap. A branching product separates compute from storage: a branch is a copy-on-write pointer into a storage layer, and the compute that serves it is either already running or starts from a warm pool, so the create is a metadata operation. PandaStack boots a server. A dedicated Firecracker microVM starts, a durable volume attaches, PostgreSQL 16 initialises against it, and the API does not report the database as running until a real connection succeeds and credentials are published — so the 30 to 90 seconds you wait is PostgreSQL genuinely becoming ready, not a queue. What you get for the wait is a dedicated kernel per database, your own server parameters and extensions, a stable public TLS endpoint routed by SNI for the database's whole life, and failure isolation that a shared storage layer cannot offer. For the pull-request use case the wait is paid once, on the first CI run for that pull request, and every subsequent run finds the same database and skips it entirely. For a database per test it is the wrong trade and I say so plainly: use a container.
Is it safe to put production data in a pull-request database?
Treat it as a legal question with a technical implementation rather than as a configuration flag, because that is what it is. The moment you branch or restore production into a pull-request environment you have created a second copy of regulated data, in a system whose access control is often a preview URL, whose retention is however long the pull request stays open, and whose existence your data map does not record. Everything that applies to the original applies to the copy: subject access and deletion requests have to reach it, your breach-notification scope includes it, and any cross-border transfer rule applies to wherever it now lives. There are three workable postures. Do not copy it — seed from a generator, which is weakest for migration rehearsal and strongest for everything else. Copy it through a masking step that runs as part of provisioning and is itself reviewed, which is the pragmatic middle and is real work. Or copy it unmasked into an environment you have deliberately brought up to production controls, and accept that the environment is now in scope for every audit. The posture to avoid is the accidental fourth one, where a branch-from-production flag was enabled for convenience and nobody wrote it down. And note that the orphaned pull-request database in my opening paragraph is the worst version of this: not a security failure, just a copy of production data nobody remembers making, under a name that refers to a merged pull request.
What happens to my pull-request database when someone force-pushes the branch?
Nothing happens to the database, and that is the problem. A force-push is a new commit on the same pull request, so a workflow keyed by pull request number correctly finds the same database again — which is what you want for a stable preview URL, and which means the database is still carrying the schema produced by the history that used to be on that branch. If the author rewrote a migration file in response to review, running pending migrations is a no-op against a schema that no longer corresponds to the tree, and your pull request now passes against a schema that will never exist anywhere. The cheap fix is a fingerprint: hash the contents and names of every file in your migrations directory, store that hash in the database (a one-row table is fine, and on PandaStack it is the only mutable place available, since a managed database has no user-writable metadata field), and compare on every run. When the fingerprint changes and the database is not new, drop the schema and rebuild from empty rather than attempting to reconcile. Rebuilding costs you the seed time; reconciling costs you a review that proved nothing. If your seed is large enough that rebuilding hurts, that is an argument for arriving at the schema by cloning a parent database rather than by replaying migrations.
Keep reading
- How to branch a Postgres database for a pull request — The mechanics of the branch itself, once you have decided which of the six you are using.
- A staging environment per branch: app and database — The other half of this — the preview application that consumes the connection string.
- The best Postgres branching platforms — A deeper comparison of the copy-on-write products, which win the short-lived-PR case.
- Throwaway Postgres for test suites — The test-run version of this question, where the container baseline wins and 30-90s is disqualifying.
- Preview environments with GitHub Actions — Where the two jobs in this post actually get wired into a pull-request workflow.
Related posts
- Top 9 Temporary PostgreSQL Database Platforms (2026)
A temporary Postgres is produced in one of four ways — a process on the runner, a schema inside somebody else's server, a copy-on-write branch, or a server of its own. The factory decides everything else, including whether your migration rehearsal means anything.
- Top 5 Scratch Postgres Platforms for CI (2026)
Before you shop for a disposable-Postgres product: a tuned postgres:16 container plus CREATE DATABASE ... TEMPLATE beats every platform on the board for unit and most integration tests. Five options for the cases where it genuinely doesn't, including the one where my own platform is the slowest thing here.
- Preview Environments on microVMs: a Live URL per PR
Every pull request gets its own live URL backed by a real backend and database — on a Firecracker microVM, so even untrusted forked-PR code is isolated by hardware, not by a shared kernel.
- How to run ephemeral test environments from GitLab CI
GitLab services get you a container on the same network. They do not get you a URL a reviewer can click, or a database with your production schema. Here is the setup that does.
- Top 7 Disposable Postgres Platforms for Developers (2026)
Three of the seven ways to get a disposable Postgres are not products you buy. Here are all seven shapes graded on time-to-DSN, isolation boundary, whether they branch real data, and the failure mode each one actually has.
More in Ephemeral databases · See Ephemeral Postgres databases on PandaStack
49ms p50 cold start. Fork, snapshot, and scale to zero.