all posts

Top 6 Throwaway Postgres Platforms for Testing in 2026

Ajay Kumar··11 min read

Every test suite that touches Postgres has, somewhere in its history, a commit that added a sleep. Not a wait-for-port loop, not a readiness probe — a literal sleep 5, with a comment saying it fixes the flake, written by somebody who had already spent an hour on it and had three other things to do. It is still there. It runs on every pull request, it costs five seconds times the number of jobs times the number of pull requests, and it is load-bearing in the sense that nobody dares remove it because once, in some configuration that may no longer exist, the suite failed without it.

That sleep is the visible scar of a question nobody answered on purpose: where does the database for the test suite come from, and who is responsible for it being gone afterwards. This post is about answering that deliberately. It is specifically about the automated case — a suite, running unattended, N jobs wide, in the critical path of every merge. That turns out to be a genuinely different problem from "give a developer a database to poke at", and the axes that decide it are different too.

There is a companion roundup on this site, published yesterday, that grades throwaway Postgres platforms for the general case — the human-exploration case, where somebody wants a real database with real data they are allowed to break. It grades by mechanism: container, copy-on-write branch, or whole dedicated instance. That one is linked first at the bottom and is the better read if you are picking a platform. This one narrows to the test suite, where the deciding properties are parallel isolation, seed cost amortisation, and what happens when a job is cancelled at 40% — none of which the general roundup needs to care about.
Disclosure: I'm Ajay, I build PandaStack, and PandaStack is entry six. In a roundup I hold myself to this: concrete numbers only for my own product, everything else described qualitatively from public documentation with no invented latency or pricing, and a standing instruction to check anything I say about another platform against its current docs. I also give PandaStack the worst verdict in this post on the axis that matters most here, because it is the truthful one and you would find out in week two anyway.

Why the test-suite case is a different question

When a human wants a disposable database, the thing they care about is whether it has real data in it. An empty Postgres is almost useless for exploration: you cannot reproduce the customer's bug, you cannot see whether the migration is slow, you cannot check whether the query plan survives contact with ten million rows. So the general roundup grades hardest on "does it carry real data", and copy-on-write branching wins.

A test suite wants almost the opposite. It wants a database whose contents are exactly what the fixtures say and nothing else, because a test that passes or fails depending on what data happened to be lying around is not a test, it is a mood. What a suite needs instead is for that known-state database to exist N times simultaneously, cheaply, with teardown that works even when the process is killed. Real production data in the loop is usually a liability in the automated case rather than a feature — it makes failures unreproducible and it puts personal data on a CI runner.

So the grading changes completely, and six axes decide it.

The six axes that actually decide it

  1. Time to first query, from nothing. Not to "container started" — to the first successful SELECT 1. This sits in the critical path of every CI run, so it is additive to your whole merge latency, and it gets multiplied by however many jobs your matrix has. Measure it on a cold runner and a warm one; the two numbers are usually very far apart and the cold one is the one that bites on a Monday morning.
  2. How the schema and the seed get in, and whether you pay for it per run or once. This is the axis that most often dominates in practice and the one most comparison tables ignore. Running your full migration history against a fresh database on every job is a cost that grows monotonically with the age of your project, which means your CI gets slower every quarter for reasons unconnected to your test suite.
  3. Parallel isolation, and specifically which unit. Database per test, schema per test, transaction rollback per test, or a whole instance per job. These are not interchangeable and each of them breaks on a specific, nameable list of things. See the next section — this is where most of the real pain lives.
  4. Fidelity to the Postgres you ship on. Major version, extensions, collation, fsync, max_connections, whether you can create a role or install an extension. A test suite's job is to tell you about production, and it can only do that to the extent the test database resembles production.
  5. Teardown, and leak behaviour when the run is cancelled mid-way. The normal path is easy and everybody gets it right. The interesting path is a cancelled workflow, a reclaimed spot runner, or an out-of-memory kill — all of which skip your cleanup code entirely. Whatever you choose, assume the cleanup did not run and ask what state that leaves.
  6. Cost shape at your actual concurrency. The per-database cost is uninteresting; the shape is what matters. Does it cost nothing because it is riding on runner minutes you already bought? Does it cost per database-hour, multiplied by your matrix width? Does it have a concurrency cap that turns into a queue, which turns into merge latency?

Isolation: four units, four specific failure modes

Pick this before you pick a platform, because it constrains the platform more than the platform constrains it. All four of these are legitimate; what matters is knowing exactly what each one cannot test.

Transaction rollback per test

