Database design
Model entities, keys, indexes, money and time so the schema survives years of change.
In 30 seconds
Database design is deciding what tables you need, how they relate, and which rules the database itself should enforce. A good schema makes correct data easy and wrong data impossible, and keeps queries fast as data grows. Code is easy to rewrite; data and schemas live for years, so this is where careful thinking pays off most.
Why I use it
- I spend real time on schema design because almost every serious bug I have seen in commerce systems traces back to data that the database allowed but the business did not.
- I design for change: products gain variants, orders gain currencies, single-tenant apps become multi-tenant, and a good schema absorbs that with migrations instead of rewrites.
- I let the database enforce the rules it is good at (foreign keys, unique constraints, NOT NULL, checks), so application bugs cannot corrupt data quietly.
- I treat money, time and identifiers as special cases with fixed rules, because mistakes there are expensive and hard to undo.
How it works
01Entities and relations
Start from the business, not from tables. List the nouns (customer, product, order, payment) and the verbs that connect them (a customer places orders, an order contains items). Each important noun becomes a table, an entity. Then decide the cardinality of each relation. One-to-many is the most common: one customer has many orders, so orders gets a customer_id foreign key. Many-to-many, like products and categories, needs a join table (product_categories) with both foreign keys and a composite primary key. One-to-one is rare and usually means optional extra data, such as a customer profile. Foreign keys make the database refuse orphan rows; choose ON DELETE behaviour deliberately: RESTRICT for business records, CASCADE only for true children like order items of a draft cart.
- Nouns become tables, verbs become relations.
- 1:N: foreign key on the 'many' side.
- N:M: a join table with a composite key.
- Name consistently: plural tables, singular_id foreign keys, snake_case.
02Normalisation vs denormalisation
Normalisation means storing each fact once. In practice, third normal form (3NF) is the target: every column depends on the key, the whole key and nothing but the key. If a customer's email is copied into every order, changing it means updating many rows, and some will be missed. Denormalisation is copying data on purpose, for speed or history. Two cases are not only allowed but required. Snapshots: an order item must store the product name and unit price at the time of purchase, because the product will change later. Read models: a cached order_count on customers or a search index. Normalise by default, denormalise deliberately, and document who keeps the copy in sync, whether a trigger, a job or the application.
03Keys: natural vs surrogate, UUID vs bigint
A natural key is a real-world value, like an email or SKU; a surrogate key is a meaningless generated id. Natural values change (people change emails, SKUs get renamed), so use a surrogate primary key and protect the natural value with a UNIQUE constraint. For the surrogate, bigint auto-increment is compact (8 bytes), fast and keeps B-tree inserts in order, but ids are guessable and must be generated by the database. UUIDs (16 bytes) can be generated anywhere, which suits distributed systems, offline clients and public URLs. Random UUIDv4 scatters inserts across the index and hurts write performance at scale; time-ordered UUIDv7 fixes that. PostgreSQL 18 has a built-in uuidv7() function; elsewhere, generate v7 in the application.
- bigint: internal tables, joins, high write volume.
- UUIDv7: public ids, offline or multi-system creation.
- Never expose sequential ids where enumeration is a risk.
- Keep natural keys as UNIQUE columns, not primary keys.
04Index strategy
An index is a sorted copy of some columns that lets the database find rows without scanning the whole table, like the index at the back of a book. Every primary key and unique constraint has one. Add others for the queries you really run: foreign keys used in joins, columns in frequent WHERE clauses and ORDER BY. In a composite index, column order matters. The leftmost-prefix rule says an index on (customer_id, created_at) helps queries filtering by customer_id, or by customer_id and a date range, but not by created_at alone. Put equality columns first and range or sort columns last. Indexes are not free: each one slows writes and uses memory, so remove unused ones. Always confirm with EXPLAIN.
-- "latest orders of a customer" in one index scan
CREATE INDEX orders_customer_created_idx
ON orders (customer_id, created_at DESC);
EXPLAIN
SELECT id, total_minor, status
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 20;
05Money and time
Never store money in a floating-point column: 0.1 + 0.2 is not 0.3 in binary floats, and rounding errors add up across thousands of orders. Use integer minor units (cents) in a bigint, or DECIMAL/NUMERIC with a fixed scale, and always store the currency code (ISO 4217, like EUR or USD) next to the amount. Store prices, discounts, tax and totals on the order itself so history never changes. For time, store instants in UTC. PostgreSQL's timestamptz stores UTC and converts on display; in MySQL, DATETIME has no time zone, so store UTC by convention, and avoid TIMESTAMP for far-future dates because it ends in 2038. For future local events, such as a booking at 10:00 in a city, store the local time plus an IANA time zone name like Asia/Yerevan, because time zone rules can change.
- Amounts: bigint minor units or NUMERIC(19,4), never float.
- Always store currency next to amount.
- Instants in UTC; future local events as local time + IANA zone.
06Soft deletes and safe migrations
A soft delete marks a row as deleted (deleted_at timestamp) instead of removing it, so you can restore it and keep history. The cost: every query must filter it out, and unique constraints must ignore deleted rows. In PostgreSQL a partial unique index solves that; in MySQL, use a generated column. Use soft deletes for user-facing content, not for everything; for audit needs, an append-only history table is often cleaner, and privacy laws may require real deletion. Migrations change a live schema, so make them backwards-compatible with the running code using expand and contract: add the new nullable column, deploy code that writes both, backfill in batches, switch reads, then drop the old column in a later release. Build big indexes without locking writes (CREATE INDEX CONCURRENTLY in PostgreSQL).
-- PostgreSQL: email unique only among non-deleted customers
CREATE UNIQUE INDEX customers_email_live_uq
ON customers (lower(email))
WHERE deleted_at IS NULL;
-- expand step: safe on a live table
ALTER TABLE orders ADD COLUMN currency char(3);
-- backfill in batches, then in a later release:
-- ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;
07Example: an e-commerce core schema
Here is a compact core that shows the ideas together. Products hold shared information; variants hold what is actually sold (size, colour, SKU, price). Orders belong to a customer, store their currency and totals, and have a status with a CHECK constraint. Order items reference the variant but also snapshot the name and unit price, so an order printed in five years still shows what the customer paid. Payments are a separate table because one order can have several attempts, refunds or partial captures. Around this core you would add addresses (snapshotted onto the order too), inventory movements, promotions applied, and a status history table for auditing.
CREATE TABLE products (id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name text NOT NULL);
CREATE TABLE variants (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
product_id bigint NOT NULL REFERENCES products(id),
sku text NOT NULL UNIQUE,
price_minor bigint NOT NULL CHECK (price_minor >= 0));
CREATE TABLE orders (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL CHECK (status IN ('pending','paid','shipped','cancelled','refunded')),
currency char(3) NOT NULL,
total_minor bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(id),
variant_id bigint NOT NULL REFERENCES variants(id),
name_snapshot text NOT NULL,
unit_price_minor bigint NOT NULL,
qty int NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, variant_id));
Use it when
- Before writing the first migration of any product that stores business data.
- When adding a major feature such as variants, multi-currency, multi-tenancy or loyalty.
- When queries get slow or data inconsistencies appear in reports.
- When reviewing pull requests that touch migrations.
Skip it when
- Throwaway prototypes and spikes, where a quick schema is fine as long as nobody ships it.
- Do not over-model rare future cases; design for the next realistic step and keep migrations cheap.
Alternatives
- Document databases (MongoDB)
Flexible nested documents; good for content-like data, weaker for relational rules and multi-row transactions.
- JSON columns in a relational database
JSONB or JSON for truly variable attributes while keeping core fields relational.
- Event sourcing
Store every change as an event and derive state; powerful for audit-heavy domains, but much more complex.
Best practices
- Enforce rules in the database: NOT NULL, foreign keys, UNIQUE and CHECK constraints.
- Use surrogate primary keys and keep natural keys unique.
- Index for real queries, with equality columns first in composite indexes.
- Store money as integers or fixed decimals with a currency code.
- Store instants in UTC and snapshot prices and addresses onto orders.
- Write expand-and-contract migrations that are safe for running code.
- Name tables, columns and constraints consistently.
Common mistakes
- Storing money as FLOAT or DOUBLE.
- Reading the current product price for old orders instead of a snapshot.
- Missing indexes on foreign keys, which makes joins and deletes slow.
- Adding a unique constraint that breaks as soon as soft-deleted rows exist.
- Running a locking migration on a large table during peak hours.
Deep dive
08Modelling trees: adjacency list, materialised path and closure table
Categories, comment threads, org charts and folder structures are trees, and SQL has several ways to store them. An adjacency list gives each row a parent_id: simple, cheap to move a node, and with recursive CTEs (WITH RECURSIVE, supported in PostgreSQL and MySQL 8) reading a subtree is fine for moderate depths. A materialised path stores the full path as a string, such as /1/7/42/; finding all descendants is a fast prefix search, but moving a branch rewrites every path below it. PostgreSQL's ltree extension makes this pattern first-class. A closure table stores every ancestor-descendant pair with its depth in a separate table; reads of any subtree or ancestor chain become one indexed join, at the cost of more rows and more work on insert and move. Nested sets are fast to read but painful to update and rarely worth it today. Choose by your dominant operation: frequent moves favour adjacency lists, heavy subtree reads favour paths or closure tables.
CREATE TABLE category_paths (
ancestor_id bigint NOT NULL REFERENCES categories(id),
descendant_id bigint NOT NULL REFERENCES categories(id),
depth int NOT NULL,
PRIMARY KEY (ancestor_id, descendant_id)
);
-- all products in category 7 and every subcategory, one join
SELECT p.*
FROM category_paths cp
JOIN products p ON p.category_id = cp.descendant_id
WHERE cp.ancestor_id = 7;
09Integrity under concurrency: constraints, upserts and optimistic locking
Application checks such as 'if the email does not exist, insert it' are race conditions: two requests can both pass the check and both insert. Only the database can enforce rules atomically, so put them there. A UNIQUE constraint plus an upsert (INSERT ... ON CONFLICT in PostgreSQL, ON DUPLICATE KEY UPDATE in MySQL) turns duplicates into a defined outcome. For external retries, such as a payment webhook or a double-clicked checkout, store an idempotency key with a unique constraint and return the stored result on a repeat. PostgreSQL's exclusion constraints can forbid overlapping ranges, which is the correct way to prevent double bookings. For records edited by humans, optimistic locking avoids holding locks while someone types: keep a version column, update WHERE version = the value you read, and treat zero affected rows as a conflict to show the user. Pessimistic locks (SELECT ... FOR UPDATE) fit short, hot paths like stock decrements.
-- optimistic lock: succeeds only if nobody changed the row since we read version 7
UPDATE products
SET price_cents = 1990, version = version + 1
WHERE id = 42 AND version = 7;
-- 0 rows affected => someone else saved first; reload and ask the user
-- PostgreSQL: no two bookings of a room may overlap
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);
10Growth: partitioning, retention and archiving
Most tables never need partitioning. Append-heavy tables that grow to hundreds of millions of rows, such as events, logs, audit trails or old orders, eventually do. Partitioning splits one logical table into physical pieces by a key, usually a time range per month. Queries that filter on that key only touch matching partitions (partition pruning), indexes stay small enough to fit in memory, and retention becomes instant: detaching or dropping an old partition replaces a DELETE of millions of rows that would bloat the table and flood replication. The rules to know: the primary key and every unique constraint must include the partition key, in both PostgreSQL and MySQL; queries that do not filter by the key scan all partitions; and too many partitions slow planning, so hundreds are fine, tens of thousands are not. Create future partitions ahead of time with a scheduled job or an extension such as pg_partman. Archive detached partitions to cheaper storage if you must keep them.
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
created_at timestamptz NOT NULL,
type text NOT NULL,
payload jsonb,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_11 PARTITION OF events
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
-- retention: instant, no row-by-row delete
ALTER TABLE events DETACH PARTITION events_2025_11;
DROP TABLE events_2025_11;
11Patterns that fight the database: polymorphic links, EAV and cross-tenant references
Some popular patterns quietly give up integrity. Polymorphic associations, such as comments with commentable_type and commentable_id, cannot have a foreign key, so orphans and typos go unnoticed. Alternatives: one nullable foreign key per parent type with a CHECK that exactly one is set, or a separate join table per parent. Entity-attribute-value tables (entity_id, attribute, value) look flexible but turn every read into many joins and store every value as text; a JSONB column for the variable part, with real columns for what you filter on, is almost always better. In multi-tenant schemas, a foreign key on customer_id alone does not stop an order from pointing at another tenant's customer. Make the tenant part of the key: a unique constraint on (tenant_id, id) in the parent and a composite foreign key (tenant_id, customer_id) in the child. Then cross-tenant references are impossible by construction, not by code review.
ALTER TABLE customers ADD CONSTRAINT uq_customers_tenant_id UNIQUE (tenant_id, id);
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer_same_tenant
FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id);
-- exclusive arc instead of a polymorphic pair
ALTER TABLE comments ADD CONSTRAINT one_parent CHECK (
num_nonnulls(post_id, product_id) = 1
);
Real-world scenarios
Duplicate orders from double clicks and retries
Problem
A few customers are charged twice each week. Logs show two checkout requests arriving within milliseconds, both passing the 'already ordered?' check in application code.
How to solve it
Move the guarantee into the database. The client generates an idempotency key (a UUID) when the checkout form loads and sends it with every attempt. Add an idempotency_key column to orders with a UNIQUE constraint, or a separate idempotency table storing the key, request hash and response. Insert the order and the key in one transaction; a concurrent duplicate fails on the constraint, and the handler returns the stored result instead of creating a second order. Pass the same key to the payment provider, since most support idempotent requests. Disable the button after the first click as a courtesy, but never rely on it. Keep keys for at least the retry window, typically 24 hours or more.
A 400-million-row events table slows everything down
Problem
An events table has grown for years. Nightly DELETEs of old rows take hours, replicas lag, backups are huge and dashboards on recent data are slow.
How to solve it
Partition by month on created_at, which requires the primary key to include created_at. Create the new partitioned table, write to both or switch writes at a quiet moment, and backfill recent months in batches; old history can stay in the legacy table until it ages out. Replace the nightly DELETE with a job that detaches and drops partitions older than the retention period, archiving them to cheap storage first if required. Dashboards filtering by date now touch only one or two small partitions. For heavy aggregates, add a daily summary table refreshed by a job, so dashboards stop scanning raw events altogether. Create next month's partition automatically a few weeks ahead.
Each product category needs different attributes
Problem
Shoes need size and width, wine needs vintage and grape, electronics need voltage. The team is debating a column per attribute, an EAV table or JSON.
How to solve it
Split attributes by how they are used. Anything every product has, or that you join, sort or constrain on (price, stock, brand, status), stays a real column. The long tail goes into a JSONB column (or JSON in MySQL), validated by an application schema per category and indexed with GIN where you filter by containment. Define the allowed attributes per category in an attribute definitions table, so the admin UI and validation are data-driven. If one attribute becomes central to search or sorting, promote it to a generated column with a normal index. Avoid EAV: it multiplies joins, loses types and makes simple filters slow. Faceted search over attributes usually belongs in a search engine anyway.
Cheat sheet
- Make duplicates impossible
ALTER TABLE orders ADD CONSTRAINT uq_orders_idem UNIQUE (idempotency_key);- Upsert in PostgreSQL
INSERT INTO prices (sku, cents) VALUES ('A1', 990) ON CONFLICT (sku) DO UPDATE SET cents = EXCLUDED.cents;- Upsert in MySQL 8
INSERT INTO prices (sku, cents) VALUES ('A1', 990) AS new ON DUPLICATE KEY UPDATE cents = new.cents;- Optimistic lock check
UPDATE t SET ..., version = version + 1 WHERE id = ? AND version = ?; -- 0 rows = conflict- No overlapping bookings (PostgreSQL)
EXCLUDE USING gist (room_id WITH =, during WITH &&)- Same-tenant foreign key
FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)- Exactly one parent set
CHECK (num_nonnulls(post_id, product_id) = 1) -- PostgreSQL- Restrict a status column
CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled'))- Keyset pagination (stable and fast)
WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20- Drop old data instantly
ALTER TABLE events DETACH PARTITION events_2025_11; DROP TABLE events_2025_11;
In my real projects
- On a pet-supplies e-commerce platform I worked with about 90 models in 12 domain folders, with pricing modelled as value objects to handle pack and weight sale modes.
- On a multi-tenant POS SaaS with 181 Prisma models, every query carries an explicit company id, and a Prisma extension throws if the tenant scope is missing.
- In the same SaaS, idempotency keys for partner orders and deduplicated offline sync commands keep data consistent when requests are retried.
- A tour booking platform I contributed to handles multi-currency pricing in USD, EUR and AMD with layered rate caching.
Questions & answers
Q1When would you denormalise a schema?
I normalise by default and denormalise for two reasons. The first is history: an order must keep the product name, price, tax and address as they were at purchase, so those are copied onto the order and its items on purpose. The second is read performance: a counter, a precomputed total, or a reporting table when joining live data is too slow. In both cases I decide who keeps the copy correct, whether a transaction in the same write, a trigger or a background job, and whether it can be rebuilt from the source of truth. Denormalisation without an owner is how reports start to disagree.
Q2UUID or auto-increment bigint as a primary key?
It depends on where ids are created and who sees them. Bigint identity columns are 8 bytes, fast, and inserted in order, which keeps B-tree indexes compact; they are great for internal tables. Their downsides are that they are guessable and must come from the database. UUIDs can be generated by clients, offline devices or other services, and they do not leak counts in URLs. Random UUIDv4 values hurt insert performance on large tables because they land all over the index, so I prefer time-ordered UUIDv7. A common compromise is a bigint primary key internally plus a unique public UUID for APIs.
Q3How do you store money and prices?
Never as floating point, because binary floats cannot represent most decimal fractions exactly and errors accumulate. I use integer minor units in a bigint, such as cents, or a fixed-precision DECIMAL or NUMERIC, and I always store the ISO currency code next to the amount. Rounding happens in one place with a documented rule. Orders store their own unit prices, discounts, tax and totals at the time of purchase, so a later price change never alters history. For multi-currency, I store the amount in the currency the customer paid plus the exchange rate used, not just a converted value.
Q4How do you run a migration on a large production table without downtime?
I use expand and contract so that old and new code both work during the deploy. Expand: add new columns as nullable or with a constant default, which most modern databases do without a table rewrite, and create indexes without blocking writes, using CREATE INDEX CONCURRENTLY in PostgreSQL or online DDL in MySQL. Then deploy code that writes to both, backfill old rows in small batches, and switch reads. Contract: add NOT NULL or constraints once data is clean, and drop old columns in a later release. I also set a lock timeout so a migration fails fast instead of queuing behind long transactions.
Q5What are the pros and cons of soft deletes?
Soft deletes set a deleted_at timestamp instead of removing the row. The pros are easy undo, preserved references from other tables, and a simple audit trail. The cons are real: every query must exclude deleted rows, and forgetting it leaks data; unique constraints must ignore deleted rows, which needs partial indexes or generated columns; tables grow forever; and privacy laws like GDPR may require actual erasure. I use soft deletes for user-facing content that people may want back, and real deletes plus an audit or history table for everything else. Framework scopes help, but they do not protect raw SQL or reports.
Q6How do you choose which indexes to create?
From the queries, not the columns. I list the frequent and critical queries, look at their WHERE, JOIN and ORDER BY clauses, and design indexes for them. Foreign keys used in joins almost always need one. For composite indexes I put equality filters first and the range or sort column last, because of the leftmost-prefix rule, and sometimes include extra columns so the query is served from the index alone. Then I check with EXPLAIN on realistic data. I also watch the other side: each index slows inserts and updates and uses memory, so I remove indexes that usage statistics show are never read.
Senior-level questions
S1How would you design balances for a wallet or loyalty-points system?
I never store the balance as the only truth. The source of truth is an append-only ledger: each movement is a row with account, signed amount in minor units, currency, type, reference and an idempotency key, and rows are never updated or deleted; corrections are new reversing entries. For transfers I use double entry, two rows that sum to zero, written in one transaction. The current balance is derived, but summing millions of rows per request is slow, so I keep a balance column on the account, updated in the same transaction as the ledger insert with a row lock or a conditional update such as WHERE balance + amount >= 0 to prevent overdrafts. A periodic job reconciles cached balances against ledger sums. The trade-off is more rows and stricter write paths in exchange for a full audit trail and the ability to explain every number.
S2When does a single relational database stop being enough, and what do you try first?
Much later than most teams think. Before adding systems, I exhaust cheap steps in order: fix queries and indexes using real slow-query data; add connection pooling; scale the server vertically, since modern machines with large memory and NVMe storage handle a lot; add caching for hot reads with clear invalidation; move reads that tolerate slight staleness, like reports, to replicas; and partition the few huge append-only tables. Then I look at the shape of the load. Analytics belongs in a columnar store, full-text and faceted search in a search engine, and queues in a queue. Sharding the primary database is the last resort, because it complicates every query, transaction and migration. When it is truly needed, sharding by tenant is usually the cleanest boundary, often via an extension or managed service rather than custom routing code.
S3How do you model data that must be auditable for years, yet comply with deletion requests?
I separate the business record from personal data. Orders, invoices and ledger entries are immutable and snapshot what they need, such as prices, tax and a billing address, because accounting law usually requires keeping them for years. Change history is captured either in an append-only history table per entity, written by triggers or the application, or with system-versioned tables where the database supports them, as MariaDB does. Event sourcing is the extreme version; it gives a perfect audit trail but makes queries and schema changes harder, so I use it only for core financial flows. For deletion requests, personal data lives in as few places as possible and is referenced by id; erasure then means anonymising those rows, or deleting a per-person encryption key so encrypted copies become unreadable. Legal retention wins where it applies, and I document which data falls under it.
Glossary
- Adjacency list
- Storing a tree by giving each row a parent_id that points to its parent row.
- Closure table
- A table holding every ancestor-descendant pair of a tree, making subtree queries a single join.
- Idempotency key
- A unique client-generated id that lets a server recognise and safely ignore a repeated request.
- Optimistic locking
- Detecting conflicting edits with a version column at write time instead of locking rows while reading.
- Exclusion constraint
- A PostgreSQL constraint forbidding any two rows whose values conflict, such as overlapping time ranges.
- Partition pruning
- The planner skipping partitions that cannot contain matching rows, based on the query's filter.
- Polymorphic association
- One pair of type and id columns pointing at rows in different tables; it cannot have a foreign key.
- EAV
- Entity-attribute-value: storing each attribute as its own row. Flexible, but slow and weakly typed.
- Keyset pagination
- Paging by remembering the last row's sort values instead of using OFFSET, so deep pages stay fast.
- Exclusive arc
- Several nullable foreign keys on one table with a CHECK that exactly one is filled.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.