Auditing Database Migration Safety and Fixing My Prisma Workflow

The migration check I should have done earlier

I paused feature work today and audited the way I was handling database migrations. Not glamorous. Very necessary.

The short version: I was being too casual with Prisma. I had been using a mix of schema edits, local database nudges, and command-line muscle memory that worked while the app was small, but would have become risky as soon as more data, environments, or collaborators entered the picture.

So I stopped and fixed the workflow properly. The project now uses the normal prisma migrate dev flow for local development, migration files are treated as part of the source code, and production will only get migrations through prisma migrate deploy. Simple rule. Much safer.

This is one of those builder moments where the app did not look different after the work was done, but the foundation got a lot less shaky.

Where the risk showed up

The audit started because I was moving quickly and noticed a bad smell: the database schema and the Prisma schema were not being treated like a tracked, repeatable history. I could make the app run locally, but I could not confidently answer a basic question: if I cloned this repo fresh tomorrow, could I recreate the exact database structure from committed files?

That answer needs to be yes.

With Prisma, there are a few ways to change the database. Some are meant for prototyping. Some are meant for migrations. I had blurred the line.

  • prisma db push is handy when shaping a schema fast, especially early on.
  • prisma migrate dev creates migration files and applies them in local development.
  • prisma migrate deploy applies committed migration files in production or staging.
  • Manual database changes can save a minute now and cost hours later.

I was not in full disaster mode. Nothing had blown up. But this is exactly when I like to fix process problems, before the bug report is “production data is weird and nobody knows why.”

The old workflow that needed to go

The old pattern was very builder-brained. Change schema.prisma. Run a command. Inspect the database. Keep moving. If something looked wrong, adjust and try again.

That feels productive because the feedback loop is fast. But there is a hidden trap: the database becomes a scratchpad, not an artifact that can be recreated safely.

The biggest issue was migration history. If I use db push to change the database, Prisma can align the current database with the schema, but it does not create a migration file. That means the repo does not contain the story of how the database got from version A to version B.

That story matters. It is what lets another environment apply the same change later. It is also what lets future me understand why a table, index, relation, or enum exists.

I also had to remind myself that “works locally” is not the same thing as “can be deployed safely.” Local speed is good. Repeatability is better.

The new rule: migrations are source code

The fix was mostly mindset, then commands.

From now on, every schema change that matters goes through a migration file. That file gets reviewed, committed, and shipped with the code. The database is no longer something I poke until it behaves. It is built from a known set of steps.

My local flow now looks like this:

  1. Edit prisma/schema.prisma.
  2. Run npx prisma migrate dev --name descriptive_migration_name.
  3. Inspect the generated SQL inside prisma/migrations.
  4. Run the app and test the feature that depends on the schema change.
  5. Commit both the schema change and the migration folder.

That is the boring path. I want boring here.

A database migration should not be a surprise. It should be a plain text change I can read before it touches anything valuable.

The command split finally clicked

I made a small table for myself because this is the kind of thing that gets fuzzy when I am moving fast.

CommandWhere I use itWhat I expect from it
npx prisma migrate devLocal developmentCreates and applies a new migration, then updates Prisma Client.
npx prisma migrate deployStaging and productionApplies already committed migrations without creating new ones.
npx prisma db pushShort-lived experiments onlySyncs schema to the database without migration history.
npx prisma generateAfter schema changes when neededRegenerates Prisma Client.
npx prisma migrate resetLocal reset onlyDrops and rebuilds the local database from migrations.

The big correction was this: migrate dev is not for production, and db push is not my default development habit anymore.

That alone removes a lot of future confusion.

The first proper migration pass

I started by checking the current Prisma schema against what I believed the app needed. Models, relations, indexes, defaults, nullable fields, all of it.

Then I ran the proper local migration command with a name that described the change instead of some vague timestamp-only folder I would hate later:

npx prisma migrate dev --name audit_database_migration_safety

Prisma generated the migration folder and applied it locally. Then I opened the SQL. This is the part I used to skip too often. Not anymore.

I looked for the stuff that can hurt:

  • Dropping columns unexpectedly.
  • Renaming fields in a way that Prisma might interpret as drop-and-add.
  • Changing nullable fields to required without a backfill plan.
  • Adding unique constraints to columns that may already contain duplicates.
  • Changing relation behavior without checking delete paths.
  • Creating indexes that make sense for real queries, not just vibes.

