Engineering

How to tell if your data model needs to change

Editorial · Reveneau · October 31, 2026

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.

Common questions

How do I know if a slow query is a data model problem or an indexing problem?

Try the index first, since it is the cheaper fix and it is correct more often than people expect. If the query is slow because a value is not indexed, adding the index solves it in minutes. If the query is slow because answering it requires walking through three or four tables that were never meant to answer that question together, no index fixes that: the tables need to carry the answer more directly, which is a model problem.

What is an N+1 query and why does it signal a model problem?

An N plus one query happens when code fetches a list of N records, then makes a separate database call for each one to get related data, turning what should be one query into N plus one. A single occurrence is usually a code mistake, fixable by joining or batching. A model problem is when the same pattern shows up in every feature that touches that relationship, because the tables were never structured to answer that kind of question together.

Is it ever fine to add a workaround instead of changing the model?

Yes. A workaround that solves the immediate problem and never needs a second one for the same reason was the right call. The signal to watch is repetition: the first patch on a table is normal engineering, a second patch on the same table for the same underlying reason is the model telling you it needs to change.

How long does a data model migration usually take compared to a feature?

It depends entirely on how many places read and write the table being changed, so there is no fixed ratio. What is true in every case is that the cost grows with every feature built on top of the old shape, so the migration gets more expensive the longer it is deferred, not less.

Can I migrate a data model without downtime?

In most cases yes, using an approach where the new shape is added alongside the old one, both are kept in sync while every write path is moved over, and the old shape is removed only after every reader has switched. It takes more steps than a direct change, but it avoids a maintenance window and lets you roll back at any point before the old shape is removed.

What is a status field doing two jobs, and why is it a problem?

It means one column is being used to answer two different questions that happen to overlap, like a single status field tracking both whether an order was paid and whether it was fulfilled. It becomes a problem the day a state exists that the combined field cannot represent, like paid but not yet fulfillable, and the workaround is usually a null check or a second flag bolted on beside it.

Should a startup worry about data model quality before it has real users?

Worry about it enough to make deliberate choices, not enough to over-engineer for scale that may never come. The realistic goal early on is to name the two or three relationships that are core to the product and get those right, while accepting that peripheral tables will need to change later as real usage teaches you things a spec could not.

What is the difference between technical debt in code and technical debt in a data model?

Code debt is usually contained: a messy function can be rewritten in isolation without touching anything else. Model debt spreads, because every feature that reads or writes the table inherits its shape, so the cost of a bad model compounds with each feature built on top of it rather than staying fixed in one place.

How do evals help with a data model migration?

An eval suite written from the specification checks that the system still behaves correctly against every case that mattered before the change, which is exactly the risk in a model migration: the shape changes but the behavior must not. Running the full suite against both the old and new model before cutting over is how you catch a case the migration missed, rather than finding it in production.