all posts

Top 8 Throwaway Postgres Platforms for 2026

Ajay Kumar··11 min read

Your integration suite truncates tables between cases. That is a completely reasonable thing for an integration suite to do, and it stays reasonable right up to the afternoon somebody exports the wrong DATABASE_URL into a CI job and the truncation lands somewhere with customers in it. If you have never watched that happen, you have almost certainly inherited a guardrail that exists because it happened to someone else: a naming convention, a pre-flight check that greps the hostname, a comment in a config file in capital letters.

All of those are mitigations for a resource problem. There is one database and there are a dozen people who periodically need to destroy one. Every negotiation in the Slack channel titled "don't run migrations today", every revert of someone else's half-applied schema change, every test you wrote defensively so it would survive sharing a schema — all of it is overhead paid because destroying the database is expensive. The fix is not a better process. It is a database cheap enough that destroying it is the normal outcome rather than an incident.

This post is about where to get one. It is deliberately not another managed-Postgres buyer's guide and not another alternatives page — the question here is narrower and more useful: which platforms will hand you a real PostgreSQL you are allowed to break, one per pull request, per test run, per agent task, per demo, and what each one is actually doing under the hood to make that affordable. I build PandaStack, which has a managed Postgres in this category; it is entry eight, with its limitations stated rather than implied.

There are three mechanisms, and they are not interchangeable

"Disposable database" is a marketing category containing three genuinely different pieces of engineering. Almost every bad choice in this space comes from buying one mechanism while needing another, so grade everything against which one it is before you compare anything else.

  1. Container per test. docker run postgres, or Testcontainers driving the same thing from inside your test process. It is a real PostgreSQL binary, the version you pinned, started in a container on your laptop or your CI runner. It starts empty, it dies when the process dies, and and it costs a container's worth of RAM on whatever machine is running the tests.
  2. Copy-on-write branch off a real database. Storage-level branching: the platform keeps a multi-version page store, and a branch is a pointer into it at a chosen point in time. You get real data almost immediately because nothing is copied. The branch shares a storage layer with its parent, and usually sits on compute that the platform sizes and bills separately.
  3. A whole isolated instance, created on demand. A separate PostgreSQL with its own process, its own memory, its own disk. Slowest to create by a wide margin, because something has to actually initialise and start a database. In exchange, nothing it does can reach anything else — including the things you did not think to isolate, like shared_buffers, an extension that segfaults a backend, or a runaway query that eats the page cache.

There is a fourth shape worth naming because it confuses the taxonomy: copy-on-write applied at the machine or volume layer rather than inside a storage engine. You get outcome three — a fully separate instance — produced by mechanism two's trick. That is the shape PandaStack uses, and it inherits both the isolation of a real instance and the awkwardness of having to boot one.

The single most useful question to ask a vendor: when I drop a table in my disposable database, what is the complete list of things that could notice? If the answer involves a shared storage layer, a shared connection pooler or a shared control plane, that is fine — but it is a different product from "a Postgres of my own", and it should be priced and trusted differently.

What to grade before you look at logos

  • Time to a usable connection string — and whether the API blocks until the database is ready or returns immediately and makes you poll. These are different integration problems, and the second one is the one that breaks CI scripts written by optimists.
  • Does it carry real data or start empty? This is the single biggest fork in the road. See the section on it below, because it determines what class of test the database can possibly run.
  • Isolation unit. A schema in a shared cluster, a database in a shared cluster, your own compute on shared storage, or your own instance end to end. Each is a different blast radius.
  • What happens when it idles. Does it keep billing at full rate, auto-suspend, get reaped, or quietly accumulate until someone reads the invoice out loud in a planning meeting?
  • Point-in-time restore. Not for disaster recovery here — for forensics. "Give me the database as it was before the backfill" is the most valuable query a disposable database can answer.
  • Can a destructive test hurt anything else? Including the parent it was copied from, the other branches, and the shared pooler everybody connects through.
  • Teardown accounting. Who deletes it when the CI job is cancelled mid-run? Every disposable-resource system leaks, because the step that destroys the resource lives inside the job that can be killed.
  • Superuser reach. CREATE EXTENSION, ALTER SYSTEM, installing something unsigned from PGXN to see whether it works. If the point is a database you can break, a restricted role defeats it.

