Riajul Islam
← Back to blog
Backend

Database Branching in Neon, and What It Actually Fixes

A branch per pull request with production-shaped data, why it makes migration review possible, and the two ways it quietly leaks customer records.

The reason migrations get reviewed badly is that nobody can run them against anything real. The diff shows a column being dropped, the reviewer has a local database with eleven rows in it, and the question "will this lock the table for four minutes" has no answer available at review time.

Neon's branching changes that specific thing. A branch is a copy-on-write fork of the database that takes about a second regardless of size, so every pull request can have its own database containing production-shaped data — and the migration can run there before anybody approves it.

This is why Neon is the Postgres in my B2B and web-app stack templates rather than a managed instance elsewhere. Here is what it genuinely fixes, what it does not, and the part that will leak customer data if you are careless.

What is a database branch, actually?#

A pointer to a moment in the storage layer, not a copy of the data. Neon separates storage from compute, keeps a write-ahead log, and a branch is a new head over the same underlying pages.

That is why the operation is instant and why a branch of a 200GB database does not cost 200GB. Storage is only consumed as the branch diverges — the pages you write, not the pages you inherit.

ApproachTime to createData realismStorage cost
Neon branchAbout a secondExactOnly the divergence
pg_dump and restoreMinutes to hoursExactFull copy
Seed scriptSecondsInventedTiny
Shared staging databaseAlready existsStale and sharedOne copy
Branch versus the alternatives

The last row is what most teams have, and it is the one this replaces. A single shared staging database means two people testing migrations at once corrupt each other's work, and it means the database drifts from production in ways nobody tracks.

What does a branch per pull request give you?#

Four things, and the second is the one that changes how migrations get reviewed.

The migration runs before approval#

CI creates a branch, applies the migration, runs the test suite against it, and reports the result on the pull request. A migration that fails, locks, or takes eleven minutes is visible in the check rather than at deploy time.

Preview deployments get their own data#

A Vercel or Cloudflare preview pointed at a branch can be clicked through with real-shaped content. Reviewing a UI change against three seeded rows and reviewing it against a customer's actual data volume are different activities, and only the second finds the pagination bug.

Destructive tests become safe#

You can delete everything on a branch. That makes it possible to actually test the account-deletion path, the data-export path, and the bulk-import path — all of which are normally under-tested because nobody wants to run them anywhere real.

Reset is instant#

A branch that gets into a bad state is deleted and recreated in a second, rather than being repaired. This sounds minor and it changes behavior: people experiment more when undo is free.

# The whole thing is one step in CI.
- run: neonctl branches create --name pr-${{ github.event.number }} --parent main
- run: npx prisma migrate deploy
- run: npm test
# ...and a delete step on PR close, or branches accumulate.

One thing worth getting right in that workflow is where the branch name comes from. Deriving it from the pull request number means a re-run of the same pipeline reuses the branch instead of creating a second one, and it means the delete step on close knows exactly what to remove without any state passed between jobs. A random name works until a job fails between creation and cleanup, which happens weekly on a busy repository.

That cleanup step is not optional housekeeping. Branches hold their divergence in storage and a repository with three hundred stale branches is paying for three hundred sets of changes, most belonging to pull requests merged months ago.

How does this change migration review?#

It turns an argument about what a statement will do into a measurement of what it did.

Most dangerous migrations are dangerous for reasons that depend on data volume, and volume is exactly what a local database lacks. Adding a non-null column with a default, creating an index without concurrently, changing a column type — all of these are instant on eleven rows and hold an exclusive lock for minutes on eleven million.

-- Instant locally. On a large table this rewrites and holds a lock throughout.
alter table events alter column payload type jsonb using payload::jsonb;

-- What the branch tells you that the diff cannot: how long, and what it locked.

Running it on a branch with real volume produces a number. A reviewer can then make an actual decision — ship it, or split it into the concurrent-index-then-swap version — instead of guessing based on how the statement reads.

This connects directly to zero-downtime migrations: the techniques there are only worth applying to the migrations that need them, and a branch is how you find out which ones those are.

Where does customer data leak?#

Two places, and both are the direct consequence of the thing that makes branching useful — the data on the branch is real.

Preview environments are less protected than production#

A preview URL is usually unauthenticated or behind a shared password, it is not monitored, and it is often indexed if somebody links to it. Pointing that at a branch containing real customer records puts production data behind preview-grade access control.

Third-party integrations fire for real#

A branch carries the rows. If the preview environment also carries production API keys, a test run sends real emails to real customers, charges real cards and posts real webhooks. The database was branched; the outside world was not.

