Home / Blog / Schema Design

database design

How to Design a Database Schema: A Practical Guide

The decisions that are cheap to change later, and the ones that aren't.

Schema design is mostly a sequence of small, reversible decisions and a handful of expensive, hard-to-reverse ones. Knowing which is which up front saves a lot of migration pain later.

1. Start from the entities, not the tables

Before writing any CREATE TABLE, list the real-world things your application deals with — customer, order, product — as nouns, independent of how they'll be stored. Each becomes a candidate table. Relationships between them (a customer has many orders; an order has many products, and a product appears in many orders) tell you where foreign keys and join tables go.

2. Normalize until duplication hurts to keep

Normalization is often taught as a series of numbered forms (1NF, 2NF, 3NF), but the practical version is simpler: each fact should live in exactly one place. If a product's price is copied into every order-line row, a price change means updating every historical order — which is sometimes actually what you want (an order should reflect the price paid, not today's price), but that's a deliberate choice, not an accident of copy-pasted columns.

-- Normalized: price looked up from products at read time
CREATE TABLE order_items (
  order_id INTEGER REFERENCES orders(id),
  product_id INTEGER REFERENCES products(id),
  quantity INTEGER NOT NULL
);

-- Deliberately denormalized: price captured at time of sale
CREATE TABLE order_items (
  order_id INTEGER REFERENCES orders(id),
  product_id INTEGER REFERENCES products(id),
  quantity INTEGER NOT NULL,
  unit_price_cents INTEGER NOT NULL -- snapshot, not a live lookup
);

3. Choose primary keys deliberately

An auto-incrementing integer (SERIAL / IDENTITY in Postgres, INTEGER PRIMARY KEY in SQLite) is compact, fast to index, and easy to reason about. A UUID is unguessable and can be generated before the row is inserted — useful for client-generated IDs or merging data from multiple sources — at the cost of a larger index and no natural sort order. Neither is universally right; the wrong one to pick is "whatever the framework defaults to, without thinking about it."

4. Decide foreign-key behavior up front

What happens to order_items when an order is deleted? This is the kind of decision that's easy to get wrong quietly and painful to fix once you have production data depending on it.

CREATE TABLE order_items (
  order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
  product_id INTEGER REFERENCES products(id) ON DELETE RESTRICT,
  quantity INTEGER NOT NULL
);

ON DELETE CASCADE removes dependent rows automatically —fine for order-to-order-items, since a deleted order shouldn't leave orphaned line items. ON DELETE RESTRICT blocks the delete instead — better for product-to-order-items, since deleting a product shouldn't silently erase historical order records.

5. Index for the queries you'll actually run

A primary key is indexed automatically; foreign keys and columns you filter or sort by often need an explicit index. The trade-off is real: every index speeds up reads on that column but slows down writes and takes up space. Add indexes based on the queries your application runs, not speculatively for every column.

CREATE INDEX idx_orders_customer_id ON orders(customer_id);

What's expensive to change later

  • Primary key type — switching from integer to UUID after other tables reference it means rewriting every foreign key.
  • A column's fundamental type — going from a string to a proper DATE or ENUM after production data already violates the new constraint requires a data-cleanup migration first.
  • Splitting a table in two once queries and application code assume a single wide table — doable, but every read and write path needs updating together.

What's cheap: adding a nullable column, adding an index, adding a new table. Design around the expensive decisions; iterate freely on the cheap ones.

A visual schema diagram makes a lot of these relationships obvious before you commit to them. QuerySQL's schema diagram view lays out tables by foreign-key dependency, with delete-rule labels on every relationship line.

← SQL JOIN Types Explained Next: SQL Transactions Explained →