The eight

Everything said about another vendor below is qualitative and describes an architecture rather than a number, because this category changes fast and a latency or price figure written in October is wrong by spring. Check every claim against that vendor's current documentation before you make a decision on it.

1. Testcontainers and docker-compose — the honest baseline

This is first and not last because for a large fraction of test suites it is simply the right answer, and a platform decision made before trying it is a platform decision made too early. Mechanism one. You get the real PostgreSQL binary at the tag you pinned, started per test class or per suite, destroyed when the process exits. No API keys, no network, no bill, no vendor. Testcontainers gives you lifecycle management from inside the test; docker-compose gives you the same database with less ceremony and more manual cleanup.

The limitation is not performance, it is emptiness, and it is severe in one specific way. A migration that adds a NOT NULL column with a default runs in milliseconds against an empty table and holds a lock for several minutes against forty million rows. Your Testcontainers suite will go green and tell you nothing about the only property of that migration that matters. Use it for everything that is really a unit test wearing a database costume, and understand that it cannot answer questions about data you do not have.

2. Neon — the reference implementation of storage-level branching

Mechanism two, and the reason "database branching" is a phrase engineers use at all. Postgres compute separated from a multi-version storage layer, so a branch is a cheap pointer rather than a copy, created at a point in time, carrying real data. If your mental model of a disposable database is "instant and already populated", this is the architecture that put it there.

What to check in their docs rather than assume: how a branch's compute is sized and what it costs while nobody is querying it, whether and how fast branches suspend, what the limits are on branch count and on total storage across branches, and how a branch behaves when the parent is under real load — because the storage layer is shared, which is the whole trick and also the whole caveat. Pricing and branch limits here have moved more than once; read the current page, not a blog post about it.

3. Supabase — branching as part of a platform workflow

Supabase's branching is tied to the rest of the platform: the thing you get is closer to an environment than to a bare database, which is exactly right if your application already depends on Supabase auth, storage and edge functions, and is overhead if you only wanted Postgres. The git-integrated workflow is the selling point — a branch that tracks a pull request without you writing the orchestration.

Check what a branch actually includes beyond the database, what it costs while idle, and how migrations are applied to a branch versus to the parent, since the platform has opinions about that. Also check how you create and destroy one programmatically if your workflow is not GitHub-shaped — a branching feature designed around a git integration can be awkward to drive from an arbitrary script or an agent.

4. PlanetScale-style branch-and-deploy-request — read the engine name carefully

The branch plus deploy-request workflow that everything else in this list is imitating was built for MySQL on Vitess: branch the schema, open a deploy request, get a diff and a safety review, merge it into production without a blocking lock. It is a genuinely good design and it is the reason the whole category exists. PlanetScale has since been extending into Postgres.

So this entry comes with a specific instruction rather than a description: if you are evaluating them because you read about branching some years ago, confirm in their current docs which engine you would actually be running, and whether the Postgres product carries the same deploy-request and schema-diff machinery or a different model. Several of the features people remember most fondly were shaped by MySQL and Vitess specifically. Do not infer a Postgres capability from a MySQL reputation; the same caution applies to any MySQL-adjacent vendor whose branching story predates their Postgres offering.

5. Prisma Postgres — shortest path from the ORM

The distinguishing property is adjacency to the tool you already run migrations with. Provisioning a database from the same place you define your schema removes a genuine friction — the gap between "I need a database" and "migrations applied, client generated, tests pointed at it" is where most per-PR workflows actually die, and closing it is worth something.

Things to establish from their docs: how a disposable instance is created and destroyed programmatically, whether it idles down or bills continuously, and — the important one — whether a copy carries data or starts empty. An ORM-adjacent database that starts empty is a very convenient mechanism-one database, which is useful, but it is not the thing that tests your migration against production cardinality.

6. Crunchy Bridge or Aiven — the boring managed instance

Mechanism three with no cleverness attached, and the list needs this rung. You ask for a Postgres, you get a real Postgres, provisioned in provisioning-shaped time, with the operational surface and the extension and version breadth you would expect from vendors whose whole business is running databases properly. Nothing is shared with anybody. Restores work the way the manual says.