Both problems have the same fix and it has to be automatic rather than remembered.

// Anonymize on branch creation, not "before somebody uses it".
update users set
  email = 'user' || id || '@example.invalid',
  phone = null,
  name  = 'Test User ' || left(id::text, 8);

truncate table payment_methods, sessions, audit_log;

Run that as a step in the same CI job that creates the branch, so a branch that exists has always been through it. And keep every outbound integration on test credentials in preview — the anonymized addresses above are .invalid on purpose, since a send to them cannot reach anyone even if the email code runs.

The safer default: branch the schema, seed the data#

For most preview work you need production's *shape*, not its contents. A branch created from a schema-only parent plus a generous seed gives you realistic volume and structure with nothing sensitive in it, and removes the whole class of problem. Use the real-data branch for migration timing, where the volume is the point, and keep it out of anything with a public URL.

How do you name and expire branches?#

With a scheme derived from something automatic, and a deletion rule that does not depend on anybody remembering. Branch sprawl is the failure mode of this workflow and it arrives quietly.

Derive the name, never type it#

pr-482 from the pull request number, preview-<sha> from a commit, restore-<timestamp> for a recovery fork. A hand-typed name means two people eventually pick the same one, and it means a cleanup job cannot tell a disposable branch from a deliberate one.

Delete on close, and sweep on a schedule#

The delete step in the pull-request workflow handles the common case and misses every branch created by a job that failed halfway. A weekly sweep that removes anything matching the disposable prefix older than a week catches the rest, and it is fifteen lines.

Protect the branches that matter#

main and anything a real environment points at should be protected so a cleanup script cannot reach them. This is worth doing before writing the cleanup script rather than after, for the obvious reason.

The reason to be strict here is that a stale branch is not inert. It holds its divergence in storage, it may have a compute endpoint that occasionally wakes, and — worst — it holds a snapshot of whatever data was on it, including the customer records from the pull request nobody ever merged.

How does this compare to the alternatives?#

Every hosted Postgres has some answer to "give me a copy". They differ in whether the answer is fast enough to put in CI.

ApproachRealisticFast enough for CISensitive data risk
Neon branchExactYesHigh, unless anonymized
Supabase branchingSchema, seeded dataYesLow
Restore from snapshotExactNoHigh
Docker Postgres + seedInventedYesNone
Getting a realistic database, four ways

The row that surprises people is the last one, because it is the right answer more often than the branching pitch suggests. If your tests only need a schema and fixtures, a container is faster, free, offline and carries no data risk at all — and it is what I use for the unit suite even on projects where branching is set up for pull requests.

The case for branching is specifically the middle ground: work that needs production's volume and distribution, for a few hours, and then needs to disappear. Migration timing, pagination behavior, query plans on real cardinality. Outside that band the simpler option usually wins, and reaching for the impressive one is how a pipeline acquires a dependency it did not need.

How do you handle branching with Prisma?#

One connection string per environment, resolved at runtime, and a migration workflow that never runs db push anywhere that matters.

// The branch URL comes from the environment. Nothing in the app knows
// which branch it is on, which is the property you want.
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")   // pooled vs direct — migrations need direct
}

The pooled-versus-direct split is the detail that costs an afternoon if missed. Neon's pooler is right for application queries and wrong for migrations, which need a session-level connection to take advisory locks. Prisma's directUrl exists for exactly this, and a migration run through the pooler fails in a way that does not obviously say so.

The application itself should be entirely unaware of which branch it is talking to. Any code that reads a branch name, checks an environment string to decide behavior, or has a special case for preview is code whose preview path is untested in production and whose production path is untested in preview. The connection string is the only thing that differs.

Never use db push outside a scratch branch#

prisma db push reconciles the schema without producing a migration file. On a scratch branch that is a fast loop; anywhere else it means the schema history and the database have diverged, and the next real migration is generated against the wrong baseline.

Generate the migration against a branch that matches production#

A migration generated against a local database that has drifted produces statements for a schema nobody has. Creating it against a fresh branch of the production schema is one command and removes the entire category.

What about the compute side?#

Branches have their own compute, and the default behavior is worth understanding before it surprises you in a bill or a benchmark.

Scale to zero means a cold start#

An idle branch suspends. The first query after that pays a start-up cost of a few hundred milliseconds. On preview environments this is exactly right. On a production branch it is not, and the setting is per branch rather than global.

Do not benchmark on a branch and conclude anything#

