DWH Interview Question 12 - What is denormalization, and when should you use it?

Data Warehouse Interview Q&A · Question 12

In this post we are going to cover denormalization: what it is, when you should use it, and what it costs you.

Denormalization means deliberately adding redundancy to a design that was normalized. We merge tables or copy columns so that reads need fewer joins. The joins still happen, but once, when the data is loaded, instead of in every query.

We will start with a normalized TB Shop model, answer a simple question with joins, then denormalize the customer data and answer the same question with no joins at all. Then we will see the price you pay when a copied value goes stale.

Question 12 of the Real DWH Question and Answers series on TechBrothersIT.

The interview answer

Say this first

Denormalization is deliberately adding redundancy, by merging tables or copying columns, so that reads need fewer joins and run faster. We accept extra storage and harder writes. It is standard in data warehouses (star schema dimensions, wide reporting tables), and used carefully in OLTP.

Let's create some sample data

We need a normalized (3NF) model: customers point to cities, and cities point to countries. We also need orders and order items, where orders keeps a pre-computed order_total, which is already a small denormalized column.

Sample data — run this first
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS dim_customer;
DROP TABLE IF EXISTS customers;
DROP TABLE IF EXISTS cities;
DROP TABLE IF EXISTS countries;

-- normalized (3NF) OLTP tables
CREATE TABLE countries (country_id INT PRIMARY KEY, country VARCHAR(50));
CREATE TABLE cities (city_id INT PRIMARY KEY, city VARCHAR(50),
                     country_id INT REFERENCES countries(country_id));
CREATE TABLE customers (customer_id INT PRIMARY KEY, name VARCHAR(50),
                        city_id INT REFERENCES cities(city_id));
INSERT INTO countries VALUES (1, 'Canada'), (2, 'USA');
INSERT INTO cities VALUES (1, 'Toronto', 1), (2, 'Dallas', 2), (3, 'Seattle', 2), (4, 'Vancouver', 1);
INSERT INTO customers VALUES (1, 'Sara Khan', 1), (2, 'Omar Ali', 2), (3, 'Lina Park', 3), (4, 'Mei Chen', 4);

-- orders keep a pre-computed order_total (a denormalized column)
CREATE TABLE orders (order_id INT PRIMARY KEY, customer_id INT, order_total DECIMAL(10,2));
CREATE TABLE order_items (order_id INT, product_id INT, qty INT, price DECIMAL(10,2),
                          PRIMARY KEY (order_id, product_id));
INSERT INTO orders VALUES (1001, 1, 53.00), (1002, 2, 25.00), (1003, 3, 30.00);
INSERT INTO order_items VALUES (1001, 101, 1, 35.00), (1001, 102, 2, 9.00),
                               (1002, 103, 1, 25.00), (1003, 104, 1, 30.00);

Let's look at the three tables of the customer hierarchy.

Query
SELECT * FROM countries ORDER BY 1;
Result2 rows · 2 columns
country_idcountry
1Canada
2USA

Two countries, each stored once.

Query
SELECT * FROM cities ORDER BY 1;
Result4 rows · 3 columns
city_idcitycountry_id
1Toronto1
2Dallas2
3Seattle2
4Vancouver1

Four cities, each pointing to its country through country_id.

Query
SELECT * FROM customers ORDER BY 1;
Result4 rows · 3 columns
customer_idnamecity_id
1Sara Khan1
2Omar Ali2
3Lina Park3
4Mei Chen4

Four customers, each pointing to a city. There is no redundancy at all: to find a customer's country we have to go through the city.

Query
SELECT * FROM orders ORDER BY 1;
Result3 rows · 3 columns
order_idcustomer_idorder_total
1001153.00
1002225.00
1003330.00

Each order stores its total. Order 1001 says 53.00.

Query
SELECT * FROM order_items ORDER BY 1;
Result4 rows · 4 columns
order_idproduct_idqtyprice
1001101135.00
100110229.00
1002103125.00
1003104130.00

Order 1001 has two items: 1 x 35.00 and 2 x 9.00, which add up to the stored 53.00.

Step 1: Normalized - every question pays for joins