For a per-pull-request workflow that creates and destroys dozens a day, the create latency and the pricing model are both working against you, and you will end up building a pool of pre-provisioned instances, which is a warm pool, which is the thing you were trying to avoid. For "I need one real database for two weeks while I evaluate a major-version upgrade", it is the least surprising option here. Verify current version support and extension allowlists against their docs, because that is exactly where these vendors differentiate.

7. Platform-bundled Postgres — find out whose database it is

Hosting platforms offer a Postgres next to the deploy button, and it is usually somebody else's Postgres with the platform's billing and dashboard wrapped around it. Vercel's Postgres story, for instance, moved toward a marketplace model where the database you provision is a partner provider's. That is not a criticism — the integration is often excellent and the DSN lands in your environment variables with no work — but it has three consequences worth knowing before you design a disposable-database workflow on top of it.

  • Your branching capability is the underlying provider's branching capability, not the platform's. If the provider does storage-level branching you inherit that; if not, no amount of dashboard will produce it.
  • Your point-in-time window, extension allowlist and version support are all the provider's, and they can change when the brokering arrangement changes.
  • Your support path has two companies in it, which matters precisely once, on the day it matters a great deal.

Establish whose engine it is before anything else, then go read that vendor's documentation and ignore the wrapper. Brokered arrangements get rearranged, so verify this one against current docs more carefully than any other entry on this list.

8. PandaStack managed Postgres — a dedicated microVM per database

Mechanism three, produced by mechanism two's copy-on-write trick at the machine layer. Each managed database is a dedicated Firecracker microVM with a durable volume attached — not a schema in a shared cluster, not a role, not a namespace inside a storage layer that several customers' pages flow through. The data lives on that volume, separate from the VM's ephemeral root filesystem, so a hibernate, a wake or a host reboot does not touch it.

What that buys: there is no interesting answer to "what else could notice if I drop this table", because the answer is a kernel boundary. You can CREATE EXTENSION, you can test an extension that crashes backends, you can test a change that would have been irresponsible on anything shared, and the worst case is one VM you were going to delete anyway. You also get a Linux machine next to the database, so an agent or a CI job can exec a migration CLI inside the same box rather than reaching it over a connection string.

The mechanics, precisely. POST /v1/databases with an optional label and an optional size returns 202 immediately with status "provisioning" — it does not block — and you poll GET /v1/databases/{id} until it reports running with connection details. From the call to a usable connection string is 30 to 90 seconds, because a real PostgreSQL is genuinely initialising and starting inside a fresh microVM. POST /v1/databases/{id}/clone creates a NEW database id from the source's archive, with an optional target_time in RFC3339 for point-in-time restore and an optional size to land the copy on a different RAM tier; the source is never touched, because a clone is rebuilt from the archive rather than read off the live instance. POST /v1/databases/{id}/branch forks a running parent on the parent's own host by reflinking its live volume — warm by default, meaning the parent is memory-forked so the branch's Postgres comes up with a hot cache instead of a cold one. A branch takes no target_time; that is what clone is for. Credentials on a running database rotate synchronously via POST /v1/databases/{id}/reset-credentials.

Idle behaviour is the part that makes per-PR economics work. After roughly fifteen minutes with no client connections the database suspends and compute billing stops; the next connection through the proxy transparently restores the VM and proceeds, fast enough that a pooled client's normal retry absorbs it, though a single bare psql may need one reconnect. Set always_on at create to opt out. Billing is one rate card for every workload class: $0.054 per vCPU-hour and $0.0162 per GiB-hour, with CPU charged on active CPU-seconds actually burned and memory on committed GiB-hours. Egress is metered.

Now the limitations, because this is the paragraph that is worth your trust. Thirty to ninety seconds is slower than a storage-level branch by a wide margin, and if your workflow creates a database on every test case rather than every pull request, that is disqualifying — use mechanism one for that and reach for this when you need real data. RAM tiers are 1g, 4g and 16g selected by size at create, and size is create-time only: Firecracker cannot change a guest's vCPU or RAM at snapshot restore, so "resizing" is really "clone into a different tier", which is a supported path but not an in-place one. The engine is PostgreSQL 16. There are no read replicas yet, so there is no cross-region read scaling story. Managed databases are in public beta: create, list, get and delete are stable, backups with point-in-time recovery work, and the advanced end of the roadmap is still the roadmap.