Open a transaction before each test, roll it back after. It is by far the fastest option — no database creation, no truncation, and the rollback is close to free. It is also the one with the longest list of things it quietly cannot do, and the list is worth memorising because every item on it shows up as a confusing flake rather than an obvious failure.

  • Any code under test that issues its own COMMIT. Your framework wraps the test in a transaction; the code commits inside it; now you are in savepoint-emulation territory, and the moment that emulation is imperfect you get a test that passes while the production code path is wrong.
  • LISTEN/NOTIFY. Notifications are delivered at commit. Roll back and they never arrive, so a worker that is supposed to wake up on a notify simply does not, and the test either hangs or asserts on an empty queue.
  • Sequences. nextval() is intentionally non-transactional: a rolled-back test still consumed its sequence values. Assert on a literal primary key and your test passes in isolation and fails when something else runs first. This is the single most common cause of "it only fails in CI".
  • Deferred constraint triggers and anything else that fires at COMMIT. If the behaviour you are testing happens at commit time, a test that never commits never tests it.
  • Anything needing a second connection. A background worker, a pooled second session, a LOCK-contention test — none of them can see your uncommitted data, so they see an empty database and behave accordingly.
  • CREATE INDEX CONCURRENTLY, VACUUM, CREATE DATABASE, ALTER SYSTEM: all of them refuse to run inside a transaction block. So a migration test cannot use this strategy at all if any migration uses CONCURRENTLY — which, if you have ever added an index to a large table without downtime, it does.

Schema per test

Create a schema, set search_path, let tests commit freely. This fixes the COMMIT problem, which is the big one, and it is cheap. What it does not fix: search_path is advisory, not a sandbox. Any schema-qualified name in your code or your migrations — public.users, a SECURITY DEFINER function with its own search_path, an extension installed into a fixed schema — escapes the box and lands in the wrong place. Shared sequences, shared extensions and anything reading pg_stat_* still see one database. Migrations that assume they own public are a particular landmine.

Database per test, or per parallel worker

The honest default. Real COMMIT semantics, real sequences, real notifications, real everything, with a hard boundary. The cost is a CREATE DATABASE per unit, which is why the unit should usually be the parallel worker rather than the individual test: one creation per worker at session start, then a cheap truncate-and-restart-identity between tests inside it. That gets you correct commit behaviour at roughly the price of the rollback strategy.

The trick that makes this cheap is CREATE DATABASE ... TEMPLATE, which is a filesystem copy of an already-migrated, already-seeded database's data directory. It copies the rows, the indexes and the planner statistics in pg_statistic — so a template clone plans like the seeded database did. That last part is underrated: a database you truncate and reseed without an ANALYZE has stats that describe the empty version, and that is one real source of "the query is fast in tests and slow in production". The catch is that CREATE DATABASE refuses to run if any other session is connected to the template, and a connection pooler holding idle sessions open will trip that for you at the worst moment.

A whole instance per job

The only option that tests the things above the database level: CREATE ROLE and permission grants, ALTER SYSTEM, installing an extension, creating a replication slot, logical decoding, crash recovery, connection limits. If your product has a multi-tenant permission model enforced by Postgres roles, or you ship anything that touches replication, this is not a luxury — the other three strategies structurally cannot test it. It is also the only one where "the test suite corrupted the database" has no blast radius.

Isolation units and what each one structurally cannot test. Pick the cheapest row that covers what you actually need to assert.
UnitCost per unitReal COMMIT?Cannot test
Transaction rollbackNearly zeroNoCommit-time behaviour, NOTIFY, sequences, second connections, CONCURRENTLY
Schema per testLowYesSchema-qualified code, shared sequences and extensions, migrations that own public
Database per workerOne CREATE DATABASEYesRoles, ALTER SYSTEM, extension installation, replication
Instance per jobA provisionYesNothing at the database level — you pay for it in time

The six

Numbered for the headline. Not a ranking — the right answer genuinely inverts depending on which of the six axes is non-negotiable, and I have ordered these from least to most machinery. Two of the six are not products at all, which is deliberate: the unglamorous options win more of these arguments than vendor comparisons admit.

1. A plain postgres container in your CI provider's service block

What it is: the services: block in a GitHub Actions workflow, or the equivalent in GitLab CI, Buildkite or CircleCI. You name an image, the provider starts it alongside your job on the same network, and it dies with the job. No API, no credentials to manage, no vendor.

Genuinely best at: teardown and leak behaviour, which it gets perfectly right for free. The container's lifetime is the job's lifetime, enforced by the provider, so a cancelled run leaks exactly nothing. Nothing else in this list can say that without you writing code. It is also free in the sense that matters — it rides on runner minutes you have already bought — and the cost shape is flat no matter how wide your matrix goes.

Where it bites: it starts empty, so you pay your full migration history plus your seed on every single job. That is the cost that grows with the age of your project. The time-to-first-query is dominated by the image pull on a cold runner and by initdb plus your readiness wait on a warm one — measure both in your own CI, because the gap is large and runner-dependent. Fidelity is whatever the image gives you: check the collation and the extension list rather than assuming, because an Alpine-based image at C collation sorts text differently from a glibc en_US.UTF-8 production database, and that difference shows up as ORDER BY assertions that are quietly testing the wrong sort.

Verdict: the correct default, and the thing you should have to argue your way out of. If your migrations run in a few seconds and your fixtures are small, stop reading and use this.

2. Testcontainers

What it is: the same container, but its lifecycle lives in your test code rather than in your CI configuration. The suite starts the database itself, waits for a declared readiness condition, hands your tests a connection string on a random port, and stops it at the end. The point is that it runs identically on a laptop and in CI.