Let's ask a simple question: how many customers do we have per country? The country is two hops away from the customer, so the query needs two joins.

Query
SELECT co.country, COUNT(*) AS customers
FROM customers c
JOIN cities ci    ON ci.city_id = c.city_id
JOIN countries co ON co.country_id = ci.country_id
GROUP BY co.country
ORDER BY co.country;
Result2 rows · 2 columns
countrycustomers
Canada2
USA2

Canada has 2 customers and USA has 2. On four customers the joins cost nothing. On millions of customers, in every dashboard that refreshes all day, those joins add up.

Step 2: Denormalize - build a flat customer dimension

Now let's denormalize. We build a flat dim_customer table and copy city and country onto every customer row. In a warehouse the pipeline does this once, at load time.

Statement
CREATE TABLE dim_customer (
  customer_id INT, name VARCHAR(50),
  city VARCHAR(50), country VARCHAR(50));
INSERT INTO dim_customer
SELECT c.customer_id, c.name, ci.city, co.country
FROM customers c
JOIN cities ci    ON ci.city_id = c.city_id
JOIN countries co ON co.country_id = ci.country_id;

Now let's check the result.

Query
SELECT * FROM dim_customer ORDER BY customer_id;
Result4 rows · 4 columns
customer_idnamecitycountry
1Sara KhanTorontoCanada
2Omar AliDallasUSA
3Lina ParkSeattleUSA
4Mei ChenVancouverCanada

Look at the country column: Canada and USA now each appear on two rows. That is redundancy, and this time it is on purpose.

Step 3: The same question with zero joins

Let's ask the same question again, this time from the flat table.

Query
SELECT country, COUNT(*) AS customers
FROM dim_customer
GROUP BY country
ORDER BY country;
Result2 rows · 2 columns
countrycustomers
Canada2
USA2

Same answer as Step 1, Canada 2 and USA 2, but now it is a simple GROUP BY with no joins. Every dashboard that reads this table gets the speed-up for free.

Four common ways to denormalize

In practice, denormalization usually shows up in one of these forms:

  • Flatten a hierarchy - city, state and country on the customer dimension. This is exactly what a star schema does.
  • Copy a column - for example the customer name stored on the order, so the order screen needs no join.
  • Pre-compute a value - like order_total on the order, so nobody has to sum the items again.
  • Wide reporting table - one big table with everything a dashboard needs. It gets its own post later in the series (question 43).

Step 4: The price - a copied value goes stale

Every copy has to be kept in sync. Let's change the quantity of one item on order 1001 and not touch the stored order_total. Then we compare the stored total with the real sum of the items.

Statement
UPDATE order_items
SET qty = 3
WHERE order_id = 1001 AND product_id = 102;

Now let's check the result.

Query
SELECT o.order_id, o.order_total,
       SUM(oi.qty * oi.price) AS real_total
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.order_total
HAVING o.order_total <> SUM(oi.qty * oi.price);
Result1 row · 3 columns
order_idorder_totalreal_total
100153.0062.00

Order 1001 still says 53.00, but its items now add up to 62.00. The data contradicts itself. This is the update anomaly that normalization was meant to prevent.

That is why denormalization works best where a pipeline rebuilds the copies, like a data warehouse. In an OLTP system it needs triggers or careful application code to keep every copy in sync.

When should you denormalize?

  • Yes - read-heavy analytics and dashboards, when joins are the measured bottleneck, and when the data is loaded by a pipeline or changes rarely.
  • Be careful - when many users and applications write the same data, because you must keep every copy in sync.

A good line for the interview: "normalize until it hurts, denormalize until it works." And add that denormalization is a deliberate step from a normalized design, not skipping design altogether.

Follow-up questions interviewers ask

Denormalization vs no normalization?

Denormalization is a deliberate, measured step from a normalized design, not skipping design.

How do you keep denormalized copies in sync in OLTP?

Triggers, application code in the same transaction, or materialized views refreshed on a schedule.

Is a star schema denormalized?

Yes, the dimensions are: hierarchies are flattened into one table per dimension.

The full script

Here is everything from this post in one script. Copy it and run it top to bottom.