Mechanism first, logos second. Verify every competitor row against that vendor's current docs — branching models, idle behaviour and limits all move.
PlatformMechanismCarries real data?Isolation unitWhen idle
Testcontainers / docker-composeContainer per testNo — starts emptyContainer on your runnerDies with the test process
NeonStorage-level CoW branchYes, from a point in timeOwn compute, shared storageSuspends — check plan details
SupabaseBranch tied to platform workflowYesBranch environment in platformVaries by plan
PlanetScale-styleBranch plus deploy requestYesDepends — confirm the engineVaries by plan
Prisma PostgresManaged instance, ORM-adjacentDepends how you copy itManaged databaseCheck current docs
Crunchy Bridge / AivenWhole managed instanceOnly if you restore into itOwn instanceKeeps running, keeps billing
Platform-bundled (Vercel-style)Resold provider underneathInherits the provider's modelThe provider's modelThe provider's model
PandaStackDedicated microVM; clone or branchYes — clone or branch carries itOwn VM, own durable volumeAuto-suspends, wakes on connect

The trap: "it starts empty" decides what you are allowed to test

This is the distinction that most comparison tables bury in a footnote, and it is the one that determines whether your disposable database can test a migration or only a query. There are three ways to put data in one, and they are not three points on a spectrum — they are three different risk profiles.

Seeding. You write fixtures. They are deterministic, they live in version control, they review like code, and they contain exactly the data shapes you imagined. They will never contain the row with the NULL in the column your migration assumes is populated, or the customer whose name broke your CSV export in 2023, or the table whose index is forty percent bloat. Seeding is honest and it is also a closed world: it can only catch bugs in code, never bugs in the collision between your code and reality.

Cloning. A copy of a real database, with real cardinality, real index state and real lock durations. This is the only option that answers "will this migration finish before the deploy timeout", which is the question that actually causes incidents. If you are choosing a platform specifically to test migrations, you are choosing a cloning mechanism and everything else is secondary.

Anonymised production snapshots. Now the uncomfortable part, stated plainly because the industry mostly does not. Standing up an anonymisation pipeline means you have built a system that copies customer data into an environment created automatically per pull request, reachable by every CI job in the repository, with credentials sitting in job environment variables, and the anonymisation itself is a script somebody wrote once that does not know about the column added last Tuesday.

The realistic failure mode is not an attacker. It is a debug step. A CI log is a document published to everyone with repository read access, and one well-intentioned `psql -c 'select * from users limit 5'` added while chasing a flaky test turns an anonymisation gap into a disclosure you have to go and report. Anonymise at the source, never in the per-PR environment, and treat the anonymised copy as production data until you have evidence rather than intent.

The ladder that actually works: seed for unit tests, because they are fast and bounded; clone a real database for migration tests and keep the clone short-lived and access-controlled; and if you must anonymise, do it once in a controlled job, verify the output against the live schema on every run so a new column fails the pipeline rather than silently flowing through, and accept that you now own a second copy of the thing your auditors ask about.

Creating and destroying one per pull request

The shape below is the one that survives contact with CI. Create, trap the teardown on every exit path, poll because the create call returns before the database is ready, and make the label reconstructable from the pull request so that the cleanup job you will inevitably need can find what the trap missed.

#!/usr/bin/env bash
# Throwaway Postgres for one pull request: create, test destructively, delete.
set -euo pipefail

PR="${PR_NUMBER:?set PR_NUMBER}"
API="https://api.pandastack.ai"
auth=(-H "Authorization: Bearer $PANDASTACK_API_KEY" -H 'Content-Type: application/json')

# POST /v1/databases returns 202 immediately with status "provisioning".
# The label is the only handle you get to choose, so make it reconstructable
# from the PR number -- you will need it to find the ones you leak.
DB_ID=$(curl -fsS -X POST "$API/v1/databases" "${auth[@]}" \
  -d "{\"label\": \"pr-$PR\", \"size\": \"1g\"}" | jq -r '.id')