Genuinely best at: the leak problem, solved properly. Testcontainers ships a reaper sidecar that holds a connection to your test process and destroys the containers when that connection drops — which means a SIGKILL, a debugger you quit, or a cancelled job still gets cleaned up. That is the one piece of engineering in this whole category that most hand-rolled solutions skip, and it is the reason to prefer it over a bash script that starts docker run. Its ability to start the rest of your dependency graph with the same mechanism is the other reason.

Where it bites: it needs a Docker socket inside your CI job, which is a real constraint on a hardened runner and an awkward one inside a container-based executor. It still starts empty, so axis two is unimproved — the container reuse and image-with-baked-data patterns exist to address that, and are worth reading their current docs on. And at high parallelism you are starting many Postgres instances on one runner, which turns the runner's memory and page cache into the bottleneck; a suite that is fast at four workers can thrash at sixteen.

Verdict: the right upgrade from entry one when you want the same database story locally and in CI, and the right answer if your dependency graph has more than one service in it.

3. A deliberately unsafe local Postgres: tmpfs, fsync=off, template databases

What it is: the unglamorous option, and the fastest one on this list. Run Postgres with its data directory on a tmpfs, with fsync=off, full_page_writes=off and synchronous_commit=off, inside the CI job. Tools in this space wrap the pattern — pg_tmp from the ephemeralpg family, the various embedded-postgres libraries — but it is a handful of initdb flags and you can do it yourself.

Genuinely best at: raw speed, by a wide margin, because every write your test suite does stops touching a disk. A test suite is almost pure small-transaction write churn, which is exactly the workload fsync punishes most. Combine it with a template database built once per job and the per-worker setup cost is a file copy in RAM. This is the configuration people are describing when they say their integration suite runs in under a minute.

Where it bites: you have deliberately broken durability, so the one thing you must not do is believe anything this database tells you about crash behaviour. Any test that asserts on recovery, WAL, replication or what survives a kill -9 is now testing a fiction and should be run somewhere else. The tmpfs also means your test data competes with your test process for the runner's RAM, which fails in an interesting way: the suite does not slow down, it gets OOM-killed. And you own the whole thing — the initdb flags, the version pinning, the extension installation, the cleanup.

Verdict: the highest-leverage change available to most teams, and almost nobody does it. If your suite is slow and your tests do not care about durability — which is nearly all tests — this is where the minutes are.

4. A branching service, driven from CI

What it is: a managed Postgres whose storage layer does copy-on-write, so creating a database that already contains your schema and your data is a metadata operation rather than a copy. Neon is the reference implementation of this and Supabase wraps the idea into a platform workflow; several others have shipped something branch-shaped and the mechanics differ enough that you have to read each one. Your CI job creates a branch of a prepared parent, runs against it, and deletes it.

Genuinely best at: axis two, decisively. The schema and the seed are in the parent, so the per-job cost of having them is close to nothing, and it does not grow as your migration history grows. For a project with a long migration history and a substantial seed, this is the axis that dominates everything else, and branching is the only mechanism here that really solves it.

Where it bites: the branch is an API call with a lifetime you now own, so axis five is entirely your problem — a cancelled job leaks a branch, and the leak is a billable object rather than a container that dies on its own. Budget for a label convention and a scheduled reaper from day one. Fidelity needs reading rather than assuming, because a storage layer that is not plain local Postgres files is exactly where extension support and version availability differ. Check what happens to branches when idle, what the concurrency limits are, and whether branch creation is rate-limited at your matrix width — a limit you hit becomes a queue, and a queue becomes merge latency. Verify all of it against their current docs; this part of the market moves fast.

Verdict: the strongest option when the seed is big and the migration history is long, and the one that requires the most lifecycle discipline from you.

5. One long-lived managed instance, database-per-job inside it

What it is: the option that wins more of these arguments than it gets credit for. Keep one ordinary managed Postgres — RDS, Crunchy Bridge, Aiven, Cloud SQL, or a box you own — alive for the repository. CI does not provision anything. It runs CREATE DATABASE ci_<run-id> TEMPLATE app_template, runs against it, and drops it.

Genuinely best at: axis one, and nothing else comes close. The instance already exists, so time-to-first-query is a CREATE DATABASE — a file copy of the template's data directory inside a server that is already running and already warm. There is no provisioning, no initdb, no image pull and no cold cache. Axis two is solved too, because the template holds the migrated schema and the seed. The arithmetic is also pleasant: one instance, flat cost, no multiplication by matrix width.

Where it bites: it is a shared, long-lived resource, which is the thing everything else in this list is trying to get away from. One suite's runaway query is every suite's problem. max_connections is a global budget that your matrix width spends, so this needs a connection-limit plan before it needs anything else. Leaked databases accumulate silently inside a healthy-looking instance until somebody notices a thousand of them. And it cannot test anything instance-level: no CREATE ROLE assertions, no ALTER SYSTEM, no extension installation, no crash recovery.

