DWH Interview Question 30 - How do you handle late-arriving dimensions?

Data Warehouse Interview Q&A · Question 30

In this post we are going to cover late-arriving dimensions, and how to load a fact when its dimension row has not shown up yet.

A late-arriving dimension happens when a fact arrives before its dimension row. A common example: a new customer buys something today, the point of sale system sends the sale to the warehouse right away, but the CRM only sends the new customer tomorrow. When the fact load looks up the customer's surrogate key, there is nothing to find.

We will see how a plain lookup silently loses that sale, compare the three usual ways teams handle it, and then build the recommended one, the inferred member, step by step in SQL.

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

The interview answer

Say this first

A late-arriving dimension happens when a fact arrives before its dimension row, like a sale for a customer the CRM has not sent yet. The best practice is an inferred member: insert a placeholder dimension row with the natural key and a flag, load the fact with its real surrogate key, and overwrite the placeholder in place when the real data arrives.

The key idea: the fact gets its final surrogate key on day one, and only the small dimension row is fixed later.

Let's create some sample data

We need a customer dimension, a staging table with today's sales, and an empty fact table. The dimension already has an unknown member with key -1, and every row has an is_inferred flag.

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

CREATE TABLE dim_customer (
  customer_key INT PRIMARY KEY,
  customer_id  VARCHAR(10),       -- natural key from the CRM
  name         VARCHAR(50),
  city         VARCHAR(50),
  is_inferred  CHAR(1)            -- 'Y' = placeholder created by the fact load
);
INSERT INTO dim_customer VALUES
  (-1, 'N/A',  'Unknown',   'Unknown', 'N'),
  ( 1, 'C101', 'Sara Khan', 'Toronto', 'N'),
  ( 2, 'C102', 'Omar Ali',  'Dallas',  'N');

-- today's sales from the POS system; C104 is a brand-new customer
-- that the CRM has not sent to the warehouse yet
CREATE TABLE stg_sales (sale_id INT, sale_date DATE, customer_id VARCHAR(10), amount DECIMAL(10,2));
INSERT INTO stg_sales VALUES
  (5001, '2026-04-01', 'C101', 40.00),
  (5002, '2026-04-01', 'C104', 75.00),
  (5003, '2026-04-02', 'C102', 30.00);

CREATE TABLE fact_sales (
  sale_id      INT PRIMARY KEY,
  sale_date    DATE,
  customer_key INT REFERENCES dim_customer(customer_key),
  amount       DECIMAL(10,2)
);

Let's look at the customer dimension first.

Query
SELECT * FROM dim_customer ORDER BY 1;
Result3 rows · 5 columns
customer_keycustomer_idnamecityis_inferred
-1N/AUnknownUnknownN
1C101Sara KhanTorontoN
2C102Omar AliDallasN

We know two real customers, C101 and C102, plus the unknown row. Now the sales waiting in staging.

Query
SELECT * FROM stg_sales ORDER BY 1;
Result3 rows · 4 columns
sale_idsale_datecustomer_idamount
50012026-04-01C10140.00
50022026-04-01C10475.00
50032026-04-02C10230.00

Three sales for 145.00 in total. Sale 5002 is for C104, a brand-new customer that is not in dim_customer yet.

Step 1: The naive lookup drops a sale

Let's start with the lookup most people write first: join staging to the dimension on the natural key customer_id to get the surrogate key.

Query
SELECT s.sale_id, d.customer_key, s.amount
FROM stg_sales s
JOIN dim_customer d
  ON d.customer_id = s.customer_id
ORDER BY s.sale_id;
Result2 rows · 3 columns
sale_idcustomer_keyamount
5001140.00
5003230.00

Only two rows come back, 5001 and 5003. Sale 5002 has no matching customer, so the inner join simply drops it. We staged 145.00 but would load only 70.00, and there is no error and no warning. That is the dangerous part: the revenue just goes missing.

Three ways to handle it

Before we fix it, here are the three options teams usually choose from.