echo "database=$DB_ID"

# Delete on ANY exit path: a green run, a segfaulting test binary, and the
# SIGTERM you get when someone hits Cancel in the CI UI. The trap covers the
# first signal; it does NOT cover SIGKILL, which is why the reaper below
# exists and is not optional.
trap 'curl -fsS -X DELETE "$API/v1/databases/$DB_ID" "${auth[@]}" >/dev/null || true' EXIT INT TERM

# Poll until Postgres is actually ready. 30-90s: a real postgres is
# initialising inside a fresh microVM, not a row appearing in a table.
STATUS="" ; DB_URL=""
for _ in $(seq 1 40); do
  read -r STATUS DB_URL < <(curl -fsS "$API/v1/databases/$DB_ID" "${auth[@]}" \
    | jq -r '[.status, (.connection_url // "")] | @tsv')
  if [ "$STATUS" = "running" ] && [ -n "$DB_URL" ]; then break; fi
  if [ "$STATUS" = "error" ]; then echo "provision failed" >&2; exit 1; fi
  sleep 5
done
if [ "$STATUS" != "running" ]; then echo "timed out waiting for postgres" >&2; exit 1; fi

export DATABASE_URL="$DB_URL"

# Everything below is allowed to be destructive, because the blast radius is
# one microVM that is getting deleted either way.
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f db/schema.sql
npm run migrate
npm run test:integration   # truncates between cases; nobody has to be warned

Two details in there are the whole point. The poll loop exists because the create call is asynchronous — a script written against an API you assumed was synchronous fails in the most annoying way possible, by handing your test suite an empty connection string. And the trap is on EXIT, INT and TERM rather than just on success, because the interesting failure is not a failing test, it is a cancelled job.

The forensic move: clone to a point in time

The highest-value thing a disposable database does has nothing to do with CI. Someone ran a backfill, it set a large number of rows to the wrong value, and the question in the incident channel is what the data looked like before. Point-in-time cloning turns that from an argument into a diff.

#!/usr/bin/env bash
set -euo pipefail
API="https://api.pandastack.ai"
auth=(-H "Authorization: Bearer $PANDASTACK_API_KEY" -H 'Content-Type: application/json')

db_url() { curl -fsS "$API/v1/databases/$1" "${auth[@]}" | jq -r '.connection_url'; }
wait_running() {
  for _ in $(seq 1 40); do
    s=$(curl -fsS "$API/v1/databases/$1" "${auth[@]}" | jq -r '.status')
    if [ "$s" = "running" ]; then return 0; fi
    if [ "$s" = "error" ]; then return 1; fi
    sleep 5
  done
  return 1
}

# "The 14:05 backfill set 1.2M orders to the wrong tenant_id."
# Clone production as it existed BEFORE the backfill, into a NEW database id.
# The source is never touched: a clone is rebuilt from the archive, not read
# off the live instance. target_time is RFC3339 and must be at least two
# minutes in the past -- the WAL has to have reached the archive first.
CLONE_ID=$(curl -fsS -X POST "$API/v1/databases/$PROD_DB_ID/clone" "${auth[@]}" \
  -d '{
        "label": "pre-backfill-forensics",
        "target_time": "2026-10-02T14:00:00Z",
        "size": "4g"
      }' | jq -r '.id')
wait_running "$CLONE_ID"

# Diff the two. Separate database, separate credentials: this comparison
# physically cannot write to production, which is the property you want at
# 11pm with four people reading over your shoulder.
psql "$(db_url "$CLONE_ID")"   -Atc "select tenant_id, count(*) from orders group by 1 order by 1" > before.txt
psql "$(db_url "$PROD_DB_ID")" -Atc "select tenant_id, count(*) from orders group by 1 order by 1" > after.txt
diff -u before.txt after.txt || true

# When you want a fast dev copy rather than a specific moment, branch a
# RUNNING parent instead: it reflinks the live volume on the parent's own
# host and is warm by default (the parent is memory-forked, so the branch's
# Postgres comes up with a hot cache). No target_time on a branch.
curl -fsS -X POST "$API/v1/databases/$PROD_DB_ID/branch" "${auth[@]}" \
  -d '{"label": "dev-copy"}' | jq -r '.id'

