DWH Interview Question 43 - One big table (OBT) or star schema: which is better?

Data Warehouse Interview Q&A · Question 43

In this post we are going to cover the one big table (OBT), and answer an interview question that comes up more and more on cloud warehouses: one big table or star schema, which is better?

A one big table is a fully denormalized table. Instead of keeping a narrow fact table and separate dimensions, we do the joins once, in advance, and store every customer and product attribute right on each sale row. Queries then need no joins at all.

We will build an OBT from a small star schema with SQL, query it, and then measure what it costs when a business attribute changes.

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

The interview answer

Say this first

A one big table (OBT) is a fully denormalized table: every dimension attribute is copied onto every fact row, so queries need no joins. It is fast and simple on columnar engines and great for BI extracts and ML, but it duplicates data, makes attribute changes expensive and history hard. Best practice: keep the star as the source of truth and generate OBTs from it for specific uses.

What a one big table is

A star schema keeps the fact table narrow and puts the descriptions in dimensions. Queries join them at run time. A one big table flattens the star:

  • Star schema - fact_sales holds keys and amounts; dim_customer and dim_product hold the descriptions.
  • One big table - obt_sales is one wide table with the customer and product attributes on every sale, so there are no joins.

Columnar warehouses like BigQuery, Snowflake and Databricks compress repeated values very well, so the extra width costs less than you might expect.

Let's create some sample data

We create a small star schema (dim_customer, dim_product, fact_sales) and an empty obt_sales table that will hold the flattened version.

Sample data — run this first
DROP TABLE IF EXISTS obt_sales;
DROP TABLE IF EXISTS fact_sales;
DROP TABLE IF EXISTS dim_customer;
DROP TABLE IF EXISTS dim_product;

-- the star schema
CREATE TABLE dim_customer (customer_key INT PRIMARY KEY, name VARCHAR(30), segment VARCHAR(20));
CREATE TABLE dim_product  (product_key INT PRIMARY KEY, name VARCHAR(30), category VARCHAR(20));
CREATE TABLE fact_sales   (sale_id INT PRIMARY KEY, sale_date DATE, customer_key INT, product_key INT, amount DECIMAL(10,2));
INSERT INTO dim_customer VALUES (1, 'Sara', 'Retail'), (2, 'Omar', 'Business');
INSERT INTO dim_product  VALUES (1, 'Pen', 'Office'), (2, 'Notebook', 'Office'), (3, 'Headphones', 'Electronics');
INSERT INTO fact_sales VALUES
  (1, '2026-06-01', 1, 1,  5.00),
  (2, '2026-06-01', 2, 2, 12.00),
  (3, '2026-06-02', 1, 3, 60.00),
  (4, '2026-06-02', 2, 1,  5.00),
  (5, '2026-06-03', 1, 2, 12.00);

-- the one big table: every attribute copied onto every sale
CREATE TABLE obt_sales (
  sale_id       INT,
  sale_date     DATE,
  customer_name VARCHAR(30),
  segment       VARCHAR(20),
  product_name  VARCHAR(30),
  category      VARCHAR(20),
  amount        DECIMAL(10,2)
);

Here are the five sales in the star. Each one only has keys and an amount.

Query
SELECT * FROM fact_sales ORDER BY 1;
Result5 rows · 5 columns
sale_idsale_datecustomer_keyproduct_keyamount
12026-06-01115.00
22026-06-012212.00
32026-06-021360.00
42026-06-02215.00
52026-06-031212.00

The products: two of the three are in the Office category.

Query
SELECT * FROM dim_product ORDER BY 1;
Result3 rows · 3 columns
product_keynamecategory
1PenOffice
2NotebookOffice
3HeadphonesElectronics

Step 1: Build the one big table from the star

Now let's build the OBT. An INSERT ... SELECT joins the fact to both dimensions, once, and writes every attribute into the wide table.

Statement
INSERT INTO obt_sales
SELECT f.sale_id, f.sale_date,
       c.name, c.segment,
       p.name, p.category,
       f.amount
FROM fact_sales f
JOIN dim_customer c ON c.customer_key = f.customer_key
JOIN dim_product  p ON p.product_key  = f.product_key;

Now let's check the result.

Query
SELECT * FROM obt_sales ORDER BY sale_id;
Result5 rows · 7 columns
sale_idsale_datecustomer_namesegmentproduct_namecategoryamount
12026-06-01SaraRetailPenOffice5.00
22026-06-01OmarBusinessNotebookOffice12.00
32026-06-02SaraRetailHeadphonesElectronics60.00
42026-06-02OmarBusinessPenOffice5.00
52026-06-03SaraRetailNotebookOffice12.00

All five sales now carry the customer name, segment, product name and category. Notice the repetition: Office appears on four rows, and Sara on three. In the star, Office is stored once per product. In a real table, these repeats would be millions of copies.

Step 2: Query the OBT with no joins

Now the payoff. Let's get sales by category from the one big table.

Query
SELECT category, SUM(amount) AS sales
FROM obt_sales
GROUP BY category
ORDER BY category;
Result2 rows · 2 columns
categorysales
Electronics60.00
Office34.00