Verdict: the best latency-per-dollar on this list, and the right answer for a large fraction of teams who think they need something more sophisticated. The sophistication you are buying elsewhere is mostly isolation you may not need.

6. PandaStack managed Postgres — a dedicated microVM per database

What it is: mine, so read the previous disclosure. Each managed database is a dedicated Firecracker microVM running PostgreSQL 16 on its own durable volume, with its own PgBouncer and a REST query broker. You get root-level isolation between databases because they are not sharing a Postgres server — or a kernel.

The honest number first, because it is the one that decides whether this fits your suite: a fresh create takes 30 to 90 seconds of wall clock to become usable. The API returns 202 immediately with status "provisioning" and an id, and the client polls — it does not block, which is a deliberate choice because a synchronous 90-second response behind a CDN gets its connection cut and leaves you with an orphan you never learned the id of. But 202 does not make the 90 seconds go away. It is the wrong shape for creating a database per test, and it is the wrong shape for creating one per job in a wide matrix where you would pay it in parallel and still wait for the slowest. It is the right shape for one database per CI workflow, or per branch, or per long-lived preview environment, held for minutes to days.

Genuinely best at: fidelity and blast radius. The template bakes a real PostgreSQL 16 cluster initialised at en_US.UTF-8 with UTF8 encoding, wal_level = logical, max_connections = 200 and shared_buffers sized to the tier, with uuid-ossp, pgcrypto, pg_stat_statements, pg_trgm, ltree, hstore, unaccent and pgvector already installed. fsync is left at its default, which is to say on — slower than entry three and faithful in the way entry three deliberately is not. You can CREATE ROLE, you can install extensions, you can test crash recovery, and a test suite that corrupts the database corrupts a database nothing else is using. For the instance-per-job row of that isolation table, this is a straightforward fit.

The branch endpoint is the interesting part for testing. POST /v1/databases/{id}/branch reflink-copies a running parent's live volume inside a brief pause window on the parent's own host, and is warm by default — the child is memory-forked from the parent, so it starts with hot shared_buffers rather than a cold cache. {"warm": false} gives you the cheaper disk-only variant. That is the axis-two answer: keep one migrated and seeded parent, branch it per run. Three constraints you need before you plan around it: the parent must be running on a live host or you get a 409 telling you to wake it; the branch is pinned to the parent's host, so N concurrent branches is an N-on-one-host capacity question rather than a fleet-wide one; and there is no branch() method in either SDK yet, so this is an HTTP call you make yourself.

Where it bites, beyond the 30 to 90 seconds: managed databases are created with the sandbox persistent flag set, specifically so the idle reaper can never delete one — deleting the VM would destroy the durable volume. The consequence for CI is blunt. Nothing expires. A database your cancelled workflow forgot about will still be there, and still billable, in a month. You need the label convention and the reaper, and you need them before your first wide matrix run, not after. The RAM tier is also fixed at create time — 1g, 4g or 16g — because Firecracker cannot resize a snapshot-restored guest; changing tier means cloning into a new id. And the separate clone endpoint, not branch, is the one that takes target_time for point-in-time restore.

Verdict: strong for per-workflow and per-branch databases where fidelity and isolation matter, and genuinely wrong for per-test creation. If your need is a database per test case, entries three and five are better answers and I would rather you used them than had a bad week with mine.

Throwaway Postgres for the test suite, graded on the six axes. The PandaStack row is specific; every other row is qualitative and should be checked against that project's or vendor's current documentation.
OptionTime to first querySeed costNatural isolation unitLeak on cancelled runCost shape at matrix width
CI service containerImage pull, then initdbPer job, every jobInstance per jobNone — dies with the jobFlat; rides on runner minutes
TestcontainersSame, plus suite startupPer job, unless you reuseInstance per jobNone — reaper sidecar handles itFlat, until the runner thrashes
tmpfs + fsync=off + templateinitdb once, then a RAM copyOnce per job, then freeDatabase per workerNone — inside the jobFlat; fastest per dollar
Branching serviceBranch creation, verify docsOnce, in the parentBranch per jobYours to clean upPer branch-hour; check limits
Long-lived instanceCREATE DATABASE TEMPLATEOnce, in the templateDatabase per jobLeaked database, silentFlat; one instance
PandaStack30–90 s create; branch is fasterOnce, in the branched parentInstance per workflowPersistent — nothing expiresPer database-hour, per tier

The pattern that actually works

Having been straight about the 30-to-90-second shape, here is the arrangement that makes it a non-issue. Separate the two lifetimes that everybody conflates: the lifetime of the Postgres server, and the lifetime of a test's data.

  1. One server per CI workflow, or per branch, or per repository. This is the thing whose creation costs 30 to 90 seconds, so create it once and pay it once. A branch of an already-seeded parent is cheaper still, because there is no initdb and no WAL replay involved.
  2. Migrations and seed run once, into a database you then stop writing to and treat as a template. Not per job and definitely not per test.
  3. One database per parallel worker inside that server, created with CREATE DATABASE ... TEMPLATE. A file copy, with the planner statistics included.
  4. Truncate with RESTART IDENTITY between tests inside a worker's database. Real commits, real notifications, sequences that do not drift.
  5. Delete the server on every exit path, and run a reaper for the exit paths that do not execute your code.

