Last week we wrote about the mirror column — moving our customer lifecycle onto a new per-product model while keeping the old status column as a synchronized copy, so none of the readers had to change. That post ended on a comfortable note: the only genuinely risky step, actually dropping the old column, you get to defer indefinitely.

This week we stopped deferring. The column is gone. We'd migrated the writers long ago; the readers had since moved to the new model one by one — so the application code was the most predictable part of the drop. Most of the remaining risk turned out to live around the code: in scripts, docs, the migration graph, and the deploy itself.

If you're sitting on your own "can we delete it yet?" moment, here's the honest answer: yes — when everything around the code says so too, and that's a wider sweep than the models file.

The code was ready. The periphery wasn't.

By drop day, every reader in the app had been migrated to the new model, behind two small query helpers, and the full test suite was green. A thorough review of the diff then went looking for trouble — and found plenty. Almost none of it was in the application code.

What it found instead:

  • Operations scripts still reading the dead column. Two of our named prod scripts printed the old field over SSH. One of them was the publish script — it would have completed the publish successfully, then crashed on the final status print, reporting failure for work that had succeeded. That's the worst kind of breakage: a healthy system reporting failure.
  • A migration-graph collision with the trunk. While our drop migration sat on a branch, unrelated migrations landed on the integration branch. Git merged the two cleanly — different filenames, no textual conflict — and then manage.py migrate refused to run at all: two leaf nodes, "conflicting migrations detected." The breakage only appears when the migration runner looks at the merged graph, which for us means CI or deploy. A merge migration fixes it in two minutes, but only if you expect it.
  • The deploy window. Our deploy is pull, migrate, restart. Between the DROP COLUMN and the restart, the old process is still running old code — and an ORM selects every mapped column on every query. For those seconds (longer, if a background worker is draining a long task), every read of that table errors. At our size the answer was simply to deploy in a quiet window; at a bigger size you'd two-phase it: ship the code that stops reading the column first, drop the column in the next release.
  • The replacement query is new code, not a rename. The old column was NOT NULL with a default, and every query leaned on that without anyone deciding to. Its replacement — a subquery against the new model — can be NULL, and exclude(value__in=[...]) silently drops NULL rows. A report that used to list every record would now quietly omit exactly the broken ones. Same shape, different semantics. We coalesced the subquery to the old default so the reports keep behaving.
  • Docs that still described the mirror. Including the instructions our own tooling reads. Stale docs don't crash, which is what makes them the longest-lived leftovers.

The grep that matters is wider than your app

The discipline we'd follow next time, compressed:

  1. Grep beyond the application. Scripts, cron entries, anything that shells into production, dashboards, docs. The app code is where you looked all along; the leftovers are where you didn't.
  2. Sync with the trunk immediately before generating the drop migration — and expect to need a merge migration anyway. The graph conflict is invisible to git.
  3. Treat the replacement read as new code. NULL-ability and join semantics are decisions, not details. Write the query helper once, give it the old column's defaults, and route everyone through it.
  4. Pick the deploy moment deliberately. A column drop is the one migration where "old code against new schema" fails on every query, not just the new feature's.

The mirror earned its keep twice

The mirror pattern made the original move boring, and it made the drop cheap to delay until we were genuinely ready — months of "is anything still reading this?" cost us nothing but one synchronized write.

But deferring the drop doesn't shrink the drop. The column had spent years quietly accumulating dependents, and not only in the code we review — in the scripts we run during incidents, the docs we hand to new tools, and the assumptions a NOT NULL default lets everyone make without noticing. Deleting a column is easy. Retiring one is a sweep of everything that ever trusted it.