That review caught one assumption right away. A field I wanted to make required was safe for new records, but existing local data had blanks. In production, that would be worse. The fix was not hard, but the migration needed to be staged properly: add the field as nullable, backfill it, then make it required in a later migration if the product truly needs that guarantee.

Small catch. Big lesson.

Shadow database behavior was part of the audit

One Prisma detail I paid attention to during this cleanup was the shadow database. When migrate dev runs, Prisma uses a shadow database to compare migration history and detect drift. Drift means the database schema does not match what the migration files say should exist.

That is exactly the kind of warning I want during development.

If the local database has been manually changed, Prisma can complain because the history and reality no longer line up. That can feel annoying in the moment, but it is doing the job. It is telling me I have an untracked change.

I would rather deal with that locally than find out later when a deploy fails.

What broke during the switch

The first snag was local data. Since I had treated the local database like a sandbox, it had some odd records. Some were from old test flows. Some were from half-built features. A few no longer matched the shape I wanted.

That made the migration louder than expected.

I had two choices: preserve the messy local data or reset and rebuild from migrations. Since this was local development and not production, I chose the clean path:

npx prisma migrate reset

That drops the local database, reapplies migrations, and gives me a clean baseline. I only do this locally. Never casually on anything with real user data.

The second snag was naming. I had to slow down and name migrations based on the product change, not the mood I was in. Names like add_user_billing_profile or create_project_invites are useful. Names like fix_schema are future trash.

The third snag was impatience. I wanted to get back to building the feature. But this is part of building the feature. If the data layer is shaky, every feature above it inherits that wobble.

The safety checklist I added

I wrote a short checklist into my working notes. Nothing fancy. Just the steps I want to follow before a schema change gets merged.

  1. Make the schema change in prisma/schema.prisma.
  2. Create the migration with npx prisma migrate dev --name clear_name_here.
  3. Open the generated SQL and read it.
  4. Check for destructive changes, especially drops, renames, required fields, and unique constraints.
  5. Run the app against the migrated local database.
  6. Run any seed script if the feature depends on starter data.
  7. Reset locally at least once when the migration history has changed heavily.
  8. Commit the migration folder with the code that needs it.
  9. Use migrate deploy outside local development.

This gives me a clear path when I am tired, distracted, or moving too fast. Future me needs rails.

How I am thinking about destructive changes now

Destructive migrations are the danger zone. Dropping a column, changing a type, tightening a constraint, or renaming a field can be totally fine in a toy database and painful in a live app.

The main shift is that I no longer treat those changes as one-step edits unless I am sure the data can be lost.

For example, instead of renaming a field and hoping everything maps perfectly, I prefer a safer multi-step path:

  1. Add the new field as nullable.
  2. Write code that populates both old and new fields for a while.
  3. Backfill existing rows.
  4. Switch reads to the new field.
  5. Remove the old field in a later migration.

That may feel slower, but it protects live data and gives me rollback options. It also makes deployments less dramatic. I like boring deployments.

Production gets a different command

This was the line I drew clearly: production should not create migrations.

Production should only apply migrations that already exist in the repo and have already been reviewed. That means the deployment command is:

npx prisma migrate deploy

No prompts. No migration generation. No local development behavior sneaking into the deployment process.

Before running migrations against a real environment, I also want a backup or restore point. If the app is still tiny, that may sound like overkill. It is not. The habit is easier to build early than to bolt on after customers are already using the product.

The repo feels healthier now

After the cleanup, the repo tells a better story. The Prisma schema shows the current shape of the database. The migration folders show how it got there. The commands are split by environment. The local database can be rebuilt from scratch.

That last part is a big win. If I can reset locally and the app still boots, seeds, and runs, I trust the setup more. If I cannot rebuild the database from committed files, the project is carrying hidden state.

Hidden state is where weird bugs breed.

My working migration habit from here

This is the rhythm I am sticking with now:

  • Use migrate dev while building locally.
  • Read every generated migration before committing it.
  • Keep migration names specific and product-focused.
  • Avoid manual database edits unless I also capture the change properly.
  • Use staged migrations for risky data changes.
  • Run migrate deploy for staging and production.
  • Keep backups in the deployment plan.

The funny thing is that none of this is advanced. It is just discipline. But when I am vibe coding and moving fast, discipline has to be designed into the workflow. Otherwise I will optimize for the next ten minutes and create a mess for next week.

Today’s win is not a flashy feature. It is a safer database path. The app is now easier to rebuild, easier to deploy, and easier to trust. That is real progress.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top