Electronics sold 60.00 and Office 34.00. This is a short query on one table, with no joins and no keys, which is easy for analysts, BI tools and data science notebooks. On a columnar engine it only reads the two columns it needs, so it is fast.

The star gives the same answer with one join to the product dimension. Modern engines handle those joins well too, so speed alone is rarely the deciding factor.

Step 3: The cost of changing an attribute

Now the business renames the Office category to Stationery. Let's count how many rows each design has to rewrite.

Query
SELECT (SELECT COUNT(*) FROM dim_product
        WHERE category = 'Office') AS star_rows,
       (SELECT COUNT(*) FROM obt_sales
        WHERE category = 'Office') AS obt_rows;
Result1 row · 2 columns
star_rowsobt_rows
24

The star updates 2 rows, one per Office product in dim_product, and every report sees the new name immediately. The OBT has to rewrite 4 rows, one for every sale ever made in that category. Here it is only 4, but in production that can be hundreds of millions of fact rows against a handful of dimension rows.

History is harder too. Type 2 changes, late corrections and keeping several wide tables consistent all become rebuild jobs.

When to use which

Each design wins somewhere.

One big tableStar schema
Joinsnonefact + dimensions
Attribute changerewrite many rowsupdate a few dim rows
History (SCD)hardbuilt in
Best forone dashboard, ML, extractsthe shared core model
  • Use a one big table for a specific dashboard, a BI extract or machine learning features on a columnar engine, where speed and simplicity matter most.
  • Use a star schema as the core model: shared, conformed dimensions, history with SCD, and many teams asking different questions.
Best answer

Keep the star as the source of truth, and generate one big tables from it for specific uses, rebuilt on every load.

Follow-up questions interviewers ask

Why do OBTs work well on columnar engines?

Queries read only the columns they need, and repeated values compress very well.

What is the main downside of an OBT?

Attribute changes and history require rewriting many fact rows, and data is duplicated across wide tables.

Can you have both?

Yes: the common pattern is a star schema as the source of truth, with OBTs generated from it.

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 #43 - One big table vs star schema
-- 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_q43;  SET search_path TO dwh_q43;
-- ========================================================================

-- Step 0: create and load the sample data
DROP TABLE IF EXISTS obt_sales;
DROP TABLE IF EXISTS fact_sales;
DROP TABLE IF EXISTS dim_customer;
DROP TABLE IF EXISTS dim_product;

-- the star schema
CREATE TABLE dim_customer (customer_key INT PRIMARY KEY, name VARCHAR(30), segment VARCHAR(20));
CREATE TABLE dim_product  (product_key INT PRIMARY KEY, name VARCHAR(30), category VARCHAR(20));
CREATE TABLE fact_sales   (sale_id INT PRIMARY KEY, sale_date DATE, customer_key INT, product_key INT, amount DECIMAL(10,2));
INSERT INTO dim_customer VALUES (1, 'Sara', 'Retail'), (2, 'Omar', 'Business');
INSERT INTO dim_product  VALUES (1, 'Pen', 'Office'), (2, 'Notebook', 'Office'), (3, 'Headphones', 'Electronics');
INSERT INTO fact_sales VALUES
  (1, '2026-06-01', 1, 1,  5.00),
  (2, '2026-06-01', 2, 2, 12.00),
  (3, '2026-06-02', 1, 3, 60.00),
  (4, '2026-06-02', 2, 1,  5.00),
  (5, '2026-06-03', 1, 2, 12.00);

-- the one big table: every attribute copied onto every sale
CREATE TABLE obt_sales (
  sale_id       INT,
  sale_date     DATE,
  customer_name VARCHAR(30),
  segment       VARCHAR(20),
  product_name  VARCHAR(30),
  category      VARCHAR(20),
  amount        DECIMAL(10,2)
);


-- Step 1: Build the one big table from the star
INSERT INTO obt_sales
SELECT f.sale_id, f.sale_date,
       c.name, c.segment,
       p.name, p.category,
       f.amount
FROM fact_sales f
JOIN dim_customer c ON c.customer_key = f.customer_key
JOIN dim_product  p ON p.product_key  = f.product_key;

-- check the result
SELECT * FROM obt_sales ORDER BY sale_id;

-- Step 2: Querying the OBT: no joins at all
SELECT category, SUM(amount) AS sales
FROM obt_sales
GROUP BY category
ORDER BY category;

-- Step 3: Renaming the Office category: rows to rewrite in each design
SELECT (SELECT COUNT(*) FROM dim_product
        WHERE category = 'Office') AS star_rows,
       (SELECT COUNT(*) FROM obt_sales
        WHERE category = 'Office') AS obt_rows;

Key points

  • OBT: every attribute on every fact row
  • No joins, fast on columnar engines
  • Duplicated data; changes rewrite many rows
  • Star: shared core, easy changes and history
  • Generate OBTs from the star
Next in this series

Question 44 — Partitioning, clustering and materialized views How do you make a data warehouse fast?

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.