Full script — copy and run top to bottom
-- ========================================================================
-- Data Warehouse Interview Q&A #12 - What is denormalization?
-- TechBrothersIT. Sample data is invented for teaching.
-- Standard SQL, tested on PostgreSQL 16. Runs top to bottom in one go.
-- Run it in an empty database or schema: other videos reuse table names
-- like dim_customer, and their leftover tables can block the DROPs below.
-- PostgreSQL example:  CREATE SCHEMA dwh_q12;  SET search_path TO dwh_q12;
-- ========================================================================

-- Step 0: create and load the sample data
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS dim_customer;
DROP TABLE IF EXISTS customers;
DROP TABLE IF EXISTS cities;
DROP TABLE IF EXISTS countries;

-- normalized (3NF) OLTP tables
CREATE TABLE countries (country_id INT PRIMARY KEY, country VARCHAR(50));
CREATE TABLE cities (city_id INT PRIMARY KEY, city VARCHAR(50),
                     country_id INT REFERENCES countries(country_id));
CREATE TABLE customers (customer_id INT PRIMARY KEY, name VARCHAR(50),
                        city_id INT REFERENCES cities(city_id));
INSERT INTO countries VALUES (1, 'Canada'), (2, 'USA');
INSERT INTO cities VALUES (1, 'Toronto', 1), (2, 'Dallas', 2), (3, 'Seattle', 2), (4, 'Vancouver', 1);
INSERT INTO customers VALUES (1, 'Sara Khan', 1), (2, 'Omar Ali', 2), (3, 'Lina Park', 3), (4, 'Mei Chen', 4);

-- orders keep a pre-computed order_total (a denormalized column)
CREATE TABLE orders (order_id INT PRIMARY KEY, customer_id INT, order_total DECIMAL(10,2));
CREATE TABLE order_items (order_id INT, product_id INT, qty INT, price DECIMAL(10,2),
                          PRIMARY KEY (order_id, product_id));
INSERT INTO orders VALUES (1001, 1, 53.00), (1002, 2, 25.00), (1003, 3, 30.00);
INSERT INTO order_items VALUES (1001, 101, 1, 35.00), (1001, 102, 2, 9.00),
                               (1002, 103, 1, 25.00), (1003, 104, 1, 30.00);


-- Step 1: Normalized: customers per country needs two joins
SELECT co.country, COUNT(*) AS customers
FROM customers c
JOIN cities ci    ON ci.city_id = c.city_id
JOIN countries co ON co.country_id = ci.country_id
GROUP BY co.country
ORDER BY co.country;

-- Step 2: Denormalize: build a flat customer dimension once (the pipeline does this)
CREATE TABLE dim_customer (
  customer_id INT, name VARCHAR(50),
  city VARCHAR(50), country VARCHAR(50));
INSERT INTO dim_customer
SELECT c.customer_id, c.name, ci.city, co.country
FROM customers c
JOIN cities ci    ON ci.city_id = c.city_id
JOIN countries co ON co.country_id = ci.country_id;

-- check the result
SELECT * FROM dim_customer ORDER BY customer_id;

-- Step 3: Denormalized: the same question with zero joins
SELECT country, COUNT(*) AS customers
FROM dim_customer
GROUP BY country
ORDER BY country;

-- Step 4: The cost: a pre-computed total goes stale
UPDATE order_items
SET qty = 3
WHERE order_id = 1001 AND product_id = 102;

-- check the result
SELECT o.order_id, o.order_total,
       SUM(oi.qty * oi.price) AS real_total
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.order_total
HAVING o.order_total <> SUM(oi.qty * oi.price);

Key points

  • Deliberately adds redundancy so reads need fewer joins
  • Joins happen once at load time, not in every query
  • Techniques: flatten hierarchies, copy columns, pre-compute values, wide tables
  • Price: storage and copies that must stay in sync
  • Best where pipelines write and people read: the warehouse
Next in this series

Question 13 — Cardinality and ER diagrams What is cardinality, and how do you read an ER diagram?

Every query and every result on this page was run on PostgreSQL 16 using the script above. The SQL is standard and runs on most databases with small changes (noted in the script). All tables and data are invented demo data for teaching.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.