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" was spread 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 structure of the data under it is, and no amount of careful coding on top of a bad structure 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 storing the data in pieces that do not match 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 work out the same series 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 lookup 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 in a structure closer to those questions.
Signal two: one field quietly has two meanings
Watch for a single column that started out meaning one thing and gained 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 no longer fits its own definition.
The warning sign 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 only in someone's memory 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 permanent: a boolean added beside the original field, then another one next to that.
A field with two meanings is not wrong on day one. It becomes wrong the moment a third meaning shows up and there is no correct place 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 restoring 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 its cost grows 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 finds the bug first.
The signal is a bug report 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 counting 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 stops matching 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 decision. 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 a sign the model needs to change.
If a status field gets one exception added, 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 going into more tables, 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 structure in place does not stay fixed. It grows 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 hoping it works. The reliable approach is to add the new structure alongside the old one, write to both while every read path is moved over one at a time, and remove the old structure 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 under the new structure. 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 something set 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.


