Why Most People Get Database Design Wrong

I spent six months tracking down a bug where a reporting query would sometimes return double the expected rows. The database was perfectly normalized on paper. The problem was that the application layer was executing three separate writes to different tables, and between those writes, another process would read the partially committed state. The design looked correct. The reality was a mess of race conditions nobody had bothered to model. This is what happens when people treat database design as a theoretical exercise rather than something that actually runs under load, at 2am, when someone has accidentally submitted a form twice.

Database Systems A Practical Approach To Design

The core of practical database design comes down to a few principles that sound obvious until you are the one responsible for fixing the system when it breaks. You model the real entities in your business. You normalize to at least third normal form to eliminate update anomalies. You add indexes where queries actually run, not where a textbook says they might. You choose between SQL and NoSQL based on your actual access patterns, not because one is newer or trendier. The first step is figuring out what your data actually represents. Start by listing every noun you encounter in a requirements meeting. Customer, product, order, payment, shipment. Then figure out which ones are entities and which ones are just attributes masquerading as entities because you have enough of them to justify a table. I once saw an entire customer service dashboard built around a table called "CustomerAddress" that was actually just five text fields a developer didn't want to denormalize. It grew to four million rows over eighteen months and became impossible to maintain. Normalization is where most people stop and call it a day. That is a mistake. Normalization prevents update anomalies, yes, but it also creates joins that hurt read performance. The trick is knowing when to normalize and when to deliberately break the rules. I normalize relationships between distinct business entities. I denormalize when I have read-heavy query patterns that would otherwise require seven table joins just to render a single page. The key is making the decision intentionally, not accidentally through laziness.

The Normalization Tradeoff Nobody Talks About

Third normal form is not the endgame. It is the default position. You should be comfortable explaining to a junior developer why you deliberately created a denormalized summary table even though the database is "improperly designed." That conversation happens constantly in production environments. Here is a specific example. We were building a transaction history view for a financial product. The relational model was clean. Each transaction had a foreign key to an account, each account had a foreign key to a customer, and each customer could have multiple accounts. The query to display a customer's full financial picture required joining across five tables with aggregate functions spread across two of them. Under light load it took 80 milliseconds. Under moderate load it climbed to 4.2 seconds, and under peak load the connection pool exhausted and the whole page timed out. The fix was not optimization. We added a materialized summary table that stored a precomputed view of each customer's total balances, transaction counts, and last activity date. The summary table updates through a database trigger that fires on every insert or update to the transaction tables. The read query becomes a single table scan against the summary instead of five joins. Page load dropped from 4.2 seconds to 12 milliseconds under the same conditions. The trigger adds roughly 3 milliseconds to each write operation, which was completely acceptable because write frequency was one hundredth of the read frequency.

This is the practical approach: model cleanly first, then optimize based on actual performance data, not hypothetical scenarios. I have never seen a case where premature denormalization caused more problems than an overloaded normalized schema. The rule of thumb I use is simple. If a query runs acceptably in production under expected load, leave it alone. If it does not, measure first, then decide whether to denormalize, add a view, introduce caching, or rewrite the query. Never guess which approach will work.

Indexing Without Losing Your Mind

Indexes are the most abused component in database design. Every developer who has ever heard "add an index" will happily add one to every column they touch. This is how you create a database that writes slowly and takes up more disk space than the actual data. A composite index on columns A and B will serve queries that filter on A alone, or on both A and B in that order. It will not serve queries that filter on B alone. I have spent entire mornings explaining this to developers who were baffled why adding an index on customer_id, created_at did not speed up their date range queries. The query plan was ignoring the index entirely because the query filtered only on created_at, which happened to be the second column in the index. The practical rule is this. Index the column or combination of columns that your most frequent queries actually filter on. Not the columns you think might be useful someday. Not every foreign key in the table. Test with explain plans. Check the query plan before and after adding an index and verify that the optimizer is actually using it. An index that is not used is just dead weight on every write operation.

I also keep index count per table below twelve unless there is a compelling reason. Beyond that point, the marginal benefit of each additional index decreases while the write penalty increases linearly. There is a crossover point where adding more indexes actually slows down writes without providing meaningful read improvement. You find that point through testing, not theory.

Choosing Between Relational and Document Models

The SQL versus NoSQL debate is mostly noise. The real question is whether your data access patterns involve consistent complex relationships between entities, or whether they involve reading and writing self-contained documents where the relationships are handled at the application layer. If you are building a system where orders relate to line items, line items relate to products, products relate to categories, and categories relate to suppliers, and you frequently need to query across all of these relationships, a relational database with proper foreign keys and joins is the right choice. The referential integrity constraints are not bureaucracy. They are the thing that prevents your order totals from becoming incorrect when a product price changes retroactively. If you are building a content management system where each article is largely self-contained and relationships are better managed through embedded references or application-level lookups, a document database like MongoDB gives you the flexibility to evolve the schema without migrations. The schema can change per document without breaking existing records. That is valuable when you have fifty different content types sharing a collection.

The mistake I see most often is choosing a document database because the team wants to move fast and avoid migrations. Speed of initial development is real, but so is the cost of schema evolution later. Relational databases have handled schema migrations for decades. Tools like Flyway and Liquibase make the process mechanical rather than traumatic. Document databases do not have mature tooling for the equivalent workflow, and that gap becomes painful when your data structure needs to change after the initial prototype stage.

Handling Concurrency Without Losing Sleep

Concurrency issues are the difference between a database that works on a developer's machine and one that works in production. The textbook examples always assume a single user. Reality involves twenty simultaneous writers updating related records. The most practical approach I have found is optimistic locking with version columns. Every updatable table gets a integer version field that increments on each update. When your application goes to save a change, it includes the original version number in the WHERE clause. If the version has changed, the update affects zero rows and you know another process modified the record. You handle the conflict by either reloading the data and retrying, or by presenting a merge conflict to the user depending on the context. Database-level row-level locking with SELECT FOR UPDATE is another option, but it tends to cause blocking issues under high concurrency. I prefer optimistic locking for read-heavy workloads with infrequent write conflicts. It scales much better because it does not hold locks during the user's thinking time between reading and writing.

There is one edge case that is worth remembering. When you have a batch process that updates large numbers of records, optimistic locking becomes impractical because the version numbers will constantly collide. In those cases, disable optimistic locking within the transaction and rely on database-level isolation instead. Set the transaction isolation level to SERIALIZABLE for that specific batch operation and let the database handle the conflicts. It is slower, but it is correct, and correctness matters more than throughput for batch operations.

When to Stop Optimizing

The hardest lesson in database design is knowing when the design is good enough. I have watched projects spend three weeks refining a schema that never encountered a performance problem. The optimized schema was theoretically superior in every way and performed identically to the simpler one under actual load. The practical threshold is this. Design until your queries perform acceptably under expected load. Add monitoring to track actual usage patterns. If the monitoring shows hot spots, address those specifically. Do not design for hypothetical future requirements. Do not normalize to Boyce-Codd normal form if third normal form handles everything you actually need. Do not build caching layers for data that fits comfortably in memory without them. A well-designed database does not look perfect on paper. It looks slightly compromised, with a few intentional denormalizations and some indexes that might be overkill. That is fine. It means the design was shaped by actual requirements rather than theoretical ideals. The database that matches the textbook exactly is usually the one that breaks first under real conditions, because nobody tested it against anything except theory.