How seemingly perfect data models become catastrophic bottlenecks as systems scale, and the trade-offs we must consciously choose rather than discover at 3am during an incident.
The cruelest technical debt doesn't announce itself with failing tests or incorrect results. It hides in plain sight within clean, functional code that passes every test. Data model debt is particularly insidious because it takes months or even years to reveal itself, often when everything else has been built on top of it, making changes astronomically expensive. This is the story of how well-intentioned design decisions become systemic bottlenecks, and what we can learn to avoid these pitfalls.
The Multi-Tenant Trap
Our multi-tenant system had been running smoothly for over a year with hundreds of tenants, each with roughly fifty thousand records. The schema was clean, normalized, and performed well. Then we onboarded a large enterprise customer with fifty million records in the same table. Suddenly, queries that ran in 200ms for every other tenant were taking 45 seconds for them.
The problem wasn't bad code. The schema had quietly encoded an untested assumption: that tenants would be roughly similar in size. This assumption had never been written down, never questioned, never tested. It was just... assumed, the way you assume things that have always been true until they suddenly aren't.
The conceptual fix was straightforward—partition the data and route large tenants differently. The implementation took months because everything else had been built around that original schema. Every query, index, and join had opinions about how the data was structured. We ended up running two schemas simultaneously for six weeks to migrate without downtime. This was the most expensive technical debt I've ever watched get paid off.
The Normalization Paradox
Data models are typically designed for the use cases teams can see right now. This is almost always the wrong frame because the use cases that matter at scale are the ones nobody anticipated. The pattern is painfully consistent:
- System starts with a well-normalized schema following third normal form
- At moderate load, everything works correctly
- Volume grows, and queries that touched thousands of rows now touch millions
- Joins that were fast become table scans
- The query planner makes surprising choices
- You're reading execution plans at midnight trying to understand why a query that used to take 80ms now takes 12 seconds
Normalization optimizes for write correctness and storage efficiency. Not read performance at volume. When your read load is enormous relative to your write load—which it is in basically every user-facing system—these goals pull in opposite directions. You discover which one your schema actually prioritized the hard way, usually on a Friday.
The Cardinality Time Bomb
I've made this exact mistake myself. A permissions table with one row per user-resource pair seems perfectly reasonable when users have tens of permissions. But when fine-grained access becomes a product requirement and users can have thousands of permissions, that table grows enormous fast.
Every permission check becomes a large query, and because authorization sits in the critical path of almost everything, a slow permissions table makes the entire system feel sluggish in ways that are incredibly hard to diagnose. You end up chasing phantom performance issues across half the codebase before someone finally traces it all the way back to a table that's just too big to query efficiently anymore.
When designing a schema, there are two critical questions: "What cardinality do I expect?" and "What cardinality could this legitimately reach?" They're not the same question. The first is optimistic. The second saves you.
The Correctness vs. Performance Tradeoff
If producing the accurate answer requires joining five tables and aggregating across millions of rows, correctness has a real cost—a cost you pay on every single request. Your options at that point are denormalization, pre-computation, materialized views, or derived tables. Each introduces consistency challenges that the normalized schema never had.
The actual tradeoff isn't "normalization vs. performance." It's "easy to get right" vs. "fast under real load." Choosing consciously is very different from discovering the tradeoff at 3am during an incident.
The Migration Minefield
A migration that takes 30 seconds in development can take three weeks in production. Not because the operation changed, but because the table grew from thousands of rows to billions, and suddenly every part of the process has consequences you never thought about:
- Locking: DDL operations on large tables can block reads or writes even briefly. "Briefly" on a hot table cascades into timeouts across the entire system within seconds.
- Backfill: Writing a new column's default value to a billion rows generates massive I/O that competes directly with live traffic.
- Dual-write period: Running old and new schemas simultaneously to migrate without downtime reveals every single implicit assumption in your application code.
Schema changes rarely happen when things are going well. They happen when things are on fire. Plan for it before you're in that situation.
Lessons from the Trenches
If I could tell my past self anything about data modeling, it would be:
- Design for read patterns, not just write patterns. Know which queries are on your critical path and whether your schema serves them cheaply or with heroics.
- Document your cardinality assumptions explicitly. Write down "This table is expected to have X rows per tenant. At Y rows, query Z will degrade." If you can't fill in those numbers, the answer to "will this hold at scale?" is unclear.
- Separate operational and analytical models early. The schema optimized for transactional correctness is rarely the schema optimized for reporting. Trying to serve both from one schema is a compromise that satisfies neither at volume.
- Treat major schema changes as operational projects, not technical tasks. They need a plan, a rollback strategy, a communication plan, and ideally someone who has done it before and can warn you about the part you haven't thought of. There's always a part you haven't thought of.
Looking Ahead
In the next installment of this series, we'll explore why access control is one of the most quietly expensive places for schema assumptions to go wrong at scale. Spoiler: 15 roles became 340. The pattern is familiar, but the implications are particularly nasty.
What data model decision have you had to undo the hard way? I want the painful stories. The "we ran two schemas for six weeks" stories. The more awful, the more instructive.

For teams dealing with these challenges, modern database platforms like MongoDB Atlas offer flexible schema options and horizontal scaling capabilities that can help mitigate some of these issues. However, no technology can substitute for thoughtful design and explicit acknowledgment of scaling assumptions.

Comments
Please log in or register to join the discussion