Step one, with the poll written out, because this is the bit people get wrong in the direction of losing the id:

"""Provision ONE throwaway Postgres per CI workflow, not per test.

The API returns 202 with status "provisioning" and does not block -- a
synchronous 30-90s create behind a CDN gets its connection cut on the slow
path, so the contract is: take the id, then poll. The SDK's create() wraps
that poll for you; the raw two-step is shown underneath because in CI you
usually want the id written to disk BEFORE you start waiting on it.
"""

import os
import pathlib
import pandastack

client = pandastack.Client()          # reads PANDASTACK_API_KEY
run_id = os.environ["GITHUB_RUN_ID"]
idfile = pathlib.Path("db-id.txt")    # the handle your teardown job needs

# --- The convenience path -------------------------------------------------
# create() posts, then polls GET /v1/databases/{id} until it reports
# "running" WITH a connection_url. It blocks for the full 30-90s.
#   size: "1g" (default) / "4g" / "16g" -- the RAM tier, and the ONLY shape
#     knob. It is fixed at create time: a snapshot-restored microVM cannot be
#     resized, so changing tier later means clone(size=...) into a new id.
#   always_on: opt this database out of idle auto-suspend. A CI database that
#     sits idle between workflow jobs is exactly what auto-suspend is for, so
#     leave it off unless a suspend mid-suite would break you.
db = client.databases.create(label=f"ci-{run_id}", size="1g", timeout=180.0)
idfile.write_text(db["id"])
print("DATABASE_URL=" + db["connection_url"])

# --- The two-step, which is what you actually want in CI ------------------
# Write the id down first. If the runner is reclaimed during the 90-second
# wait, a database you never learned the id of is a database you cannot
# delete, and it will not expire on its own (see the teardown section).
#
#   import requests
#   created = requests.post(
#       "https://api.pandastack.ai/v1/databases",
#       headers={"Authorization": f"Bearer {os.environ['PANDASTACK_API_KEY']}"},
#       json={"label": f"ci-{run_id}", "size": "1g"},
#       timeout=30,
#   ).json()                                  # 202, status "provisioning"
#   idfile.write_text(created["id"])          # id in hand BEFORE the wait
#   db = client.databases.wait_until_ready(created["id"], timeout=180.0)
#
# wait_until_ready raises TimeoutError if it never comes up and RuntimeError
# if it reports failed/error -- both of which should fail the job loudly
# rather than fall through to a suite that silently tests nothing.

Steps two and three are plain SQL, and worth reading even if you are not using a managed database for this — the template-database trick is the single most useful thing in this post and it works on any Postgres:

-- Pay the schema and the seed ONCE per database, then clone it per worker.
--
-- CREATE DATABASE ... TEMPLATE is a filesystem copy of the template's data
-- directory. It copies the rows, the indexes AND pg_statistic -- which is
-- why a template clone plans queries like the seeded database did, and why a
-- truncate-and-reseed database (no ANALYZE) can pick different plans from
-- production and make "fast in tests, slow in prod" look like a mystery.

-- 1. Build the template once: migrations + seed, then ANALYZE.
CREATE DATABASE app_template;
\connect app_template
-- ... run your migration tool here, then your seed, then:
ANALYZE;

-- 2. Stop anyone from holding a session open on it. CREATE DATABASE fails
--    outright if another session is connected to the source:
--      ERROR: source database "app_template" is being accessed by other users
--    A connection pooler that keeps idle sessions warm will do this to you,
--    so point the pooler at the per-worker databases, never at the template.
\connect postgres
UPDATE pg_database SET datallowconn = false WHERE datname = 'app_template';

-- 3. Per worker (or per test, if you can afford it), clone it.
--    Note: CREATE DATABASE cannot run inside a transaction block, so this
--    does not live in your test's transactional fixture.
CREATE DATABASE test_gw0 TEMPLATE app_template;
CREATE DATABASE test_gw1 TEMPLATE app_template;

-- 4. Teardown. FORCE (PG13+) terminates leftover sessions instead of
--    failing, which is what you want when a cancelled test left one behind.
DROP DATABASE IF EXISTS test_gw0 WITH (FORCE);

Steps three and four as a pytest fixture, parallel-safe under xdist. The last test in this file is a fidelity assertion rather than a test of your code, and it is the cheapest bug-finder here:

# conftest.py -- database-per-xdist-worker, cloned from a template database.
#
# The isolation unit is the WORKER, not the test: one CREATE DATABASE per
# worker at session start, and a cheap reset between tests inside it. That
# gets you real COMMIT semantics (so LISTEN/NOTIFY, advisory locks, deferred
# constraint triggers and code that commits on its own all behave) without
# paying a database creation per test case.

import os
import psycopg
import pytest
from psycopg import sql

ADMIN_URL = os.environ["DATABASE_URL"]        # points at the "postgres" db
TEMPLATE = "app_template"


