Bad database design usually does not explode on day one. The problems show up months later, when data starts drifting, one table says one thing, another says something else, the application is full of special-case fixes, migrations become painful, and every business rule change risks breaking old data.
If your goal is a database that survives long term, the first priority is not query optimization. It is consistency in the design itself.
Quick conclusion
If you only want a short checklist when designing a database:
- every entity should have one clear source of truth
- naming should stay consistent across tables, columns, and keys
- important relationships should usually have
foreign keys - core rules like uniqueness, required fields, and allowed values should live in the database through
unique,not null, andcheck - do not duplicate data unless you have a clear reason
- multi-table updates should be wrapped in transactions
Those 6 points already prevent a large percentage of long-term data inconsistency problems.
What does consistency mean in database design?
In practice, consistency has 2 layers here:
- data consistency: data does not contradict itself, does not leave orphan records, and does not duplicate meaninglessly
- design consistency: naming, column styles, table boundaries, and state modeling stay uniform across the system
Examples of inconsistency:
usersusesid, another table usesuserId, another usesuid- one place stores
status = active, another storesis_active = 1 orders.user_idstill points to a user that no longer exists- emails get duplicated because validation exists only in application code, not as a database constraint
These look small at first, but the cost grows with every new table.
When does a database lose consistency most easily?
Risk increases when:
- multiple developers change the schema without a clear convention
- business rules live only in application code and the database enforces almost nothing
- data is copied between tables "for convenience"
- multi-table updates happen without transactions
- soft delete, status fields, and audit fields are modeled differently in every table
That is why database design needs rules early, not after every table evolves on its own.
Principle 1: Every entity needs one source of truth
One important rule is:
A critical piece of information should have one clear canonical location.
Examples:
- a user's email should come from
users - the current product price should come from
products - an order's current status should come from a standard field in
orders
If the same meaning exists in many places without a good reason, it will drift sooner or later.
Bad example:
users.email
orders.customer_email
invoices.customer_email
If all of them are supposed to mean "the user's current email", that design will drift. Duplicate it only when you intentionally need a historical snapshot, such as the billing email captured at invoice time.
Principle 2: Lock down naming conventions early
Consistency in naming removes a surprising amount of confusion from code and migrations.
Decide early:
snake_caseorcamelCase- whether primary keys are always called
id - whether foreign keys always use
xxx_id - whether timestamps always use
created_at,updated_at
A practical convention:
users
orders
order_items
id
user_id
order_id
created_at
updated_at
deleted_at
The key point is not the exact style. It is staying consistent once you choose one.
Principle 3: Use clear primary keys and foreign keys
Primary keys identify records. Foreign keys tell the database how tables are related.
Example:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL,
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id)
);
Practical benefits of foreign keys:
- they prevent orphan records
- they make relations clearer during queries and migrations
- they force developers to respect data relationships instead of writing around them
Not every system uses FKs everywhere, but you should skip them only for a clear reason, not just for convenience.
Principle 4: Important rules should live in the database, not only in code
Many teams validate only in the API:
- emails must be unique
- status must be from a known set
- every row must have a
user_id
But if the rule exists only in application code:
- background jobs can violate it
- import scripts can violate it
- other services can bypass it
That is why core rules should also exist in the database:
NOT NULLUNIQUECHECKDEFAULT
Example:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL CHECK (status IN ('active', 'inactive', 'blocked')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Application validation still matters for cleaner errors, but the database should be the final enforcement layer.
Principle 5: Normalize enough, and do not duplicate too early
A common mistake is duplicating data too early just to make queries feel easier.
Examples:
- storing
user_nameinorders - storing
product_priceincart_items - storing
department_nameacross many tables
Unless you intentionally need a historical snapshot, most of these values should be derived from their source table.
The goal is:
- one home for source data
- derived or denormalized data only when truly needed
Over-normalization is also not ideal if queries become unusably complex. But for most CRUD systems, preventing data drift matters more than premature denormalization.
Principle 6: The same kind of data should use the same format
Examples of inconsistency:
- IDs stored as
INTin one place andVARCHARin another - money stored as
FLOATin one table andDECIMALin another - statuses stored as strings in one place and numbers in another
- timestamps stored as local time in one service and UTC elsewhere
Practical rules:
- money should usually use
DECIMAL, notFLOAT - timestamps should be standardized in UTC
- foreign keys should use the same type as the primary key they reference
- status fields should follow one representation model
Principle 7: Multi-table updates need transactions
This is where many systems lose consistency even when the schema looks decent.
Example order flow:
- create a row in
orders - create multiple rows in
order_items - reduce inventory
- write a payment log
If step 3 fails after steps 1 and 2 already committed, the database is already inconsistent.
These flows should be wrapped in a transaction:
BEGIN;
-- insert orders
-- insert order_items
-- update inventory
COMMIT;
Consistency is not just about schema. It is also about how writes are executed.
Principle 8: Soft delete, status fields, and audit fields need shared patterns
A system becomes messy quickly when:
- one table uses
is_deleted - another uses
deleted - another uses
deleted_at
The same goes for audit fields:
created_atupdated_atcreated_byupdated_by
Not every table needs full auditing, but if you use these patterns, they should be consistent.
A practical pattern:
created_at
updated_at
deleted_at
Using deleted_at IS NULL is often clearer than a simple is_deleted boolean.
Principle 9: Indexes support queries, but they do not fix bad design
Indexes matter a lot, but they do not rescue a schema with broken integrity.
Examples:
- if duplicated data is wrong, indexing it only makes wrong data faster
- if relationships are unclear, indexes do not restore integrity
- if status values are stored inconsistently, faster queries still return messy meaning
A better order of priorities:
- consistent schema
- clear constraints
- correct queries
- then optimize with indexes
What does a small but consistent schema look like?
Example:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
full_name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('pending', 'paid', 'cancelled')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE order_items (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0),
unit_price DECIMAL(12,2) NOT NULL CHECK (unit_price >= 0),
CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders(id)
);
What matters here is not that the schema is "advanced". It is that:
- naming is uniform
- constraints are explicit
- relationships are explicit
- data types are consistent
Very common database design mistakes
1. Missing unique constraints on fields that should obviously be unique
Examples:
- user emails
- order codes
- transaction references
Application-level checks alone are not enough.
2. Status values represented in multiple ways
Examples:
active1enabled
Those may mean the same thing, but they make queries and application code much harder to maintain.
3. Source data copied into too many tables
If it is not a deliberate snapshot, it is usually a future inconsistency problem.
4. No transaction around multi-step writes
Even a clean schema will drift if write flows are not atomic.
5. Foreign keys with different data types than the primary keys they reference
For example, users.id as BIGINT but orders.user_id as INT or VARCHAR. Those mismatches always cause friction later.
Conclusion
Consistent database design does not mean making the schema academically perfect. The more practical goal is:
- data should not contradict itself
- core business rules should be enforced by the database
- tables should follow the same conventions
- multi-step write flows should not leave half-finished states behind
If you must prioritize only a few things, lock these down first:
- naming conventions
- primary keys and foreign keys
not null,unique, andcheck- transactions for multi-table flows
Those four alone already make a database much easier to keep healthy over time.