A branch is on shared infrastructure with its own compute sizing, and its page cache is empty. A query that is slow on a fresh branch may be fast on production simply because production has the relevant pages resident. Use branches to find missing indexes and locking behavior, which are structural, and measure timings where the cache is warm.

There is a related trap in autoscaling. A branch sized smaller than production will hit resource limits on a query production handles comfortably, and the failure looks like a bug in the query rather than a difference in the environment. When you are using a branch to judge whether something is fast enough, match the compute size deliberately — otherwise you are measuring the branch, not the change.

Branch from a point in time, not just from the head#

Neon can branch from a timestamp within the retention window. That is the recovery story: something deleted an hour ago is recoverable by branching from ninety minutes ago and copying the rows across, without restoring anything or touching the live database.

The workflow is worth rehearsing once before you need it, because the useful version is not "restore everything". It is: branch from before the incident, connect to both branches at once, and copy across only the affected rows. Production keeps serving throughout, and the blast radius of the fix is the rows you name rather than the whole database.

It is worth being clear that this is not a substitute for backups. Retention is bounded, and a bug discovered three weeks later is outside it — which is the argument for backups you have actually restored sitting underneath this rather than beside it.

When is branching not the answer?#

Three cases where reaching for it adds process without adding safety.

  • Unit tests. They should run against an ephemeral local Postgres in a container. A network round trip per test is slower than the tests are, and the data realism is irrelevant when the input is a fixture.
  • Long-lived environments. A branch that lives for six months is a second production database with none of the operational attention. If an environment is permanent, make it a project rather than a branch.
  • Load testing. Shared compute produces numbers that are not reproducible and not representative. Provision something sized for it.

The pattern in all three is the same: branching is for short-lived, disposable, data-shaped work. The moment something is permanent, or the moment the measurement is about infrastructure rather than about your schema, it belongs elsewhere.

What does this cost to adopt?#

An afternoon of CI configuration, and it is one of the few pieces of infrastructure work where the payoff is visible in the first week.

The pieces are: a create step and a delete step in the pull-request workflow, an anonymization step between them, the preview environment reading its connection string from the branch, and test credentials for every outbound integration in preview. That is a day at the outside, most of it spent on the integration credentials rather than on Neon.

The honest counterweight is that it adds a moving part to CI. A branch-creation step that fails blocks the pipeline, and a quota reached on a busy day blocks everybody's pull request at once. Set a branch limit, delete on close, and have the workflow fail loudly rather than silently falling back to a shared database — a fallback that quietly points CI at production data is a considerably worse outcome than a red pipeline.

The value is not that branching is clever. It is that a reviewer can finally answer "what will this migration do" with a number instead of an opinion.

Conclusion#

Create a branch per pull request, run the migration and the test suite against it in CI, and delete it when the pull request closes. That single loop turns migration review from a reading exercise into a measurement, which is the whole reason to bother.

Anonymize on creation as an automated step rather than a remembered one, and keep every outbound integration on test credentials in preview environments. The database is the only thing that got branched — the email provider, the payment processor and the public preview URL did not, and each is a route for real data or real side effects to escape.

Use the direct connection for migrations and the pooled one for queries, never run db push outside a scratch branch, and generate migrations against a branch of the production schema so the baseline is right. These are small details that each cost an afternoon when missed.

Keep branches short-lived and disposable. Unit tests belong on a local container, permanent environments belong in their own project, and load tests belong on dedicated compute — branching is for the case where you need production's shape for a few hours and then need it gone. If you are choosing a Postgres host and want to talk through whether this workflow is worth building around, get in touch.

Frequently asked questions

How is a Neon branch different from a database copy?

A branch is copy-on-write over shared storage, so it is created in about a second regardless of database size and only consumes storage as it diverges. A dump-and-restore copies every page, takes minutes to hours, and costs the full size of the database for as long as it exists.

Is it safe to use production data in preview environments?

Not without anonymizing it first. Preview URLs have weaker access control than production and are rarely monitored, and preview environments often carry live API keys that will send real email and charge real cards. Anonymize as an automated step at branch creation, not as a remembered one.

Can database branching replace backups?

No. Point-in-time branching is bounded by the retention window, so it handles a mistake noticed within hours and cannot help with one found three weeks later. Keep restore-tested backups underneath it — branching is a fast recovery path, not a durable one.

Should unit tests run against a database branch?

No. Use an ephemeral local Postgres in a container. A network round trip per test is slower than the tests themselves, and data realism does not matter when every input is a fixture. Save branches for migration timing and preview environments where volume is the point.

Read next Schema Migrations That Do Not Take the Site Down

Got a project worth
writing about?