def _worker() -> str:
    # pytest-xdist sets PYTEST_XDIST_WORKER to gw0, gw1, ... and leaves it
    # unset when you run single-process. Deriving the database name from it
    # is what makes parallel runs not collide.
    return os.environ.get("PYTEST_XDIST_WORKER", "gw0")


@pytest.fixture(scope="session")
def database_url():
    name = f"test_{_worker()}_{os.getpid()}"
    # autocommit: CREATE DATABASE and DROP DATABASE cannot run inside a
    # transaction block, and psycopg opens one for you by default.
    with psycopg.connect(ADMIN_URL, autocommit=True) as admin:
        admin.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
            sql.Identifier(name)))
        admin.execute(sql.SQL("CREATE DATABASE {} TEMPLATE {}").format(
            sql.Identifier(name), sql.Identifier(TEMPLATE)))
    try:
        yield ADMIN_URL.rsplit("/", 1)[0] + "/" + name
    finally:
        # Best effort. If the runner is SIGKILLed this never runs, which is
        # the entire argument for the periodic reaper further down the post.
        with psycopg.connect(ADMIN_URL, autocommit=True) as admin:
            admin.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
                sql.Identifier(name)))


@pytest.fixture(autouse=True)
def reset(database_url):
    """Between tests: truncate the data, keep the schema and the sequences sane.

    RESTART IDENTITY matters more than it looks. nextval() is deliberately
    non-transactional, so a rolled-back test still consumed sequence values --
    which is how a suite that asserts on literal primary keys passes alone
    and fails when another test runs before it.
    """
    yield
    with psycopg.connect(database_url, autocommit=True) as conn:
        tables = conn.execute("""
            SELECT quote_ident(schemaname) || '.' || quote_ident(tablename)
            FROM pg_tables
            WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
        """).fetchall()
        if tables:
            conn.execute("TRUNCATE %s RESTART IDENTITY CASCADE"
                         % ", ".join(t[0] for t in tables))


def test_the_test_database_matches_production(database_url):
    """Fidelity assertion. Run it first; it is cheaper than the bug it finds.

    A different collation changes ORDER BY on text and the ordering inside
    every text index -- the glibc 2.28 collation change broke real indexes
    in exactly this way. If CI runs a musl-based image at C collation and
    production runs glibc en_US.UTF-8, your ORDER BY name assertions are
    testing a different sort than the one your users get.
    """
    with psycopg.connect(database_url) as conn:
        version, collate, ctype = conn.execute(
            "SELECT current_setting('server_version_num')::int,"
            "       datcollate, datctype"
            "  FROM pg_database WHERE datname = current_database()"
        ).fetchone()
        assert version // 10000 == 16, f"server major {version}"
        assert collate == "en_US.UTF-8", collate
        assert ctype == "en_US.UTF-8", ctype
        installed = {r[0] for r in conn.execute(
            "SELECT extname FROM pg_extension").fetchall()}
        assert {"pgcrypto", "pg_trgm", "vector"} <= installed, installed

And if the seed is large enough that even building the template per workflow hurts, branch a long-lived seeded parent instead. This is the HTTP call, with the constraints spelled out, because the SDKs do not wrap it yet:

#!/usr/bin/env bash
# Branch a RUNNING, already-seeded parent instead of creating from nothing.
#
# A branch reflink-copies the parent's live durable volume inside a brief
# pause window on the parent's OWN host, and is warm by default: the child
# is memory-forked from the parent, so it comes up with hot shared_buffers
# rather than a cold cache. There is no initdb and no WAL replay, which is
# why it is not in the same latency class as a create from nothing.
#
# Three constraints, all of them load-bearing in CI:
#   1. The parent must be RUNNING on a live host. A hibernated or failed
#      parent gets a 409 telling you to wake it or use clone instead.
#   2. The branch is PINNED to the parent's host, because that is where its
#      volume is. N branches of one parent is an N-on-one-host question.
#   3. There is no branch() in either SDK yet -- this endpoint is HTTP only.
set -euo pipefail

API="https://api.pandastack.ai"
auth=(-H "Authorization: Bearer $PANDASTACK_API_KEY" -H 'Content-Type: application/json')
PARENT="${SEED_DATABASE_ID:?the long-lived, already-migrated+seeded database}"

# warm:true is the default (memory-fork, hot cache). {"warm": false} gives a
# disk-only reflink branch: cheaper pause on the parent, cold buffers in the
# child. size is optional and branches into a different RAM tier (1g/4g/16g);
# omit it to inherit the parent's. There is NO target_time here -- clone is
# the endpoint that does point-in-time.
BRANCH=$(curl -fsS -X POST "$API/v1/databases/$PARENT/branch" "${auth[@]}" \
  -d "{\"label\": \"ci-$GITHUB_RUN_ID\", \"warm\": true}" | jq -r '.id')
echo "branch=$BRANCH"

# 202 + poll, same as create. Delete on every exit path -- and note that a
# trap does NOT run on SIGKILL or on a reclaimed spot runner, which is why
# the label above is reconstructable and why you still need a reaper.
trap 'curl -fsS -X DELETE "$API/v1/databases/$BRANCH" "${auth[@]}" || true' EXIT