# Delete the forensic clone. This is the step that does not happen by itself.
curl -fsS -X DELETE "$API/v1/databases/$CLONE_ID" "${auth[@]}"
The two-minute floor on target_time is not arbitrary and every archive-based system has some version of it: the write-ahead log has to physically reach the archive before anyone can replay to a moment inside it. If you ask for thirty seconds ago, the honest answer is a 400, and a platform that cheerfully accepts it is either streaming synchronously or quietly giving you a different point in time than the one you asked for.

Every disposable-resource system leaks

The teardown trap above runs inside the job it is cleaning up after. A cancelled job gets SIGTERM and then SIGKILL, a runner can be reclaimed mid-step, and a network partition between your script and the API is indistinguishable from a successful delete. So the trap is the common case and a reaper is the invariant. Without one you will discover the leak the way everyone does: as a line item.

Auto-suspend makes a leaked database cheap rather than free — compute stops but storage does not, and a forgotten database is still a credential in a system somebody will eventually audit. Run the reaper on a schedule and give it a hard rule about which labels it owns, so it never deletes a database some human created by hand.

"""Reap throwaway databases whose pull request is no longer open.

Run hourly. The per-job teardown trap handles the normal path; this handles
SIGKILL, reclaimed runners, and the network partition that looked like a
successful DELETE.
"""

import os
import time

import requests

API = "https://api.pandastack.ai"
REPO = os.environ["GITHUB_REPOSITORY"]          # "acme/web"
MIN_AGE_SECONDS = 15 * 60                        # never touch one still provisioning
OWNED_PREFIX = "pr-"                             # the ONLY labels this job may delete

ps = requests.Session()
ps.headers["Authorization"] = f"Bearer {os.environ['PANDASTACK_API_KEY']}"
gh = requests.Session()
gh.headers["Authorization"] = f"Bearer {os.environ['GITHUB_TOKEN']}"

# GET /v1/databases -> {"items": [...], "count": n}
items = ps.get(f"{API}/v1/databases", timeout=30).json()["items"]

for db in items:
    label = db.get("label") or ""
    if not label.startswith(OWNED_PREFIX):
        continue  # somebody's real database. Not ours. Walk away.

    created = db.get("created_at") or 0
    if created and time.time() - created < MIN_AGE_SECONDS:
        continue  # a job may still be polling for this one

    pr = label[len(OWNED_PREFIX):]
    r = gh.get(f"https://api.github.com/repos/{REPO}/pulls/{pr}", timeout=30)
    if r.status_code == 404:
        state = "closed"           # squashed and branch deleted: safe to reap
    elif r.ok:
        state = r.json()["state"]
    else:
        continue                   # GitHub is having a day; try again next hour

    if state == "open":
        continue

    resp = ps.delete(f"{API}/v1/databases/{db['id']}", timeout=60)
    print(f"reaped {db['id']} ({label}) -> HTTP {resp.status_code}")

What to actually pick

  • Unit tests and anything that only needs a schema: mechanism one. Testcontainers, empty, per suite. Do not buy a platform for this.
  • Migration tests and PR preview environments: a clone of a real database, created per pull request and torn down on close. This is the only rung where data realism is the product, and it is where storage-level branching and VM-level cloning actually compete.
  • Anything an agent drives: a whole isolated instance. An agent will do something you did not enumerate — that is why you hired it — and the containment story should be a kernel boundary rather than a role grant.
  • Demos, customer trials, and the database you hand a new engineer on day one: a whole instance with idle auto-suspend, so forgetting about it costs storage rather than a monthly bill.
  • The shared staging database: keep it, but demote it. Once destroying a database is cheap, staging stops being a scarce resource under negotiation and becomes what it should have been — one more environment, no more sacred than the others.

The honest summary across all eight is that nobody is best at this, because the three mechanisms trade against each other in ways no engineering can collapse. Instant plus real data means sharing a storage layer. Full isolation means waiting for a database to start. Free and local means empty. Pick the mechanism that matches the question you are actually asking, and be suspicious of any product that claims all three properties at once without telling you which one it quietly relaxed.

Frequently asked questions

