SE

Data modeling

Must-know concept1.5 hIntermediate

Normalization, denormalization, schema design.

Normalize until it hurts, denormalize until it works. Data modeling is the constant trade-off between integrity (one fact in one place) and read performance (the fact is already where the query needs it).

The big idea

Two pressures pull schemas in opposite directions:

Normalizeone fact, one place
Reality checkprofile your queries
Denormalizeduplicate for speed

Start normalised. Denormalise only where evidence shows a real performance problem you can't solve with an index.

Normal forms (just enough)

You don't need to recite the formal definitions. The intuitive rules:

  1. 1NF — atomic values

    No "tags: 'cooking,baking,vegan'" in a single column. Use a related table or an array type if your DB supports it.

  2. 2NF + 3NF — no transitive dependencies

    A field belongs in the table whose primary key it directly describes. If users.city determines users.country, country belongs to a cities table.

  3. BCNF and beyond

    You don't need this in 90% of designs. Read it when you hit the 10%.

A worked example

E-commerce: users have orders, orders have line items, items reference products.

CREATE TABLE users (
  id            BIGSERIAL PRIMARY KEY,
  email         TEXT NOT NULL UNIQUE,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE TABLE products (
  id            BIGSERIAL PRIMARY KEY,
  sku           TEXT NOT NULL UNIQUE,
  name          TEXT NOT NULL,
  price_cents   INTEGER NOT NULL CHECK (price_cents >= 0)
);
 
CREATE TABLE orders (
  id            BIGSERIAL PRIMARY KEY,
  user_id       BIGINT NOT NULL REFERENCES users(id),
  status        TEXT NOT NULL CHECK (status IN ('new','paid','shipped','cancelled')),
  total_cents   INTEGER NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE TABLE order_items (
  order_id      BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  product_id    BIGINT NOT NULL REFERENCES products(id),
  quantity      INTEGER NOT NULL CHECK (quantity > 0),
  price_cents   INTEGER NOT NULL,                 -- snapshot at time of order
  PRIMARY KEY (order_id, product_id)
);

When to denormalise

Three legitimate reasons:

Historical accuracy
Order line items snapshot the price at purchase time.
Hot read paths
A counter (users.unread_count) that would be slow to compute every page load. Keep it in sync via the write path.
Cross-shard reads
If a join would cross a shard boundary, duplicate the data into both shards.

Surrogate vs natural keys

Do

Use surrogate IDs (BIGSERIAL, UUID) as primary keys for entities. They're stable when business attributes change. Add UNIQUE constraints on the natural keys you care about (email, sku).

Don't

Use the email as the primary key for users. The day someone changes their email is the day every foreign key has to chase the change.

Modeling many-to-many

The bread-and-butter pattern: a join table with two foreign keys.

CREATE TABLE user_roles (
  user_id  BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role_id  BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
  granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, role_id)
);

If you find yourself adding columns to the join table (granted_by, expires_at), it has become a first-class entity — give it its own ID and treat it like one.

In practice

Sketch the schema with sample rows in a tool like dbdiagram.io before you write a migration. Write the 5–10 most important queries against it on paper. If a query is ugly, the schema is wrong. Fix it now, not in production.

Key takeaways

  • Start normalised: one fact, one place; foreign keys for relationships.
  • Denormalise only with evidence — historical snapshots, hot read paths, sharding.
  • Every duplicated value is a write-path obligation. Update it everywhere or lose data.
  • Use surrogate IDs for primary keys; constrain the natural keys separately.
  • Sketch the schema *with the queries* before writing the first migration.

Checkpoint questions

Use these to test whether the lesson is clear enough to explain without rereading.

  1. 1Which entities, relationships, and invariants must be captured before drawing tables?
  2. 2When is normalization helpful, and when is denormalization justified?
  3. 3Why might an order item store price_cents even if the product already has a price?
  4. 4How would you model a many-to-many relationship and query it efficiently?

References

External resources for going deeper after the lesson above.