for _ in $(seq 1 60); do
  read -r status url < <(curl -fsS "$API/v1/databases/$BRANCH" "${auth[@]}" \
    | jq -r '[.status, (.connection_url // "")] | @tsv')
  [ "$status" = "running" ] && [ -n "$url" ] && break
  [ "$status" = "failed" ] && { echo "branch failed" >&2; exit 1; }
  sleep 3
done

DATABASE_URL="$url" npm run test:integration

Cancelled at 40%: what leaks and what does not

Every disposable-resource system leaks, and the leak always happens on the path your cleanup code does not run on. There are exactly three of those and they are all routine: somebody cancels the workflow because they spotted a typo, the spot runner gets reclaimed mid-suite, and the test process gets OOM-killed. None of them run your trap, your finally block or your after-hook.

This is the axis where the boring options win outright. A CI service container and a Testcontainers container both leak nothing, because something other than your code owns their lifetime — the provider in one case, a reaper sidecar holding an open socket in the other. Anything you created through an API leaks a billable object, and it leaks it silently, and the first signal is an invoice or a quota error three weeks later.

If you provision databases from CI through any API, write the reaper in the same pull request as the provisioning code. Not the next sprint. The rules that make it safe rather than terrifying: label every database so the label reconstructs from the CI run that owns it, give the reaper an ownership prefix and let it delete nothing outside that prefix, never touch anything younger than about fifteen minutes so you cannot race a database that is still provisioning, and ask the CI provider whether the run is still active rather than inferring it from age. On PandaStack specifically this is not optional: managed databases are marked persistent so the idle reaper will never delete one, because a delete would destroy the durable volume. Nothing expires. Only your DELETE removes it.

There is a second-order leak worth naming: the one inside a long-lived instance. Databases created with CREATE DATABASE and never dropped do not cost you a line item, so nobody notices, and six months later the instance has nine hundred of them, pg_dump of the cluster takes an hour, and somebody is hitting a connection limit for reasons that make no sense. Reap those too, by name prefix and age.

Cost shape at CI concurrency

The per-unit price is the least interesting thing about this. What matters is which of three shapes you have signed up for. Shape one is flat and already paid: a container riding on runner minutes, where going from four to forty concurrent jobs changes your database bill by zero. Shape two is linear in matrix width times wall-clock: anything you provision per job through an API. Shape three is a cap, which is the worst one, because a concurrency limit you hit does not show up as a bill — it shows up as a queue, and a queue shows up as merge latency, and merge latency shows up as engineers batching changes into bigger pull requests.

For shape two, do the arithmetic before you commit rather than after. On PandaStack's published rate card — $0.054 per vCPU-hour and $0.0162 per GiB-hour, one card for every workload class — memory is billed on working-set GiB-hours and CPU on the active CPU-seconds actually burned. So forty concurrent 1g databases commit 40 GiB, which is about 65 cents an hour while all forty exist, plus whatever CPU the suites actually burn. If each one lives for the eight minutes of a job, forty jobs cost single-digit cents of memory. If one of them leaks and lives for a month, it costs more than the entire month of CI that created it. That asymmetry is the whole argument for the reaper, stated in money.

Which also explains why the recommendation above is one server per workflow rather than per job. One database held for the twenty minutes a workflow actually takes costs the same as one job's worth and covers the whole matrix.

What to actually pick

In rough order of how often the answer is right:

  • Fast migrations, small fixtures, one database: a plain CI service container. Do not over-engineer this. Add a readiness loop instead of the sleep and move on.
  • The suite is slow and nothing in it asserts on durability: tmpfs plus fsync=off plus a template database, inside the job. This is the cheapest large win available to most teams and it requires no vendor.
  • You want the same database story on a laptop and in CI, or more than one service in the graph: Testcontainers, and let its reaper own the lifetime.
  • Long migration history, large seed, and the per-job setup cost is what hurts: a branching service, or a long-lived instance with a template database. Compare those two honestly — the second is cheaper and faster and gives up multi-tenant isolation you may not need.
  • You need to assert on roles, permissions, extension installation, replication or crash recovery: an instance per job. That is the only row of the isolation table that covers it, and PandaStack is a reasonable fit at per-workflow granularity.
  • You need per-test database creation: not PandaStack. Entries three and five.

And whichever you pick: delete the sleep, assert the collation, and write the reaper in the same pull request as the provisioning. Those three cost an afternoon and they are the difference between a test database you trust and one you have a superstition about.

Frequently asked questions

Should I create a database per test or per parallel worker?

Per worker, almost always. Database-per-test gives you the strongest isolation available but you pay a CREATE DATABASE for every test case, and on a suite of any size that cost dominates everything else. Per-worker gets you the property that actually matters — real COMMIT semantics, so LISTEN/NOTIFY works, sequences behave, deferred constraint triggers fire and code that commits on its own does not need savepoint emulation — at the cost of one creation per worker at session start. Inside the worker's database, reset between tests with TRUNCATE ... RESTART IDENTITY CASCADE, which is cheap and, crucially, resets the sequences. The reason RESTART IDENTITY matters is that nextval() is deliberately non-transactional: a rolled-back or truncated test still consumed sequence values, so a suite that asserts on literal primary keys passes when run alone and fails when something else runs first. That is the single most common cause of a test that only fails in CI. Derive the database name from your runner's worker id — PYTEST_XDIST_WORKER under pytest-xdist, the equivalent in your framework — so parallel runs cannot collide.

