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)34# You get this diff (because someone else altered the table):5~ ALTER COLUMN "name" TYPE TEXT -- not your change6+ 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-alpine4 environment:5 POSTGRES_USER: dev6 POSTGRES_PASSWORD: dev7 POSTGRES_DB: app8 ports:9 - "5432:5432"10 volumes:11 - pgdata:/var/lib/postgresql/data1213volumes: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 example2neon branch create --name feature/new-orders3# 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 example2services:3 postgres:4 image: postgres:175 env:6 POSTGRES_PASSWORD: test7 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";23const container = await new PostgreSqlContainer().start();4const connectionString = container.getConnectionUri();5// Run tests against a fresh, isolated database6// 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 database2bun db:push # Apply schema3bun 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.