OptionTotals todayFix later
1. Hold in staging✗ missingretry the load
2. Unknown member (-1)✓ correctupdate the FACT keys
3. Inferred member✓ correctupdate one DIM row
  • Hold in staging - keep the fact back until the customer arrives. Nothing is wrong in the warehouse, but today's revenue is missing from reports and you need a retry process.
  • Unknown member (-1) - load the fact against the unknown row. Totals are correct, but later you must find those facts and update their keys, and updating a large fact table is slow and risky.
  • Inferred member - create the customer row now as a placeholder with a flag. The fact gets its final key today, and only one small dimension row is updated later. This is the Kimball recommendation.

Let's build option three.

Step 2: Create the inferred member

Now let's find every customer in staging that is not in the dimension yet and insert a placeholder for it. Each new row gets the next surrogate key, Unknown for the attributes it does not know yet, and is_inferred = 'Y'.

Statement
INSERT INTO dim_customer
SELECT m.max_key + ROW_NUMBER() OVER (ORDER BY s.customer_id),
       s.customer_id, 'Unknown', 'Unknown', 'Y'
FROM (SELECT DISTINCT customer_id FROM stg_sales) s
CROSS JOIN (SELECT MAX(customer_key) AS max_key
            FROM dim_customer) m
WHERE NOT EXISTS (SELECT 1 FROM dim_customer d
                  WHERE d.customer_id = s.customer_id);

Now let's check the result.

Query
SELECT * FROM dim_customer ORDER BY customer_key;
Result4 rows · 5 columns
customer_keycustomer_idnamecityis_inferred
-1N/AUnknownUnknownN
1C101Sara KhanTorontoN
2C102Omar AliDallasN
3C104UnknownUnknownY

C104 now has customer key 3. It is a real row with a real surrogate key, it just does not have real attributes yet. The NOT EXISTS check means customers we already know, like C101 and C102, are not touched.

Step 3: Load the facts

Now we run exactly the same kind of inner join as in Step 1, but this time to load the fact table.

Statement
INSERT INTO fact_sales
SELECT s.sale_id, s.sale_date, d.customer_key, s.amount
FROM stg_sales s
JOIN dim_customer d
  ON d.customer_id = s.customer_id;

Now let's check the result.

Query
SELECT * FROM fact_sales ORDER BY sale_id;
Result3 rows · 4 columns
sale_idsale_datecustomer_keyamount
50012026-04-01140.00
50022026-04-01375.00
50032026-04-02230.00

All three sales are loaded, 145.00 in total, and sale 5002 points to customer key 3. Nothing was dropped, and nothing went to the unknown member.

Step 4: The real customer arrives

The next day the CRM sends C104: her name is Lina Park, from Boston. Let's update the placeholder row in place with the real name and city, and set the flag back to N. The WHERE clause only touches rows that are still inferred.

Statement
UPDATE dim_customer
SET name = 'Lina Park', city = 'Boston', is_inferred = 'N'
WHERE customer_id = 'C104'
  AND is_inferred = 'Y';

Now let's check the result.

Query
SELECT * FROM dim_customer ORDER BY customer_key;
Result4 rows · 5 columns
customer_keycustomer_idnamecityis_inferred
-1N/AUnknownUnknownN
1C101Sara KhanTorontoN
2C102Omar AliDallasN
3C104Lina ParkBostonN

Row 3 now shows Lina Park from Boston, with is_inferred set to N. The surrogate key is still 3, so the fact table does not need to change at all.

Step 5: The report is now correct

Finally, let's run a sales report by customer name.

Query
SELECT d.name, SUM(f.amount) AS sales
FROM fact_sales f
JOIN dim_customer d
  ON d.customer_key = f.customer_key
GROUP BY d.name
ORDER BY sales DESC;
Result3 rows · 2 columns
namesales
Lina Park75.00
Sara Khan40.00
Omar Ali30.00

Lina Park shows her 75.00, Sara Khan 40.00 and Omar Ali 30.00. The sale was never lost, and the fact row was never updated. Only one dimension row changed.

The SCD Type 2 twist