Is fsync=off safe for a test database?

Yes, with one specific exception, and it is the biggest speed win available to most test suites. A test suite is almost pure small-transaction write churn, which is exactly the workload that fsync punishes hardest, so turning off fsync, full_page_writes and synchronous_commit — and putting the data directory on a tmpfs while you are there — can change the character of a suite rather than just shaving a percentage. What you have given up is durability, and nothing else: query results, constraints, transaction isolation, planner behaviour and every assertion your tests actually make are unaffected. The exception is any test that asserts on what survives a crash — recovery, WAL replay, replication, kill -9 behaviour. Those are now testing a fiction and belong on a faithfully configured instance. One operational footgun: with the data directory on tmpfs, your test data competes with your test processes for the runner's RAM, and the failure mode is not a slowdown, it is an OOM kill partway through the suite.

Why does my test suite pass locally and fail in CI with different ORDER BY results?

Almost certainly collation. Text ordering in Postgres comes from the database's collation, and that comes from the operating system's locale data at initdb time — so a musl-based Alpine image initialised at C collation sorts text differently from a glibc en_US.UTF-8 production database, and the difference is not exotic: case handling, punctuation, and accented characters all move. This is the same class of problem as the glibc 2.28 collation change, which altered sort order between distribution versions and left real production text indexes in an order the new library disagreed with. The fix is to stop assuming and start asserting: run SELECT datcollate, datctype FROM pg_database WHERE datname = current_database() plus a server-version check and a SELECT extname FROM pg_extension in your test suite, as an actual test, and fail the build if the test database does not match the Postgres you ship on. It costs milliseconds and it finds the bug once instead of every quarter. PandaStack's postgres-16 template initialises its cluster at en_US.UTF-8 with UTF8 encoding for exactly this reason.

How long does it take to get a throwaway Postgres on PandaStack, and can I create one per test?

A fresh managed database takes 30 to 90 seconds of wall clock to become usable, and no, you should not create one per test. The API is honest about the shape: POST /v1/databases returns 202 immediately with an id and status "provisioning", and your client polls GET /v1/databases/{id} until it reports running with a connection_url — it does not block, because a synchronous 90-second response behind a CDN gets its connection cut and leaves an orphan whose id you never learned. But 202 does not shorten the 90 seconds. The right granularity is one database per CI workflow, per branch, or per preview environment, with database-per-worker isolation inside it using CREATE DATABASE ... TEMPLATE. If you already have a migrated and seeded parent running, POST /v1/databases/{id}/branch is substantially faster than a create from nothing — it reflink-copies the parent's live volume on the parent's host and is warm by default, memory-forked so the child starts with hot shared_buffers. Two constraints: the parent has to be running on a live host or you get a 409, and the branch is pinned to that host, so high branch concurrency is a single-host capacity question.

What happens to a throwaway database if my CI run is cancelled halfway through?

It depends entirely on who owns the lifetime, and this is the axis where the unglamorous options win. A container in your CI provider's services block dies with the job, enforced by the provider, so a cancellation leaks nothing. Testcontainers ships a reaper sidecar that holds an open connection to your test process and destroys the containers when it drops, so a SIGKILL, a quit debugger or a reclaimed runner still gets cleaned up — that is a genuine piece of engineering most hand-rolled scripts skip. Anything you created through an API leaks a billable object, silently, because your trap, finally block and after-hook all fail to run on exactly those three paths: cancellation, spot reclamation, and OOM kill. On PandaStack this is emphatic rather than probabilistic: managed databases are created with the sandbox persistent flag set so the idle reaper can never remove one — a delete would destroy the durable volume — which means nothing expires and only your DELETE removes it. Write the reaper in the same pull request as the provisioning code, scope it to a label prefix you own, never touch anything younger than about fifteen minutes so you cannot race a provisioning database, and check whether the run is still active rather than inferring it from age.

Keep reading

  • Top 8 throwaway Postgres platforms — The companion roundup: the general case, graded by mechanism — container, copy-on-write branch, or whole dedicated instance. Read that one if you are picking a platform rather than wiring a test suite.
  • Testcontainers, and what comes after it — Entry two examined properly, including the nested-Docker constraint and where container-per-test stops scaling.
  • Seeding test data in ephemeral databases — Axis two in depth: building a seed that stays honest as the schema moves, and anonymising a production copy if you must use one.
  • Ephemeral CI — The rest of the CI story — isolated runners per job, and why the database is only half the shared-state problem.
  • PandaStack managed Postgres — PostgreSQL 16 in a dedicated microVM: RAM tiers, the branch and clone endpoints, and the extension list quoted above.

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.