SCD Type 2 for Data Engineers: How a Customer Tier Change Broke Historical Reporting
Imagine reviewing a sales dashboard and discovering that revenue previously attributed to Silver customers is now showing under Gold.
The total revenue hasn’t changed. No transactions have disappeared. Yet the report no longer reflects what happened when those sales were made.
What went wrong?
A customer upgraded from Silver to Gold, and the customer table was updated to show their latest tier. When the reporting query joined historical sales to that table, it assigned every transaction to Gold, including purchases made before the upgrade.
This is a common historical reporting challenge in data warehousing and a classic use case for Slowly Changing Dimension Type 2 (SCD Type 2).
In this article, we’ll use a fictional customer-tier example to reproduce the problem, implement SCD Type 2 in PostgreSQL, fix the reporting query and test the results. The SQL examples and validation results were tested in PostgreSQL 17.
1. The Business Scenario
Imagine a subscription business that groups customers into Bronze, Silver and Gold tiers.
Customer 1001 starts the year in Silver and upgrades to Gold on 1 March 2026. The customer makes three purchases:
sale_id
customer_id
sale_date
amount
501
1001
2026-01-15
£100
502
1001
2026-02-20
£150
503
1001
2026-03-10
£200
The first two purchases happened while the customer was Silver. The third happened after the upgrade. So the correct historical breakdown is:
Customer tier
Revenue
Silver
£250
Gold
£200
Total
£450
Before the upgrade, the customer lookup showed Silver. Afterwards, it was overwritten with Gold. That latest record correctly answers “What tier is this customer in now?” but cannot answer “What tier were they in when each purchase happened?”
2. How the Customer Tier Change Broke Reporting
The business runs a revenue-by-tier report and gets Gold: £450, with no Silver row. All three sales have been assigned to the customer’s latest tier.
SELECT
c.customer_tier,
SUM(s.amount)AS total_revenue
FROM fact_sales s
JOIN dim_customer c
ON s.customer_id = c.customer_id
GROUPBY
SELECT
c.customer_tier,
SUM(s.amount)AS total_revenue
FROM fact_sales s
JOIN dim_customer c
ON s.customer_id = c.customer_id
GROUPBY
SELECT
c.customer_tier,
SUM(s.amount)AS total_revenue
FROM fact_sales s
JOIN dim_customer c
ON s.customer_id = c.customer_id
GROUPBY
The query is valid SQL, but dim_customer only holds the current value, Gold. This is a current-state lookup. It is useful if the question is “How much revenue came from customers who are Gold today?” It is not suitable for “How much revenue was generated while customers were Silver or Gold?”
The important distinction is that correct grand totals do not guarantee correct historical attribution. The £450 total is accurate; the tier breakdown is not.
Set up the sample data
To reproduce the example, run the following statements in a practice PostgreSQL database where these tables do not already exist. They create the latest-state lookup and the sales facts. We’ll create the historical dimension separately.
CREATETABLE dim_customer (
customer_id INTEGER PRIMARYKEY,
customer_tier VARCHAR(20)NOTNULL);
CREATETABLE fact_sales (
sale_id INTEGER PRIMARYKEY,
customer_id INTEGER NOTNULL,
sale_date DATE NOTNULL,
amount NUMERIC(10,2)NOTNULL);
INSERTINTO dim_customer (customer_id, customer_tier)VALUES(1001,'Gold');
INSERTINTO fact_sales (
sale_id, customer_id, sale_date, amount
)VALUES(501,1001, DATE '2026-01-15',100.00),(502,1001, DATE '2026-02-20',150.00),(503,1001, DATE '2026-03-10',200.00)
CREATETABLE dim_customer (
customer_id INTEGER PRIMARYKEY,
customer_tier VARCHAR(20)NOTNULL);
CREATETABLE fact_sales (
sale_id INTEGER PRIMARYKEY,
customer_id INTEGER NOTNULL,
sale_date DATE NOTNULL,
amount NUMERIC(10,2)NOTNULL);
INSERTINTO dim_customer (customer_id, customer_tier)VALUES(1001,'Gold');
INSERTINTO fact_sales (
sale_id, customer_id, sale_date, amount
)VALUES(501,1001, DATE '2026-01-15',100.00),(502,1001, DATE '2026-02-20',150.00),(503,1001, DATE '2026-03-10',200.00)
CREATETABLE dim_customer (
customer_id INTEGER PRIMARYKEY,
customer_tier VARCHAR(20)NOTNULL);
CREATETABLE fact_sales (
sale_id INTEGER PRIMARYKEY,
customer_id INTEGER NOTNULL,
sale_date DATE NOTNULL,
amount NUMERIC(10,2)NOTNULL);
INSERTINTO dim_customer (customer_id, customer_tier)VALUES(1001,'Gold');
INSERTINTO fact_sales (
sale_id, customer_id, sale_date, amount
)VALUES(501,1001, DATE '2026-01-15',100.00),(502,1001, DATE '2026-02-20',150.00),(503,1001, DATE '2026-03-10',200.00)
Run the current-state query above after loading this data. Tested result: Gold = £450.00.
3. Introducing SCD Type 2
SCD Type 2 preserves previous versions of important attributes rather than overwriting them. When the customer upgrades, we close the Silver version and insert a Gold version.
customer_key
customer_id
customer_tier
effective_from
effective_to
is_current
1
1001
Silver
2026-01-01
2026-03-01
false
2
1001
Gold
2026-03-01
NULL
true
customer_id is the stable business key; customer_key is a unique surrogate key for each version. effective_from and effective_to define when that version applied. is_current identifies the latest version.
We use an inclusive start and exclusive end: Silver applies from 1 January until before 1 March; Gold applies from 1 March onwards. NULL means the current record has no known end date.
This example uses whole dates, assuming a tier change takes effect at the beginning of the day. Systems with changes during a day should generally use timestamps and consistent timezone rules.
SCD Type 2 also requires a reliable source of changes. It cannot automatically recreate a previous value and effective date that were permanently overwritten without a log, snapshot or other historical source.
The CHECK prevents a version from ending on or before its start date. It does not prevent different versions from overlapping, so we’ll validate that separately.
BEGIN;
UPDATE dim_customer_history
SET
effective_to = DATE '2026-03-01',
is_current = FALSEWHERE customer_id = 1001AND is_current = TRUEAND customer_tier = 'Silver'AND effective_from < DATE '2026-03-01';
INSERTINTO dim_customer_history (
customer_id, customer_tier, effective_from,
effective_to, is_current
)VALUES(1001,'Gold', DATE '2026-03-01',NULL,TRUE);
COMMIT
BEGIN;
UPDATE dim_customer_history
SET
effective_to = DATE '2026-03-01',
is_current = FALSEWHERE customer_id = 1001AND is_current = TRUEAND customer_tier = 'Silver'AND effective_from < DATE '2026-03-01';
INSERTINTO dim_customer_history (
customer_id, customer_tier, effective_from,
effective_to, is_current
)VALUES(1001,'Gold', DATE '2026-03-01',NULL,TRUE);
COMMIT
BEGIN;
UPDATE dim_customer_history
SET
effective_to = DATE '2026-03-01',
is_current = FALSEWHERE customer_id = 1001AND is_current = TRUEAND customer_tier = 'Silver'AND effective_from < DATE '2026-03-01';
INSERTINTO dim_customer_history (
customer_id, customer_tier, effective_from,
effective_to, is_current
)VALUES(1001,'Gold', DATE '2026-03-01',NULL,TRUE);
COMMIT
The transaction keeps the update and insert together: both commit, or neither does. This is an educational example, not a production-ready change-processing pipeline. In particular, rerunning it can create another Gold version because the insert is unconditional. Production code needs checks for duplicate events, unexpected current states, overlapping dates and concurrent writes.
Verify the versions:
SELECT
customer_key, customer_id, customer_tier,
effective_from, effective_to, is_current
FROM dim_customer_history
WHERE customer_id = 1001ORDERBY
SELECT
customer_key, customer_id, customer_tier,
effective_from, effective_to, is_current
FROM dim_customer_history
WHERE customer_id = 1001ORDERBY
SELECT
customer_key, customer_id, customer_tier,
effective_from, effective_to, is_current
FROM dim_customer_history
WHERE customer_id = 1001ORDERBY
Tested result: two versions: Silver (is_current = false, ending 2026-03-01) and Gold (is_current = true, no end date). In a newly created table, the generated keys will normally be 1 and 2.
5. Fixing the Report with a Point-in-Time Join
Now match each sale to the customer version valid on the sale date:
SELECT
s.sale_id,
s.sale_date,
s.amount,
c.customer_tier
FROM fact_sales s
JOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)ORDERBY s.sale_date,
SELECT
s.sale_id,
s.sale_date,
s.amount,
c.customer_tier
FROM fact_sales s
JOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)ORDERBY s.sale_date,
SELECT
s.sale_id,
s.sale_date,
s.amount,
c.customer_tier
FROM fact_sales s
JOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)ORDERBY s.sale_date,
Tested result:
sale_id
sale_date
amount
customer_tier
501
2026-01-15
£100
Silver
502
2026-02-20
£150
Silver
503
2026-03-10
£200
Gold
The effective-date conditions are the crucial difference. They match each transaction to its historical version, rather than today’s customer record.
Now aggregate by historical tier:
SELECT
c.customer_tier,
SUM(s.amount)AS total_revenue
FROM fact_sales s
JOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)GROUPBY c.customer_tier
ORDERBY c.customer_tier DESC
SELECT
c.customer_tier,
SUM(s.amount)AS total_revenue
FROM fact_sales s
JOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)GROUPBY c.customer_tier
ORDERBY c.customer_tier DESC
SELECT
c.customer_tier,
SUM(s.amount)AS total_revenue
FROM fact_sales s
JOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)GROUPBY c.customer_tier
ORDERBY c.customer_tier DESC
Tested result: Silver = £250.00; Gold = £200.00. Total revenue is still £450; the historical attribution is now correct.
Our sales facts contain customer_id, so the query uses that business key and the effective dates. Another warehouse design resolves the relevant customer_key when loading the fact, allowing a direct surrogate-key join. Both approaches require reliable history.
6. Testing Before and After SCD Type 2
We executed the following checks in PostgreSQL 17. The results below are the actual outcomes from our fictional dataset, not hypothetical results.
Compare the before-and-after reports
Customer tier
Before
After
Silver
£0
£250
Gold
£450
£200
Total
£450
£450
The incorrect report didn’t lose revenue. It misclassified £250.
Reconcile the original facts and the join
SELECTCOUNT(*)AS sales_count,
SUM(amount)AS total_revenue
FROM
SELECTCOUNT(*)AS sales_count,
SUM(amount)AS total_revenue
FROM
SELECTCOUNT(*)AS sales_count,
SUM(amount)AS total_revenue
FROM
Tested result: 3 sales; £450.00.
SELECTCOUNT(*)AS joined_sales_count,
SUM(s.amount)AS joined_revenue
FROM fact_sales s
JOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)
SELECTCOUNT(*)AS joined_sales_count,
SUM(s.amount)AS joined_revenue
FROM fact_sales s
JOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)
SELECTCOUNT(*)AS joined_sales_count,
SUM(s.amount)AS joined_revenue
FROM fact_sales s
JOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)
Tested result: 3 joined sales; £450.00. Matching totals and counts are useful, but they don’t prove each sale matched exactly once.
Check for missing or duplicate matches
SELECT
s.sale_id,COUNT(c.customer_key)AS matching_versions
FROM fact_sales s
LEFTJOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)GROUPBY s.sale_id
HAVINGCOUNT(c.customer_key) <> 1
SELECT
s.sale_id,COUNT(c.customer_key)AS matching_versions
FROM fact_sales s
LEFTJOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)GROUPBY s.sale_id
HAVINGCOUNT(c.customer_key) <> 1
SELECT
s.sale_id,COUNT(c.customer_key)AS matching_versions
FROM fact_sales s
LEFTJOIN dim_customer_history c
ON s.customer_id = c.customer_id
AND s.sale_date >= c.effective_from
AND(
s.sale_date < c.effective_to
OR c.effective_to ISNULL)GROUPBY s.sale_id
HAVINGCOUNT(c.customer_key) <> 1
Tested result: 0 rows. Each sale matched exactly one historical version. A count of zero would mean missing history; more than one would mean duplicate matches and potentially inflated revenue.
Check for overlapping historical versions
SELECT
a.customer_id,
a.customer_key AS first_version,
b.customer_key AS second_version
FROM dim_customer_history a
JOIN dim_customer_history b
ON a.customer_id = b.customer_id
AND a.customer_key < b.customer_key
AND(
b.effective_to ISNULLOR a.effective_from < b.effective_to
)AND(
a.effective_to ISNULLOR b.effective_from < a.effective_to
)
SELECT
a.customer_id,
a.customer_key AS first_version,
b.customer_key AS second_version
FROM dim_customer_history a
JOIN dim_customer_history b
ON a.customer_id = b.customer_id
AND a.customer_key < b.customer_key
AND(
b.effective_to ISNULLOR a.effective_from < b.effective_to
)AND(
a.effective_to ISNULLOR b.effective_from < a.effective_to
)
SELECT
a.customer_id,
a.customer_key AS first_version,
b.customer_key AS second_version
FROM dim_customer_history a
JOIN dim_customer_history b
ON a.customer_id = b.customer_id
AND a.customer_key < b.customer_key
AND(
b.effective_to ISNULLOR a.effective_from < b.effective_to
)AND(
a.effective_to ISNULLOR b.effective_from < a.effective_to
)
Tested result: 0 rows. The Silver period ends precisely when the Gold period starts; the ranges don’t overlap.
Check for exactly one current version
SELECT
customer_id,
SUM(CASEWHEN is_current THEN1ELSE0END)AS current_version_count
FROM dim_customer_history
GROUPBY customer_id
HAVING SUM(CASEWHEN is_current THEN1ELSE0END) <> 1
SELECT
customer_id,
SUM(CASEWHEN is_current THEN1ELSE0END)AS current_version_count
FROM dim_customer_history
GROUPBY customer_id
HAVING SUM(CASEWHEN is_current THEN1ELSE0END) <> 1
SELECT
customer_id,
SUM(CASEWHEN is_current THEN1ELSE0END)AS current_version_count
FROM dim_customer_history
GROUPBY customer_id
HAVING SUM(CASEWHEN is_current THEN1ELSE0END) <> 1
Tested result: 0 rows. This catches customers with no current version as well as customers with multiple current versions.
Test the upgrade-date boundary
SELECT
customer_id,
customer_tier
FROM dim_customer_history
WHERE customer_id = 1001AND DATE '2026-03-01' >= effective_from
AND(
DATE '2026-03-01' < effective_to
OR effective_to ISNULL)
SELECT
customer_id,
customer_tier
FROM dim_customer_history
WHERE customer_id = 1001AND DATE '2026-03-01' >= effective_from
AND(
DATE '2026-03-01' < effective_to
OR effective_to ISNULL)
SELECT
customer_id,
customer_tier
FROM dim_customer_history
WHERE customer_id = 1001AND DATE '2026-03-01' >= effective_from
AND(
DATE '2026-03-01' < effective_to
OR effective_to ISNULL)
Tested result: customer 1001, Gold. A sale on 1 March matches the new tier, not Silver.
These checks validate the example, not every possible production scenario. Real pipelines also need to handle late-arriving changes, gaps, corrections and repeated events.
7. Common SCD Type 2 Interview Questions
What’s the difference between SCD Type 1 and Type 2?
Type 1 overwrites an existing value. Type 2 keeps historical versions with effective dates. Type 1 can suit corrections where history isn’t needed; Type 2 suits attributes whose past values matter to reporting.
Why use a surrogate key?
The business key identifies the customer. The surrogate key identifies a specific version of that customer. Customer 1001 therefore has one business key but two historical version keys.
What if the same change arrives twice?
A reliable pipeline should detect a previously processed event and avoid inserting an unnecessary duplicate. This property is called idempotency. Our simple transaction deliberately doesn’t implement that protection.
How do you handle a late-arriving change?
Suppose the business later confirms the Gold upgrade was effective on 20 February, not 1 March. The version dates must be corrected, and affected sales may need to be reprocessed, especially if facts store surrogate keys. A sale loaded late can still match the correct version if its true transaction date and the historical dimension are accurate.
Can SCD Type 2 recover history that was overwritten?
Not on its own. You need a reliable historical source, such as an audit log, event stream or snapshot, to reconstruct the previous tier and when it changed.
Should every changing attribute use Type 2?
No. Preserve history when it supports a real reporting or audit requirement. If only the current value matters, a simpler current-state design may be sufficient.
Common mistakes include joining historical facts to the latest record, filtering on is_current for historical reporting, allowing overlapping date ranges, leaving gaps in history and validating only grand totals.
8. Key Takeaways
Correct totals can hide incorrect attribution. Our original report had £450 in revenue but assigned £250 to the wrong tier.
Current-state and historical reporting answer different questions. Be clear about which one the business needs.
SCD Type 2 preserves versions, but queries must use the right dates. The point-in-time join is what fixes the report.
Validate the history as well as the totals. Check for missing matches, duplicate matches, overlaps and invalid current-version counts.
A report can contain every transaction and still tell the wrong story if its historical context has been lost.
Further Practice
If you’re preparing for a data engineering or analytics interview, being able to explain the approach, write the SQL and validate the results matters as much as knowing the definition of SCD Type 2.