Skip to content
BACK TO JOURNAL
Engineering10 MIN READ · APRIL 2026

Your Schema Is a Contract You
Can't Roll Back

You can redeploy code in seconds. You cannot undo a migration. The decisions that outlast your code aren't in the application layer, they're in the schema.

Fojan Studio

The most dangerous line of code in a production system isn't in your application. It's in a migration file. `ALTER TABLE users DROP COLUMN legacy_id`, six words that, once executed against a live database, are effectively irreversible.

Engineers treat code with a kind of comfortable disposability. It can be changed, reverted, hot-patched, redeployed. Bad code costs time. Bad schema costs data, downtime, and the trust of every team that built something on top of it.

Yet schema design rarely gets the same rigor as API design. The entity fields are decided quickly, the migration is written, it runs in the next deploy, and everyone moves on. The consequences often don't surface until months later, when the quick decisions of week two have become the load-bearing walls of a system nobody wants to touch.

Why schema is different from code

Code and schema have a fundamentally different relationship with time. When you change code, the new version replaces the old. When you change schema, you're modifying state that already exists, rows that were written by the old code, that are being read by the new code, possibly at the same moment the migration is running.

This is why schema changes are the most operationally complex part of most production deployments. They have to be backwards compatible with the running version of the code during the deployment window. They have to account for data that was written under different assumptions. And they have to do all of this without locking tables in a way that brings down production.

A schema is not just a data structure. It's an implicit contract between every piece of code that reads or writes to it, present and future. Breaking that contract silently is how production incidents happen.

The additive principle

The safest general rule for schema evolution is: prefer adding over changing, and changing over removing. New columns can be added without breaking existing reads. Existing columns can be made nullable without breaking existing writes. Columns can almost never be safely removed until every reference to them has been purged from the codebase, and verified.

This sounds obvious until you're three sprints into a refactor and someone wants to clean up the old columns. The question is never 'is this column still used?' The question is 'am I certain this column is no longer used, including by the version of the code that's currently running on the three servers that haven't been restarted yet?'

  1. 01Adding a nullable column: safe. The existing rows get NULL, existing queries ignore it.
  2. 02Renaming a column: dangerous. Every query that references the old name breaks immediately.
  3. 03Changing a column type: dangerous without a migration plan. Implicit casts don't always work the way you expect under load.
  4. 04Dropping a column: safe only after the code that references it has been deployed and verified in production, then a second deploy removes the column.

The hidden cost of 'we'll refactor later'

Every schema shortcut taken in month one compounds into migration complexity in month twelve. A `type` column that started as a boolean and got extended to a string and then an enum and then a foreign key reference is a column with four different implicit contracts baked into its data, and every query that touches it has to reason about all four.

The refactor conversation always sounds the same: 'We should fix the schema, but we can't take the downtime right now.' 'There are too many places that reference this to change safely.' 'We'd need to backfill 40 million rows.' These are not excuses, they're accurate descriptions of the actual cost of the original shortcuts. The debt was real. It was just deferred.

The question to ask upfront is not 'what does this column need to store now?' It's 'what might this column need to store, and what queries will need to run against it?' A column that stores user state will eventually need to be queried for all users in a given state. If that column isn't indexed, that query becomes a full table scan. If the values aren't normalised, that query becomes unpredictable.

The expand-contract pattern

When a schema change is unavoidable, a rename, a type change, a structural refactor, the expand-contract pattern is the safest path through it.

  1. 01Expand: add the new column alongside the old one. Write to both. Read from the old.
  2. 02Migrate: backfill the new column for all existing rows.
  3. 03Contract: update the application to read from the new column. Verify in production.
  4. 04Clean up: drop the old column in a separate deployment, after verifying nothing references it.

This is slower than just renaming the column. It requires two or three deployments instead of one. But it's safe, each step is independently reversible, each step can be verified before the next one runs, and no step locks the table for long enough to cause visible downtime.

What good schema design actually requires

The engineers who design schemas well tend to ask the same questions. What are the most frequent read patterns, and is the schema indexed to support them? What does this column represent conceptually, not just technically? How will this table look at 10x current volume? What happens to existing rows when this new column is added?

They also tend to document decisions, not in code comments, but in architecture decision records or schema changelogs. A column name is not documentation. 'We added this column in Q3 2025 to support multi-org billing; the previous approach stored org context in the request header which made audit trails unreliable' is documentation.

The engineers who inherit your schema in two years aren't trying to understand what the column stores. They're trying to understand why it was designed that way, so they know whether their change is safe or not.

The schema is the most honest part of your system. It doesn't lie, it doesn't abstract, and it doesn't forgive shortcuts. Treat it accordingly.