How to tell if your data model needs to change

We have opened codebases where a single "add a filter to this list" ticket touched nine files and took three weeks. The filter itself was trivial. The reason it took three weeks was that the answer to "which orders belong to which customer" lived across four tables that were never designed to answer that question together, and every new feature had been quietly working around that gap since the model was first set.
That is the pattern worth naming: sometimes the code is not the problem. The shape of the data underneath it is, and no amount of careful coding on top of a bad shape will make the ninth workaround go faster than the first.
Here is how to tell which one you are dealing with, and what to do once you know.
Signal one: a query that has to touch too many tables to answer a simple question
If answering "what does this customer own" requires joining five tables, and joining them again slightly differently for "what does this customer owe," the model is describing the world in pieces that do not line up with the questions the product actually asks.
This shows up first as a performance problem, because more joins mean more work for the database on every request. But the deeper cost is that every engineer who touches that area has to re-derive the same chain of joins by hand, and each one gets it slightly wrong in a different place. A single slow query is often just a missing index, and adding the index is the right fix. A model problem is when the same multi-table chase shows up in every feature that touches that relationship, because the tables were built around how the data was entered, not around how it gets read.
The fix for an index is minutes. The fix for a model is deciding which questions the data needs to answer quickly, and storing it closer to that shape.
Signal two: one field is quietly doing two jobs
Watch for a single column that started out meaning one thing and grew a second meaning nobody wrote down. A status field that began as "draft, published, archived" and now also encodes "needs review" through a special combination of status plus a null date somewhere else is a model that has outgrown its own definition.
The tell is a comment or a code review note that says something like "if status is X and the date field is empty, that actually means Y." Every one of those sentences is a rule that exists in someone's head and in scattered if statements, instead of in the schema where the next person could find it. The day a new state appears that the combined field cannot represent, the workaround becomes a permanent fixture: a boolean bolted on beside the original field, then another one next to that.
A field doing two jobs is not wrong on day one. It becomes wrong the moment a third meaning shows up and there is nowhere honest to put it.
Signal three: history is being reconstructed instead of recorded
Some products need to know not just what is true now, but what was true last month: a price at the time of purchase, a plan a customer was on when they filed a support ticket, an approval state at the moment a decision was made. If the model only stores current values, that history has to be rebuilt after the fact from logs, backups, or guesswork, and it is often wrong.
The signal here is a support or finance request that cannot be answered directly from the database. It has to be answered by pulling an old backup, or by reasoning about "well, the price probably changed around then." Every time that happens, someone is doing by hand what the schema should have been recording as a normal part of the write.
This one is easy to miss early, because a product without history requirements looks identical to one that has them and simply has not been asked yet. The moment it is asked once, it will be asked again.
Signal four: a foreign key points at the wrong owner
This one is subtle and it compounds quietly. A project_id on a table that should really belong to a workspace, because projects can move between workspaces but the model assumed they never would. It works fine until the first time a project actually does move, and then every query that assumed the old ownership has to be found and fixed, one at a time, usually by whoever hits the bug first.
The signal is a bug report shaped like "why does this record still show up under the old owner." That is not a logic bug. It is the schema recording a relationship that the business no longer treats as fixed, and every place that trusted the old relationship needs to be found before the record type can move freely.
Signal five: a number is computed by walking rows instead of being stored
If "how many active members does this team have" is answered by counting rows every single time it is asked, that is fine until the count gets asked often enough, or the counting logic gets copied into a second place and drifts from the first. The signal is two features disagreeing about the same number, because two different pieces of code implemented "count the active members" slightly differently.
A stored, maintained count that updates when membership changes is a model decision, not a query optimization. Once a number matters enough to appear in more than one place, it belongs in one place, updated deliberately, not recomputed differently by whoever wrote each screen.
The decision rule: patch once, migrate on the second patch
None of these five signals means "migrate immediately." A workaround that solves the problem in front of you and never needs a second one for the same reason was the right call. Most code has a few of these, and that is normal engineering, not a mistake.
The rule we use when scoping a build: the first patch on a table is a patch. The second patch on the same table, for the same underlying reason, is the model asking to change.
If a status field gets one exception bolted on, that is a patch. If it gets a second exception six weeks later because the first one did not generalize, the field is not describing what it needs to describe, and no third exception will fix that either. If a query gets a join added to reach data it was not designed to reach, that is a patch. If the next feature in the same area needs another join reaching further still, the tables are being asked to answer questions they were not built for, and adding a fifth join will not be the last one.
The reason this rule works is that a patch is local and a model change is not. A messy function can usually be rewritten in isolation without touching anything else that depends on it. A table's shape is read and written by every feature built on top of it, so the cost of leaving a bad shape in place does not stay fixed. It compounds with every feature added afterward, because each new feature either inherits the same workaround or invents its own version of it.
What migrating well actually looks like
A model migration does not have to mean a maintenance window and a prayer. The reliable approach is to add the new shape alongside the old one, write to both while every read path is moved over one at a time, and remove the old shape only once nothing depends on it anymore. It takes more steps than changing the table directly, but it means the system keeps working the whole way through, and there is a safe point to stop at any step if something looks wrong.
The part that actually makes this safe is having a way to check that behavior did not change underneath the new shape. In the work we do, that check is a suite written directly from the specification: what must remain true no matter how the data is stored. Running that suite against the system before and after the migration is how a missed case gets caught in a test run instead of in a customer's account, because the model changed but a rule it was quietly enforcing did not get carried over.
The teams that get this right treat the data model as a decision worth revisiting, not a foundation poured once and left alone. Software gets easier to build on top of a model that fits the questions being asked of it, and harder to build on top of one that does not, no matter how careful the code above it is.