If the customer dimension is SCD Type 2, should the real data create a new version of the row? No. The placeholder was never true, so we overwrite it in place, even for Type 2 columns. Versioning starts only after the real data has arrived.

Tip

Count the rows that still have is_inferred = 'Y' and alert if any stay inferred for days. It usually means the source system has a problem.

The reverse case, where the fact itself arrives late, is a different problem, and it is the next question in the series.

Follow-up questions interviewers ask

Why not just use the unknown member (-1)?

Totals are right, but you must later find and re-key those facts, which means updating a large fact table.

SCD Type 2 dimension: new version when real data arrives?

No. The placeholder was never true, so overwrite it in place; versioning starts after that.

How do you monitor inferred members?

Count rows with is_inferred = Y and alert if any stay inferred longer than expected.

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 #30 - Late-arriving dimensions
-- 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_q30;  SET search_path TO dwh_q30;
-- ========================================================================

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

CREATE TABLE dim_customer (
  customer_key INT PRIMARY KEY,
  customer_id  VARCHAR(10),       -- natural key from the CRM
  name         VARCHAR(50),
  city         VARCHAR(50),
  is_inferred  CHAR(1)            -- 'Y' = placeholder created by the fact load
);
INSERT INTO dim_customer VALUES
  (-1, 'N/A',  'Unknown',   'Unknown', 'N'),
  ( 1, 'C101', 'Sara Khan', 'Toronto', 'N'),
  ( 2, 'C102', 'Omar Ali',  'Dallas',  'N');

-- today's sales from the POS system; C104 is a brand-new customer
-- that the CRM has not sent to the warehouse yet
CREATE TABLE stg_sales (sale_id INT, sale_date DATE, customer_id VARCHAR(10), amount DECIMAL(10,2));
INSERT INTO stg_sales VALUES
  (5001, '2026-04-01', 'C101', 40.00),
  (5002, '2026-04-01', 'C104', 75.00),
  (5003, '2026-04-02', 'C102', 30.00);

CREATE TABLE fact_sales (
  sale_id      INT PRIMARY KEY,
  sale_date    DATE,
  customer_key INT REFERENCES dim_customer(customer_key),
  amount       DECIMAL(10,2)
);


-- Step 1: The naive lookup: an inner join silently drops the sale
SELECT s.sale_id, d.customer_key, s.amount
FROM stg_sales s
JOIN dim_customer d
  ON d.customer_id = s.customer_id
ORDER BY s.sale_id;

-- Step 2: Create an inferred member for every customer that is not in the dimension yet
INSERT INTO dim_customer
SELECT m.max_key + ROW_NUMBER() OVER (ORDER BY s.customer_id),
       s.customer_id, 'Unknown', 'Unknown', 'Y'
FROM (SELECT DISTINCT customer_id FROM stg_sales) s
CROSS JOIN (SELECT MAX(customer_key) AS max_key
            FROM dim_customer) m
WHERE NOT EXISTS (SELECT 1 FROM dim_customer d
                  WHERE d.customer_id = s.customer_id);

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

-- Step 3: Load the facts: now every sale finds its key
INSERT INTO fact_sales
SELECT s.sale_id, s.sale_date, d.customer_key, s.amount
FROM stg_sales s
JOIN dim_customer d
  ON d.customer_id = s.customer_id;

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

-- Step 4: Next day: the CRM sends C104, so overwrite the placeholder in place
UPDATE dim_customer
SET name = 'Lina Park', city = 'Boston', is_inferred = 'N'
WHERE customer_id = 'C104'
  AND is_inferred = 'Y';

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

-- Step 5: The report is now correct, without touching the fact
SELECT d.name, SUM(f.amount) AS sales
FROM fact_sales f
JOIN dim_customer d
  ON d.customer_key = f.customer_key
GROUP BY d.name
ORDER BY sales DESC;

Key points

  • Fact arrives before its dimension row
  • A plain inner join silently drops the fact
  • Inferred member: placeholder row + natural key + flag
  • Fact gets its final key on day one
  • Real data: overwrite in place, even for SCD2
Next in this series

Question 31 — Late-arriving facts How do you handle late-arriving facts?

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.