"On two occasions I have been asked, 'Pray, Mr. Babbage, if you put into the machine wrong figures, will the right answers come out?' I am not able rightly to apprehend the kind of confusion of ideas that could provoke such a question."
Charles Babbage, 1864
The "Product-Sales-Customer" model is a classic example of a star schema for a Data Warehouse (DWH) according to Ralph Kimball's methodology. The underlying business process is intuitively understandable to almost anyone.
When developing an ETL pipeline (data transformation pipeline), a data engineer needs reliable source data on which to safely design and debug the logic.
An excellent dataset for such tasks can be found in the GitHub repository prepared by Baraa Khatib Salkini: sql-data-warehouse-project.
The source data is stored in the 01_datasets/source_crm directory, and its main structure can be described by the following diagram:
As can be seen from the diagram, the model consists of one fact table and two dimension tables. The "Product" dimension is implemented as a Slowly Changing Dimension Type 2 (SCD 2), meaning it stores the full history of changes. The "Customer" dimension, on the contrary, is implemented as a Slowly Changing Dimension Type 1 (SCD 1): it does not store history, but simply overwrites attributes, keeping only their current state.
Overall, this is an excellent dataset containing 18,495 customers, 397 products, and 60,398 records in the sales fact table.
However, in its raw form, this data has quality and integrity issues. The purpose of this article is to describe how to clean up this data so that it can be correctly used for DWH modeling and configuring the ETL pipeline.
Data Structure Description
"Product" Dimension (prd_info table)
| field | description |
|---|---|
| prd_id | surrogate product key |
| prd_key | product business key |
| prd_nm | product name |
| prd_cost | product cost |
| prd_line | product line |
| prd_start_dt | validity period start date |
| prd_end_dt | validity period end date |
For different validity periods of the same product, the business key remains unchanged, while the surrogate key changes. The currently active record has a NULL value in the end date field.
Example of the first few records from the table:
| prd_id | prd_key | prd_nm | prd_cost | prd_line | prd_start_dt | prd_end_dt |
|---|---|---|---|---|---|---|
| 210 | CO-RF-FR-R92B-58 | HL Road Frame - Black- 58 | R | 2003-07-01 | NULL | |
| 211 | CO-RF-FR-R92R-58 | HL Road Frame - Red- 58 | R | 2003-07-01 | NULL | |
| 212 | AC-HE-HL-U509-R | Sport-100 Helmet- Red | 12 | S | 2011-07-01 | 2007-12-28 |
| 213 | AC-HE-HL-U509-R | Sport-100 Helmet- Red | 14 | S | 2012-07-01 | 2008-12-27 |
| 214 | AC-HE-HL-U509-R | Sport-100 Helmet- Red | 13 | S | 2013-07-01 | NULL |
Right here, we can already notice data quality issues:
- The product cost is missing in the first two rows.
- The validity period end date is earlier than its start date.
- Even if we swap these dates, the validity periods will still overlap.
Let's note these errors as tasks that require fixing directly in the data.
"Customer" Dimension (cust_info table)
| field | description |
|---|---|
| cst_id | surrogate customer key |
| cst_key | customer business key |
| cst_firstname | customer first name |
| cst_lastname | customer last name |
| cst_marital_status | customer marital status |
| cst_gndr | customer gender |
| cst_create_date | attribute creation or update date |
Example of a few records from the table (at the beginning and in the middle of the sample):
| cst_id | cst_key | cst_firstname | cst_lastname | cst_marital_status | cst_gndr | cst_create_date |
|---|---|---|---|---|---|---|
| 11000 | AW00011000 | Jon | Yang | M | M | 2025-10-06 |
| 11001 | AW00011001 | Eugene | Huang | S | M | 2025-10-02 |
| 29466 | AW00029466 | NULL | NULL | M | NULL | 2026-01-25 |
| 29466 | AW00029466 | Lance | Jimenez | M | NULL | 2026-01-26 |
| 29466 | AW00029466 | Lance | Jimenez | M | M | 2026-01-27 |
It is worth noting here that this is a Type 1 SCD dimension. For the same combination of surrogate and business keys, there are multiple records, but for analytics, only the latest one (with the maximum date) should be used.
"Sales" Fact Table (sales_details table)
| field | description |
|---|---|
| sls_ord_num | order business key |
| sls_prd_key | product business key |
| sls_cust_id | surrogate customer key |
| sls_order_dt | order date |
| sls_ship_dt | shipping date |
| sls_due_dt | planned delivery date |
| sls_quantity | quantity |
| sls_price | price per unit |
| sls_sales | sales amount |
Example of a few records from the table (at the beginning and in the middle of the sample):
| sls_ord_num | sls_prd_key | sls_cust_id | sls_order_dt | sls_ship_dt | sls_due_dt | sls_sales | sls_quantity | sls_price |
|---|---|---|---|---|---|---|---|---|
| SO43697 | BK-R93R-62 | 21768 | 20101229 | 20110105 | 20110110 | 3578 | 1 | 3578 |
| SO43698 | BK-M82S-44 | 28389 | 20101229 | 20110105 | 20110110 | 3400 | 1 | 3400 |
| SO43699 | BK-M82S-44 | 25863 | 20101229 | 20110105 | 20110110 | 3400 | 1 | 3400 |
| SO51181 | HL-U509-R | 12132 | 20121228 | 20130104 | 20130109 | 35 | 1 | 35 |
| SO51182 | BK-R79Y-42 | 22998 | 20121228 | 20130104 | 20130109 | 1701 | 1 | 1701 |
The following features are noticeable here:
- The product key in the fact table is shortened:
HL-U509-Ris used instead of the full keyAC-HE-HL-U509-R. - Dates are presented in a different format: without using a hyphen as a separator.
- The product cost does not match: the fact table shows a price of 35, while the maximum price for this product in the product dimension is 13.
The answer to the first observation lies in the data structure: the product category is encoded at the beginning of the product dimension's business key. This can be seen in the category reference file source_erp/PX_CAT_G1V2.csv, which contains the following information:
| ID | CAT | SUBCAT | MAINTENANCE |
|---|---|---|---|
| AC_HE | Accessories | Helmets | Yes |
Data Correction
So, we have reviewed the data structure and identified errors within it. Our task is to correct this data.
But first, we will simplify the structure to leave only three entities, as shown in the diagram above:
- We will remove categories and the product line. With these additional attributes, the data schema would turn into a "Snowflake" schema, so for simplicity, we will omit them. To do this:
- Remove the
prd_linefield from theprd_infotable. - Cut the first 6 characters from the business key
prd_key, leaving only the product code.
- Remove the
- Remove the
cst_marital_statusandcst_gndrfields from thecust_infotable for the same reason.
We will perform all operations in a SQLite database, as the data volume is relatively small and a single-user engine is more than enough for us. The correction process can be described by the following diagram using the prd_info table as an example:
The sequence of actions is as follows:
- Import the
prd_infotable from theprd_info.csvfile. - Copy the required fields into the
Producttable, where we also create several auxiliary fields. In doing so, some fields are populated immediately during the copy process. - Using a sequence of queries, we populate the remaining auxiliary fields by generating the necessary data in them.
- Then, we update the main data, using the auxiliary fields as variables for the correction algorithms.
- Upon completion of the correction, before writing to files, we delete the auxiliary fields. Since they may also be needed for correcting other tables (e.g., the fact table), they are deleted only after all tables have been processed.
It should be noted that the majority of the data that needs to be corrected consists of dates. We will regenerate them randomly, but in a way that keeps them consistent. The SQLite query language does not support procedural programming (variables, conditions, loops), so the initial constants for dates and their ranges can be set using a CTE (Common Table Expression):
WITH bounds AS ( SELECT '2025-01-01' AS A, -- Start date 365 AS B -- Generation interval in days ) Here we set the start date bounds.A (2025-01-01) and the generation range for the entire year of 2025 - 365 days from the start date (bounds.B).
Now, let's look at the data correction for each table separately.
Correction of cust_info Table Data
Analysis of cust_info Table Data
Let's load the data from the cust_info.csv file into a database table, for example data_fix.db (the name can be anything), specifying that missing values during import should be replaced with NULL.
After that, let's run a simple analytical query:
SELECT COUNT(*) AS TotalRows, COUNT(DISTINCT cst_id) AS UniqueIds, COUNT(DISTINCT cst_key) AS UniqueKeys, COUNT(DISTINCT cst_id || '_' || cst_key) AS UniqueIdAndKey, COUNT(*) - COUNT(cst_id) AS IdNullCount, COUNT(*) - COUNT(cst_key) AS KeyNullCount, COUNT(*) - COUNT(cst_create_date) AS CreatedDateNullCount FROM cust_info; Results:
| TotalRows | UniqueIds | UniqueKeys | UniqueIdAndKey | IdNullCount | KeyNullCount | CreatedDateNullCount |
|---|---|---|---|---|---|---|
| 18494 | 18484 | 18488 | 18484 | 4 | 0 | 4 |
As you can see from the results, we have four rows with an empty surrogate key, while there are no rows with an empty business key.
These rows can be displayed separately by running the query SELECT * FROM cust_info WHERE cst_id IS NULL:
| cst_id | cst_key | cst_firstname | cst_lastname | cst_marital_status | cst_gndr | cst_create_date |
|---|---|---|---|---|---|---|
| SF566 | ||||||
| PO25 | ||||||
| 13451235 | ||||||
| A01Ass |
It is evident that, apart from business keys, there are no other attributes for these records. All empty values are NULL.
All these rows can be safely deleted from the table, as the fact table uses the surrogate key to link with customers, which is absent in these records. That is, these records will not affect the facts in any way.
Let's modify the query to exclude such records and count duplicates:
SELECT COUNT(*) AS TotalRows, COUNT(DISTINCT cst_id) AS UniqueIds, COUNT(DISTINCT cst_key) AS UniqueKeys, COUNT(DISTINCT cst_id || '_' || cst_key) AS UniqueIdAndKey, COUNT(*) - COUNT(DISTINCT cst_id) AS IdDuplicatesCount, COUNT(*) - COUNT(DISTINCT cst_key) AS KeyDuplicatesCount, COUNT(*) - COUNT(DISTINCT cst_id || '_' || cst_key) AS IdAndKeyDuplicatesCount, COUNT(*) - COUNT(cst_create_date) AS CreatedDateNullCount FROM cust_info WHERE cst_id IS NOT NULL; Result:
| TotalRows | UniqueIds | UniqueKeys | UniqueIdAndKey | IdDuplicatesCount | KeyDuplicatesCount | IdAndKeyDuplicatesCount | CreatedDateNullCount |
|---|---|---|---|---|---|---|---|
| 18490 | 18484 | 18484 | 18484 | 6 | 6 | 6 | 0 |
As you can see, there are six duplicate records based on the combination of surrogate and business keys. At the same time, when counting unique combinations, the duplicate rows themselves are also taken into account. The distribution of duplicates by their count for each surrogate key can be calculated as follows:
SELECT cst_id, COUNT(*) AS dupsCount, MIN(cst_create_date) AS minCreationDate, MAX(cst_create_date) AS maxCreationDate, julianday(MAX(cst_create_date)) - julianday(MIN(cst_create_date)) AS maxDateDiff FROM cust_info WHERE cst_id IS NOT NULL GROUP BY cst_id HAVING COUNT(*) > 1; Result:
| cst_id | dupsCount | minCreationDate | maxCreationDate | maxDateDiff |
|---|---|---|---|---|
| 29433 | 2 | 2026-01-25 | 2026-01-27 | 2 |
| 29449 | 2 | 2026-01-25 | 2026-01-26 | 1 |
| 29466 | 3 | 2026-01-25 | 2026-01-27 | 2 |
| 29473 | 2 | 2026-01-25 | 2026-01-26 | 1 |
| 29483 | 2 | 2026-01-25 | 2026-01-27 | 2 |
As you can see, the maximum number of duplicates for a single surrogate key can be 3. There is only one such group, while the rest contain two records each. The interval between creation dates in these groups is a maximum of two days.
Thus, if we generate records randomly within the year 2025, we can safely use a difference of one day for duplicates. Additionally, we have the ability to use sorting by creation date for ranking records.
Now we have all the necessary information to generate new modification dates for customers.
Creating the Customer Table
Earlier, we decided to remove the cst_marital_status and cst_gndr fields. Instead, we will add three auxiliary fields:
| Field | Description |
|---|---|
| dupsCnt | Number of duplicates for the surrogate key |
| dupNo | Duplicate number, sorted by creation date |
| newDate | New random creation date |
We save the new date in an additional column so we can verify the data without overwriting the original dates.
The full query looks like this:
CREATE TABLE Customer AS WITH bounds AS ( SELECT '2025-01-01' AS A, 365 AS B ) SELECT cst_id, cst_key, cst_firstname, cst_lastname, cst_create_date, COUNT(*) OVER(PARTITION BY cst_id) AS dupCnt, ROW_NUMBER() OVER(PARTITION BY cst_id ORDER BY cst_create_date ASC) AS dupNo, date(bounds.A, '+' || (ABS(RANDOM()) % bounds.B) || ' days') AS newDate FROM cust_info, bounds WHERE cst_id IS NOT NULL; Now, all dates in the newDate column fall within the year 2025. However, for duplicates, they might not be sequential (i.e., not exactly one day apart), which is easy to see by running the query SELECT * FROM Customer WHERE dupCnt > 1;.
Since the number of duplicates does not exceed three, this situation can be fixed using two queries:
UPDATE Customer SET newDate = date(prev.newDate, '+1 days') FROM Customer AS prev WHERE Customer.cst_id = prev.cst_id AND Customer.dupNo = 2 AND prev.dupNo = 1 AND prev.dupCnt > 1; UPDATE Customer SET newDate = date(prev.newDate, '+1 days') FROM Customer AS prev WHERE Customer.cst_id = prev.cst_id AND Customer.dupNo = 3 AND prev.dupNo = 2 AND prev.dupCnt > 2; After executing these queries, you can verify that the dates for duplicates of the same customer are spaced exactly one day apart.
There are two steps left. The first step will transfer the new date from the newDate field to the original cst_create_date field:
UPDATE Customer SET cst_create_date = newDate; The following queries will remove the auxiliary fields that were used during the calculations:
ALTER TABLE Customer DROP COLUMN dupCnt; ALTER TABLE Customer DROP COLUMN dupNo; ALTER TABLE Customer DROP COLUMN newDate; The final verification query requires the sales_details fact table to be loaded into the database as well:
SELECT * FROM sales_details WHERE sls_cust_id NOT IN ( SELECT cst_id FROM Customer ); It returns no records. This means that all customers are used in the fact table, and nothing needs to be deleted from the "Customer" dimension.
Now, we can export the table to a Customer.csv file with column headers, keeping NULL values as empty strings.
This concludes the correction of the "Customer" dimension.
Data Correction for the prd_info Table
Analysis of prd_info Table Data
First, let's check the table using a query similar to the initial statistics query for the cust_info table:
| TotalRows | UniqueIds | UniqueKeys | UniqueIdAndKey | IdNullCount | KeyNullCount | StartDateNullCount |
|---|---|---|---|---|---|---|
| 397 | 397 | 295 | 397 | 0 | 0 | 0 |
As you can see from the results, the number of unique surrogate keys equals the total number of rows in the table. Furthermore, the combination of the surrogate key and the business key is also unique across the entire table. There are no records with empty values for either the surrogate key or the business key. Moreover, there are no NULL values in the interval start date.
Note: all statistical queries are available in the repository within the stat_queries.sql file.
As for the number of possible intervals and whether they are open or closed, this data can be obtained using another statistical query:
WITH group_stats AS ( -- calc each group SELECT prd_key, COUNT(*) AS TotalInGroup, MAX(prd_id) AS FinalPrdId FROM prd_info GROUP BY prd_key ), final_null_stat AS ( -- add HasFinalNulls to check if last prd_end_dt is NULL SELECT group_stats.prd_key, group_stats.TotalInGroup, CASE WHEN prd_info.prd_end_dt IS NULL THEN 1 ELSE 0 END AS HasFinalNulls FROM group_stats INNER JOIN prd_info ON prd_info.prd_id = group_stats.FinalPrdId ) SELECT TotalInGroup, COUNT(*) AS groupsCount, COUNT(*) - SUM(HasFinalNulls) AS TotalNotNullEndDateCount, SUM(HasFinalNulls) AS TotalNullEndDateCount FROM final_null_stat GROUP BY TotalInGroup; The query returns the following data:
| TotalInGroup | groupsCount | TotalNotNullEndDateCount | TotalNullEndDateCount |
|---|---|---|---|
| 1 | 218 | 80 | 138 |
| 2 | 52 | 18 | 34 |
| 3 | 25 | 0 | 25 |
As you can see, the maximum number of records in a group is three. At the same time, all groups with three records have an open interval: the last prd_end_dt date is NULL. Among the groups with two records, there are both open and closed intervals, meaning they have a specific finite end date for prd_end_dt.
Creating the Product Table
To correctly generate intervals for products given these input conditions, we will need three random dates, and they must be sequential so that SCD 2 is filled correctly.
For this, in the processing table, we will introduce not one, but three dates, and we will fill them in a more complex way.
We will add seven auxiliary fields:
| field | description |
|---|---|
| recCnt | number of records in the group for the business key |
| id | sequential number of the record in the group, sorted by surrogate key |
| date1 | new random date 1 |
| date2 | new random date 2, such that date2 > date1 |
| date3 | new random date 3, such that date3 > date2 |
| min_dt | minimum new date among records in the group |
| max_dt | maximum new date among records in the group |
Since we have no duplicates, slightly different names are used for the number of records in the group and the sequential number of the record.
The full query looks like this:
CREATE TABLE Product AS SELECT prd_id , SUBSTR(prd_key, 7) AS prd_key , prd_nm , prd_cost , prd_start_dt , prd_end_dt , COUNT(*) OVER(PARTITION BY prd_key) AS recCnt , ROW_NUMBER() OVER ( PARTITION BY prd_key ORDER BY prd_id ASC) AS id , CAST(NULL AS TEXT) AS date1 , CAST(NULL AS TEXT) AS date2 , CAST(NULL AS TEXT) AS date3 , CAST(NULL AS TEXT) AS min_dt , CAST(NULL AS TEXT) AS max_dt FROM prd_info; We leave all new dates empty for now, we trim the business key to remove categories, and we do not include the product line.
We can immediately delete from the products those records that are not represented in the facts:
DELETE FROM Product WHERE prd_key NOT IN (SELECT sls_prd_key FROM sales_details); This will delete 220 rows, leaving 177 that are linked to facts. This will also delete records with empty prices.
The next step is to generate three dates. Since procedural capabilities are not used, we can generate them for each business key using a recursive CTE with a limit of three rows. The result will be in the form of "sequential number-date", and this needs to be transposed into additional columns. SQLite does not support PIVOT, so we transpose manually using FILTER and aggregate functions:
WITH RECURSIVE -- 1. get unique business keys target_list AS ( SELECT DISTINCT prd_key FROM Product ), -- 2. range for dates generation bounds AS ( SELECT '2025-01-01' AS A, 365 AS B ), -- 3. recursive generator for each key generator(prd_key, idx, date_result) AS ( -- first date SELECT t.prd_key, 1, date(b.A, '+' || (ABS(RANDOM() + t.prd_key) % b.B) || ' days') FROM target_list t, bounds b UNION ALL -- next dates, up to 3 for each key SELECT g.prd_key, g.idx + 1, date(b.A, '+' || (ABS(RANDOM() + g.idx) % b.B) || ' days') FROM generator g, bounds b WHERE g.idx < 3 ), -- 4. remove duplicates for each key unique_dates AS ( SELECT prd_key, date_result FROM generator GROUP BY prd_key, date_result ), -- 5. final output with numbering (1, 2, 3) for each key num_data AS ( SELECT prd_key, ROW_NUMBER() OVER (PARTITION BY prd_key ORDER BY date_result ASC) AS genId, date_result FROM unique_dates ) , final_data AS ( SELECT prd_key, MAX(date_result) FILTER (WHERE genId = 1) AS [date1], MAX(date_result) FILTER (WHERE genId = 2) AS [date2], MAX(date_result) FILTER (WHERE genId = 3) AS [date3] FROM num_data GROUP BY prd_key ) UPDATE Product SET date1 = final_data.date1 , date2 = final_data.date2 , date3 = final_data.date3 FROM final_data WHERE Product.prd_key = final_dataSource:https://dev.to/dyacc11/preparing-clean-data-for-a-dwh-lhcRelated posts
Goravel feature testing for a REST API


