Stop Sharing Databases in Development

Author

Johnny Lin

Date Published

The Setup That Seems Fine (Until It Isn't)


You spin up a Neon project, a Supabase instance, or a MongoDB Atlas cluster. You paste the connection string into the team's shared `.env`. Everyone connects. It works. Ship it, right?

This is one of the most common setups in collaborative development — and one of the most quietly destructive.

Shared databases in development feel like a shortcut. They're actually a trap. The problems don't show up on day one. They show up the morning someone drops a table during a schema migration and three other developers can't figure out why their app is crashing.


What Actually Goes Wrong

1. Schema Conflicts Are Silent Killers

Developer A is working on a feature that adds a `status` column to the `orders` table. Developer B is refactoring `orders` to split it into `orders` and `order_items`. Both push schema changes to the same database.

The result? One of them wins. The other gets a runtime error they didn't cause, can't reproduce locally, and will waste 30 minutes debugging before realizing the database moved under them.

With ORMs like Drizzle or Prisma, this is even more insidious — your schema file says one thing, the actual database says another, and the error messages are cryptic.

You stare at your schema. The column is right there. You run `db:push` again. It fails because someone else's migration already altered the table. Welcome to dependency hell, database edition.

You stare at your schema. The column is right there. You run `db:push` again. It fails because someone else's migration already altered the table. Welcome to dependency hell, database edition.

1PostgresError: column "status" of relation "orders" does not exist


2. Test Data Is a Shared Mutable State

Every developer has different test scenarios. One person seeds 10,000 rows to test pagination. Another deletes all rows to test empty states. A third is debugging a specific edge case and manually inserted rows with specific IDs.

On a shared database, everyone's test data collides. You can't trust what's in the database at any given moment. You find yourself writing queries with `WHERE created_by = 'my-name'` just to filter out other people's garbage. That's not development — that's coping.


3. You Can't Run Destructive Migrations

Need to `DROP TABLE` and recreate? Need to `TRUNCATE` for a clean test? Need to run `db:push --force` to reset schema drift?

On a shared database, you can't. Or you can, but you'll break everyone else. So you don't. You work around it. You accumulate hacks. You stop doing the thing that would actually fix your problem because the social cost is too high.

This is the insidious part: shared databases don't just cause bugs — they change how you develop. You become conservative when you should be experimental. You avoid schema changes when you should be iterating.


4. "Works on My Machine" Becomes "Works with That Data"

Your app works because row ID 47 happens to exist with the right foreign keys. Your colleague's doesn't because they're hitting different data. You both have the same code, the same schema, but different runtime behavior.

This is nearly impossible to debug without realizing the data is the variable. And on a shared database, data is *always* the variable.


5. Migration History Becomes a Minefield

Most ORMs and migration tools (Drizzle, Prisma, Knex, Flyway, Alembic) track applied migrations in a metadata table like `_prisma_migrations` or `drizzle.__drizzle_migrations`. This table is the source of truth for "what shape is this database in right now?"

On a shared database, that history is a single linear timeline — but your team's development is not. Developer A applies migration `005_add_status`. Developer B, working from an older branch, applies `005_rename_orders`. The migration table now says both `005`s ran. The actual schema is a Frankenstein of both. Your migration tool doesn't know what state the database is in anymore, and neither do you.

It gets worse with tools like Drizzle's `db:push` that diff the schema file against the live database. If someone else pushed a different schema, your `db:push` generates a diff against their changes, not the baseline you expected. You end up with migrations that "fix" things that weren't broken on your branch.

1# You expect this diff:
2+ ADD COLUMN "status" VARCHAR(50)
3
4# You get this diff (because someone else altered the table):
5~ ALTER COLUMN "name" TYPE TEXT -- not your change
6+ ADD COLUMN "status" VARCHAR(50)
7- DROP COLUMN "legacy_field" -- not your change either

With isolated databases, each developer's migration history is a clean, linear sequence that matches their branch. No surprises.


7. Branching Is Impossible

Git gives you cheap branches. Your code diverges and converges cleanly. But your database? It's stuck on one timeline.

If you're on a feature branch that requires schema changes, you either:

- Push the schema change and break `main` for everyone

- Don't push it and manually hack around the mismatch

- Create a "feature flag" for a database column (please don't)

None of these are good. All of them are common.


The Cost You're Not Measuring

Teams with shared dev databases consistently report:


Symptom

Root Cause

"Random" test failures

Mutated shared data

Slow PR reviews

Reviewers can't reproduce the state

Fear of migrations

Breaking others' environments

Long debugging sessions

Chasing data-dependent bugs

Type-safe code throws runtime errors

Schema mismatch between code and DB

`db:push` generates unexpected diffs

Migration history polluted by other branches

Onboarding takes days

Setting up requires "the right data"

These aren't individual problems. They're systemic friction from a single architectural choice.


What Isolation Looks Like

Option 1: Docker Compose (Recommended Starting Point)

A single `docker-compose.yaml` in your repo that spins up Postgres (or whatever you use) locally:

1services:
2 postgres:
3 image: postgres:17-alpine
4 environment:
5 POSTGRES_USER: dev
6 POSTGRES_PASSWORD: dev
7 POSTGRES_DB: app
8 ports:
9 - "5432:5432"
10 volumes:
11 - pgdata:/var/lib/postgresql/data
12
13volumes:
14 pgdata:

Cost: Free. ~10 seconds to start. Works offline.


Option 2: Database-per-Branch (Cloud)

Services like Neon and PlanetScale support branching databases — each git branch gets its own database fork. This is excellent when you genuinely need cloud-hosted databases:

1# Neon CLI example
2neon branch create --name feature/new-orders
3# Connection string automatically points to the branch

The database branches with your code. Merge the PR, merge the database. Delete the branch, delete the data.


Option 3: Ephemeral Containers in CI

For CI pipelines, spin up a fresh database per test run:

1# GitHub Actions example
2services:
3 postgres:
4 image: postgres:17
5 env:
6 POSTGRES_PASSWORD: test
7 ports:
8 - 5432:5432

Every CI run starts clean. No flaky tests from leftover data. No "re-run and it passes" nonsense.


Option 4: Testcontainers

For integration tests, [Testcontainers](https://testcontainers.com/) spins up real database containers programmatically:

1import { PostgreSqlContainer } from "@testcontainers/postgresql";
2
3const container = await new PostgreSqlContainer().start();
4const connectionString = container.getConnectionUri();
5// Run tests against a fresh, isolated database
6// Container is destroyed after tests


"But We Need Shared Data for Integration Testing"

No, you need a shared schema and a shared seed script. Not a shared database.

1# Every developer runs this against their own database
2bun db:push # Apply schema
3bun db:seed # Seed consistent test data

If your seed script is deterministic, every developer has the same starting state. If someone needs to test with 10,000 rows, they modify their local seed — not yours.

The schema lives in code. The seed lives in code. The database is disposable infrastructure.


"Managed Databases Are Expensive Per-Developer"

This is the argument that keeps teams on shared databases, and it doesn't hold up:


Approach

Cost Per Developer

Docker Compose (Postgres)

$0

Neon (free tier branches)

$0

SQLite for dev (if applicable)

$0

The "free" shared database costs you hours of debugging, days of onboarding friction, and weeks of accumulated workarounds per quarter. The ROI on isolated databases is not even close.


The Principle

Your development database is not infrastructure to be shared. It's a tool to be owned.

Each developer should be able to:

- Reset their database in seconds

- Run any migration without asking permission

- Seed any data scenario they need

- Break things without breaking anyone else

If your setup doesn't allow this, it's holding your team back. The fix is straightforward: put a `docker-compose.yaml` in the repo, document the one-liner to start it, and delete the shared connection string from your team's `.env`.

Your future self — staring at a clean database after a `docker compose down && docker compose up -d` instead of debugging someone else's leftover data — will thank you.