What is the difference between a database branch and a disposable database?

A branch is a specific implementation of a disposable database, and the distinction matters for blast radius. Storage-level branching keeps a multi-version page store under a Postgres compute layer, so a branch is a pointer into that store at a chosen point in time rather than a copy — which is why it appears almost instantly with real data in it. A disposable instance is a whole separate PostgreSQL with its own process, memory and disk, created on demand and destroyed when you are done. The practical consequence is what your destructive test can reach. In a branch, SQL-level damage is contained but you are still sharing a storage layer and, on some platforms, a pooler and a control plane with the parent. In a separate instance, there is no shared component below your SQL at all, which is what makes it safe to test an extension that crashes backends or a setting that would be reckless anywhere shared. Branches win on speed; instances win on isolation and on the range of things you are allowed to break.

Can I test a database migration against a Testcontainers Postgres?

You can test that it applies without a syntax error, and that is genuinely worth automating. You cannot test the property that causes incidents. A migration adding a NOT NULL column with a default, rewriting a table, or building an index completes in milliseconds against an empty table and holds a lock for minutes against tens of millions of rows — and the empty-table run gives you no signal about which it will be. The same gap applies to anything involving query plans, since the planner's choices depend on statistics that an empty database does not have, and to anything involving constraint violations in existing data, which by definition does not exist yet. The working split is to run migrations against a container in CI as a syntax and ordering check on every commit, and separately to run them against a clone of a real database — a branch, a point-in-time clone, or a restored copy — before anything reaches production. The second check is the one that answers whether the deploy finishes.

How do I stop per-PR databases from leaking and running up a bill?

Assume the per-job teardown will fail, because it lives inside the job that can be cancelled. Trap the delete on EXIT, INT and TERM so the normal paths are covered, then run a scheduled reaper that lists databases, filters to a label prefix the reaper exclusively owns, checks whether the corresponding pull request is still open, and deletes the ones that are not. Two guardrails make the reaper safe to leave running unattended: a minimum age so it never deletes something a job is still polling for, and a strict label prefix so it can never touch a database a human created by hand. Platforms with idle auto-suspend reduce the cost of a leak — on PandaStack a database suspends after roughly fifteen minutes without client connections and wakes on the next one — but suspend is not delete. Storage still bills, and a forgotten database is still a live credential sitting in somebody's secret store, which is a problem that outlives the invoice.

Is it safe to put real production data in a database created per pull request?

It is safe only if you treat that database as production, which most teams do not, because the whole appeal of a disposable environment is lower ceremony. Be concrete about what you are building: customer data, copied automatically, into an environment that every CI job in the repository can reach, with credentials in job environment variables, and logs readable by everyone with repository access. The realistic failure is not exfiltration — it is a debug statement. Someone chasing a flaky test adds a query that prints a few rows, the job log keeps it, and now you are reading a disclosure policy. If you need realistic data, prefer a clone or branch of a real database that is short-lived and access-controlled over an anonymised copy that spreads. If you do anonymise, do it once at the source rather than in the per-PR environment, and validate the output against the live schema on every run so a column added last Tuesday fails the pipeline instead of flowing straight through it.

How fast is a PandaStack disposable Postgres, and what are its limits?

From the create call to a usable connection string is 30 to 90 seconds. The API itself does not block: POST /v1/databases returns 202 with status "provisioning" and you poll GET /v1/databases/{id} until it reports running with connection details. The time is real work — a PostgreSQL initialising and starting inside a fresh Firecracker microVM — which makes it substantially slower than a storage-level branch, and that is the honest trade for each database being a separate machine with its own durable volume rather than a namespace in shared storage. A branch off a running parent reflinks the parent's live volume on the parent's host and comes up warm, which is the faster path when you want a dev copy rather than a specific point in time. Limits worth knowing before you commit: the engine is PostgreSQL 16; RAM tiers are 1g, 4g and 16g chosen by size at create and are create-time only, because Firecracker cannot change a guest's RAM at snapshot restore, so changing tier means cloning into a new one; and there are no read replicas yet. Managed databases are in public beta.

Keep reading

Related posts

More in Ephemeral databases · See Ephemeral Postgres databases on PandaStack

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.