Data Modeling & Data WarehousingData Modeling & Warehousing
Thuộc bộ kiến thức Data Engineer Roadmap.
Tổng quan
Data warehouse là kho lưu trữ tập trung, có cấu trúc, được thiết kế cho các truy vấn phân tích — kiểu câu hỏi quét qua nhiều tháng hoặc nhiều năm dữ liệu lịch sử và tổng hợp theo nhiều chiều khác nhau (“tổng doanh thu theo khu vực và danh mục sản phẩm trong 8 quý gần nhất”). Nó đối lập với operational database (một hệ thống OLTP như hệ thống đứng sau trang thanh toán), vốn được tối ưu cho việc đọc/ghi nhanh, phạm vi hẹp trên trạng thái hiện tại của một ứng dụng duy nhất: “giỏ hàng của khách này bây giờ có gì,” “giao dịch thanh toán này có thành công không.”
Sự đối lập này quan trọng vì hai loại workload kéo thiết kế database theo hai hướng ngược nhau:
| Khía cạnh | Operational database (OLTP) | Data warehouse (OLAP) |
|---|---|---|
| Mục đích | Vận hành nghiệp vụ (một ứng dụng) | Hiểu nghiệp vụ (toàn tổ chức) |
| Kiểu truy vấn | Ngắn, có index, tra cứu điểm và ghi nhỏ | Chạy lâu, quét toàn bảng, tổng hợp nặng |
| Phạm vi dữ liệu | Trạng thái hiện tại | Toàn bộ lịch sử, thường nhiều năm |
| Schema | Chuẩn hóa cao (3NF), tối ưu tránh update anomaly | Phi chuẩn hóa (denormalized), tối ưu tốc độ đọc/tổng hợp |
| Người dùng | Code ứng dụng, một service duy nhất | Analyst, công cụ BI, data scientist, nhiều team |
| Kiểu cập nhật | Thường xuyên, nhỏ, transactional (ACID) | Load hàng loạt (batch), theo giờ/ngày, chủ yếu append |
| Engine ví dụ | PostgreSQL, MySQL, Oracle | Snowflake, BigQuery, Redshift, Databricks SQL |
Warehouse được nạp dữ liệu bởi các pipeline lấy dữ liệu từ nhiều hệ thống operational (và các nguồn khác — xem ./03-data-sources-and-ingestion.md), biến đổi và đưa vào một schema được xây dựng cho phân tích thay vì cho giao dịch của một ứng dụng cụ thể. Note này trình bày cách thiết kế schema đó: dimensional modeling, star schema so với snowflake schema, slowly changing dimensions, data mart, và vì sao các engine warehouse hiện đại lưu dữ liệu theo cột thay vì theo dòng.
Kiến thức nền tảng
Kiến trúc warehouse: Kimball vs. Inmon
Có hai trường phái định hình cách xây dựng data warehouse, và đáng để biết cả hai dù một trường phái đã thắng thế rõ ràng trong thực tế.
Cách tiếp cận của Bill Inmon (top-down / Corporate Information Factory). Xây dựng một enterprise data warehouse chuẩn hóa cao (3NF) duy nhất trước tiên, mô hình hóa toàn bộ dữ liệu tổ chức như một khối tích hợp. Các data mart theo từng phòng ban sau đó được tạo ra từ warehouse trung tâm này. Cách này mang lại tính nhất quán mạnh và một nguồn sự thật duy nhất, nhưng xây dựng chậm và tốn kém — về cơ bản bạn phải mô hình hóa toàn bộ doanh nghiệp trước khi ai đó nhận được giá trị — và schema chuẩn hóa lại không thuận tiện cho công cụ truy vấn của người dùng cuối và phân tích ad-hoc.
Cách tiếp cận của Ralph Kimball (bottom-up / bus architecture). Xây dựng dimensional model dần dần, từng business process một (đơn hàng, giao hàng, ticket hỗ trợ), mỗi cái được biểu diễn dưới dạng một fact table join với các dimension table dùng chung, có thể tái sử dụng — gọi là conformed dimensions. Bus architecture là kỷ luật giúp việc này mở rộng được: mọi fact table tham chiếu đến “customer” hay “date” đều dùng cùng một dimension customer và date (cùng key, cùng thuộc tính, cùng grain), để một báo cáo có thể join qua nhiều fact table — ví dụ so sánh chi phí marketing với doanh thu bán hàng — và cho ra kết quả nhất quán. Bạn tạo ra giá trị nhanh (từng dimensional model một) thay vì phải chờ một mô hình enterprise hoàn chỉnh, đổi lại phải kỷ luật thực sự về conformance giữa các dimension để warehouse không bị phân mảnh thành các silo không nhất quán.
Trong thực tế, dimensional modeling của Kimball là mô hình chiếm ưu thế hiện nay, kể cả trong các cloud warehouse hiện đại. Ngay cả những tổ chức có xu hướng triết lý theo Inmon (xây một lớp “raw” hoặc “integration” chuẩn hóa trước) hầu như luôn expose một lớp dimensional kiểu Kimball ở trên để phục vụ BI, vì star schema là thứ giúp phân tích self-service ad-hoc và các công cụ BI (Looker, Tableau, Power BI) chạy nhanh và dễ hiểu đối với người không phải kỹ sư. Phần còn lại của note này tập trung vào dimensional modeling theo Kimball, vì đó là thứ bạn sẽ xây dựng hàng ngày.
Fact và dimension
Dimensional modeling tổ chức dữ liệu thành hai loại bảng:
- Fact table lưu trữ các sự kiện có thể đo lường được, mang tính định lượng — một giao dịch bán hàng, một lượt click, một lần giao hàng, một ticket hỗ trợ đã đóng. Mỗi dòng là một lần xảy ra của business process ở một grain xác định (quyết định thiết kế quan trọng nhất của fact table: “một dòng cho mỗi line item của đơn hàng,” không phải “một dòng cho mỗi đơn hàng,” cũng không phải “một dòng cho mỗi khách hàng mỗi ngày” — hãy chọn grain nguyên tử (atomic) nhất mà dữ liệu nguồn của bạn hỗ trợ được). Fact table chủ yếu chứa các foreign key trỏ đến dimension, cộng với các measure dạng số có tính cộng dồn (additive) được — có thể SUM — theo càng nhiều chiều càng tốt: quantity, revenue, cost.
- Dimension table lưu trữ ngữ cảnh mô tả, chủ yếu là văn bản, trả lời câu hỏi “ai, cái gì, ở đâu, khi nào, như thế nào” cho một fact: khách hàng, sản phẩm, cửa hàng, ngày tháng. Dimension thường rộng (nhiều cột mô tả), tương đối ít dòng hơn (vài nghìn đến vài triệu dòng, so với hàng tỷ dòng ở fact table), và thay đổi chậm hơn so với fact.
Ví dụ thực tế: fact table bán hàng
Xét một data warehouse bán lẻ. Business process là “một khách hàng mua một sản phẩm tại một cửa hàng vào một ngày.” Grain tự nhiên là một dòng cho mỗi line item trong mỗi giao dịch.
- date_keydim_datedate_key (PK), full_date, day_of_week, month / quarter / year, is_holiday
- customer_keydim_customercustomer_key (PK), customer_id, full_name, email, segment, city / region, valid_from / valid_to, is_current
- product_keydim_productproduct_key (PK), product_id, product_name, category, subcategory, brand, unit_cost
- store_keydim_storestore_key (PK), store_id, store_name, city, region
Ở dạng SQL DDL:
CREATE TABLE dim_date (
date_key INT PRIMARY KEY, -- surrogate key, ví dụ 20260719
full_date DATE NOT NULL,
day_of_week SMALLINT NOT NULL,
day_name VARCHAR(10) NOT NULL,
month SMALLINT NOT NULL,
month_name VARCHAR(10) NOT NULL,
quarter SMALLINT NOT NULL,
year SMALLINT NOT NULL,
is_weekend BOOLEAN NOT NULL,
is_holiday BOOLEAN NOT NULL
);
CREATE TABLE dim_customer (
customer_key BIGINT PRIMARY KEY, -- surrogate key
customer_id VARCHAR(50) NOT NULL, -- natural/business key từ hệ thống nguồn
full_name VARCHAR(200),
email VARCHAR(200),
segment VARCHAR(50),
city VARCHAR(100),
region VARCHAR(100),
valid_from DATE NOT NULL,
valid_to DATE,
is_current BOOLEAN NOT NULL
);
CREATE TABLE dim_product (
product_key BIGINT PRIMARY KEY,
product_id VARCHAR(50) NOT NULL,
product_name VARCHAR(200),
category VARCHAR(100),
subcategory VARCHAR(100),
brand VARCHAR(100),
unit_cost NUMERIC(10,2)
);
CREATE TABLE dim_store (
store_key BIGINT PRIMARY KEY,
store_id VARCHAR(50) NOT NULL,
store_name VARCHAR(200),
city VARCHAR(100),
region VARCHAR(100)
);
CREATE TABLE fact_sales (
sale_key BIGINT PRIMARY KEY, -- surrogate key cho chính dòng fact (tùy chọn)
customer_key BIGINT NOT NULL REFERENCES dim_customer(customer_key),
product_key BIGINT NOT NULL REFERENCES dim_product(product_key),
store_key BIGINT NOT NULL REFERENCES dim_store(store_key),
date_key INT NOT NULL REFERENCES dim_date(date_key),
order_id VARCHAR(50) NOT NULL, -- degenerate dimension: không cần dim table riêng
quantity INT NOT NULL,
unit_price NUMERIC(10,2) NOT NULL,
discount_amount NUMERIC(10,2) DEFAULT 0,
net_revenue NUMERIC(12,2) NOT NULL -- measure có tính additive
);
Chú ý hai điều thường gặp trong các mô hình thực tế. Thứ nhất, order_id nằm trực tiếp trên fact table mà không có dimension riêng — đây là một degenerate dimension: một identifier hữu ích để group hoặc drill-through (“cho tôi xem mọi line item của đơn hàng này”) nhưng không mang thuộc tính mô tả nào đáng để tách thành bảng riêng. Thứ hai, mọi join key đều là surrogate key (customer_key, product_key — số nguyên vô nghĩa do warehouse sinh ra), không phải natural/business key từ hệ thống nguồn (customer_id). Surrogate key tách rời warehouse khỏi các thay đổi key ở hệ thống nguồn và, quan trọng hơn, chính là thứ giúp Slowly Changing Dimensions khả thi, vì cùng một khách hàng có thể có nhiều surrogate key đại diện cho các phiên bản khác nhau của khách hàng đó theo thời gian.
Một truy vấn phân tích tiêu biểu trên schema này:
SELECT
d.year,
d.quarter,
p.category,
r.region,
SUM(f.net_revenue) AS total_revenue,
SUM(f.quantity) AS total_units
FROM fact_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_store r ON f.store_key = r.store_key
WHERE d.year = 2026
GROUP BY d.year, d.quarter, p.category, r.region
ORDER BY total_revenue DESC;
Một fact table, vài lượt join, một GROUP BY — đây chính xác là dạng truy vấn mà dimensional modeling được thiết kế để làm cho nhanh và dễ đọc.
Khái niệm chính
Star schema vs. snowflake schema
Ví dụ ở trên là một star schema: fact table nằm ở giữa, và mỗi dimension là một bảng đơn, phi chuẩn hóa (denormalized), join trực tiếp với fact table — vẽ ra thì trông giống một ngôi sao với fact table là trung tâm. Denormalized nghĩa là dim_product lặp lại text category và subcategory trên mọi dòng sản phẩm thay vì chuẩn hóa chúng thành các bảng dim_category / dim_subcategory riêng.
Snowflake schema lấy cùng mô hình đó và chuẩn hóa dimension sâu hơn: dim_product sẽ tham chiếu đến một bảng dim_category, bảng này có thể tham chiếu đến dim_department, và cứ thế — mỗi dimension “phân nhánh” (snowflake) ra thành các sub-dimension, giảm dư thừa dữ liệu nhưng đổi lại phải join thêm.
-- Phiên bản snowflake của dim_product
CREATE TABLE dim_category (
category_key INT PRIMARY KEY,
category_name VARCHAR(100),
department_key INT REFERENCES dim_department(department_key)
);
CREATE TABLE dim_product_snowflake (
product_key BIGINT PRIMARY KEY,
product_id VARCHAR(50) NOT NULL,
product_name VARCHAR(200),
category_key INT REFERENCES dim_category(category_key), -- đã chuẩn hóa ra
brand_key INT REFERENCES dim_brand(brand_key), -- đã chuẩn hóa ra
unit_cost NUMERIC(10,2)
);
-- Truy vấn giờ cần thêm một bước join để lấy tên category
SELECT c.category_name, SUM(f.net_revenue)
FROM fact_sales f
JOIN dim_product_snowflake p ON f.product_key = p.product_key
JOIN dim_category c ON p.category_key = c.category_key
GROUP BY c.category_name;
| Khía cạnh | Star schema | Snowflake schema |
|---|---|---|
| Cấu trúc dimension | Phi chuẩn hóa (phẳng, rộng) | Chuẩn hóa thành sub-dimension |
| Số lượt join từ fact đến thuộc tính | Một bước | Nhiều bước |
| Storage | Dư thừa nhiều hơn (lặp lại giá trị text) | Ít dư thừa hơn |
| Độ phức tạp truy vấn | SQL đơn giản hơn, ít join hơn | Nhiều join hơn, SQL phức tạp hơn |
| Tốc độ truy vấn (engine row-oriented) | Thường nhanh hơn | Có thể chậm hơn (overhead do join thêm) |
| Tốc độ truy vấn (engine columnar) | Nhanh; chi phí storage của dư thừa được giảm thiểu nhờ compression | Cũng nhanh, nhưng join thêm hiếm khi đáng giá |
| Độ phức tạp ETL/load | Load và cập nhật đơn giản hơn | Nhiều bảng hơn cần duy trì và đồng bộ |
| Phù hợp nhất cho | BI ad-hoc, self-service analytics, hầu hết cloud warehouse hiện đại | Dimension rất lớn, có tính phân cấp thực sự, hiếm thay đổi; hệ thống bị giới hạn storage |
Trong các cloud warehouse columnar hiện đại (Snowflake, BigQuery, Redshift), star schema thường được ưu tiên hơn. Storage rẻ và nén rất tốt với text dimension lặp lại (xem phần dưới), nên chi phí dư thừa của denormalization là nhỏ, trong khi lợi ích về đơn giản khi join và tốc độ truy vấn lại lớn và rõ ràng ngay lập tức — cả với người viết SQL lẫn với công cụ BI tự sinh SQL. Snowflaking vẫn xuất hiện với các dimension thực sự khổng lồ, phân cấp (ví dụ nhà bán lẻ với taxonomy sản phẩm sâu), hoặc trong các hệ thống on-premise row-oriented nơi storage từng là nút thắt cổ chai — nhưng đó là ngoại lệ chứ không còn là lựa chọn mặc định ngày nay.
Slowly Changing Dimensions (SCD)
Các thuộc tính dimension được gọi là “slowly changing” vì, khác với fact, chúng vẫn thay đổi — chỉ là không thường xuyên và không đều đặn. Vấn đề kinh điển: một khách hàng chuyển từ Hà Nội vào TP.HCM. Bạn ghi đè giá trị city, làm mất đi thông tin rằng mọi giao dịch trong quá khứ thực ra được thực hiện khi khách hàng đó còn sống ở Hà Nội? Hay bạn cần giữ lại lịch sử đó để báo cáo lịch sử chính xác (“cho xem doanh thu theo thành phố của khách hàng tại thời điểm mỗi giao dịch”)? Câu trả lời phụ thuộc vào câu hỏi nghiệp vụ, và các thuộc tính khác nhau trên cùng một dimension thậm chí có thể dùng chiến lược khác nhau. Các loại SCD của Kimball là bộ từ vựng chuẩn cho quyết định này.
Type 0 — Giữ nguyên gốc. Thuộc tính không bao giờ thay đổi sau khi được load, theo thiết kế (ví dụ: ngày đăng ký ban đầu hoặc kênh thu hút khách hàng ban đầu). Mọi cập nhật từ nguồn đều bị bỏ qua.
Type 1 — Ghi đè (overwrite). Giá trị mới thay thế giá trị cũ tại chỗ; không giữ lịch sử. Đơn giản nhất để triển khai, phù hợp khi giá trị cũ không có giá trị phân tích (sửa lỗi chính tả trong tên) hoặc nghiệp vụ chỉ thực sự quan tâm đến trạng thái hiện tại.
-- SCD Type 1: ghi đè tại chỗ
UPDATE dim_customer
SET city = 'Ho Chi Minh City', region = 'South'
WHERE customer_id = 'CUST-00123';
Mọi dòng fact từng join với khách hàng này giờ sẽ báo cáo “Ho Chi Minh City,” kể cả các giao dịch thực hiện khi khách hàng còn sống ở Hà Nội. Lịch sử bị mất.
Type 2 — Thêm dòng mới (phổ biến nhất trong thực tế). Thay vì ghi đè, chèn một dòng dimension mới đại diện cho phiên bản mới của khách hàng, và làm hết hiệu lực (expire) dòng cũ. Mỗi phiên bản có surrogate key riêng, cộng với valid_from / valid_to (hoặc effective_date / expiration_date) và cờ is_current. Các dòng fact được load về sau sẽ join với surrogate key mới; các dòng fact đã load trong quá khứ vẫn trỏ đến surrogate key cũ, nên báo cáo lịch sử tự nhiên phản ánh đúng “thành phố của khách hàng tại thời điểm giao dịch.”
-- SCD Type 2: expire dòng cũ, insert dòng mới
BEGIN;
UPDATE dim_customer
SET valid_to = CURRENT_DATE - INTERVAL '1 day',
is_current = FALSE
WHERE customer_id = 'CUST-00123'
AND is_current = TRUE;
INSERT INTO dim_customer
(customer_key, customer_id, full_name, email, segment, city, region, valid_from, valid_to, is_current)
VALUES
(nextval('customer_key_seq'), 'CUST-00123', 'Nguyen Van A', 'a@example.com', 'Retail',
'Ho Chi Minh City', 'South', CURRENT_DATE, NULL, TRUE);
COMMIT;
-- Truy vấn trạng thái "hiện tại" chỉ là một filter
SELECT * FROM dim_customer WHERE is_current = TRUE;
-- Truy vấn "tại một thời điểm trong quá khứ" (gán đúng thuộc tính lịch sử)
SELECT * FROM dim_customer
WHERE customer_id = 'CUST-00123'
AND '2025-03-15' BETWEEN valid_from AND COALESCE(valid_to, '9999-12-31');
Type 2 là “cỗ máy chính” của dimensional modeling chính vì nó cho bạn cả hai góc nhìn miễn phí: join fact table với dim_customer theo surrogate key được lưu tại thời điểm load, bạn tự động có được thuộc tính đúng theo thời điểm (point-in-time-correct); lọc dimension theo is_current = TRUE, bạn có góc nhìn hiện tại cho báo cáo kiểu operational.
Type 3 — Thêm cột mới. Thay vì thêm dòng, thêm một cột như previous_city bên cạnh current_city. Cách này chỉ giữ đúng một giá trị trước đó — hữu ích cho so sánh “hiện tại vs. trước đây” (ví dụ: “khu vực trước đây vs. hiện tại của nhân viên bán hàng”) nhưng không thể ghi nhận nhiều hơn một lần thay đổi, nên hiếm khi được dùng ngoài một số thuộc tính đặc thù.
ALTER TABLE dim_customer ADD COLUMN previous_city VARCHAR(100);
UPDATE dim_customer
SET previous_city = city,
city = 'Ho Chi Minh City'
WHERE customer_id = 'CUST-00123';
| Type | Cơ chế | Lịch sử được giữ | Độ phức tạp | Ứng dụng điển hình |
|---|---|---|---|---|
| Type 0 | Không bao giờ cập nhật | Toàn bộ (đóng băng tại thời điểm load) | Rất đơn giản | Ngày đăng ký gốc, ngày sinh |
| Type 1 | Ghi đè | Không có | Thấp | Sửa lỗi chính tả, thuộc tính không có giá trị lịch sử về phân tích |
| Type 2 | Thêm dòng + valid_from/valid_to/is_current | Toàn bộ | Trung bình | Địa chỉ, segment, khu vực bán hàng — lựa chọn mặc định |
| Type 3 | Thêm cột “giá trị trước đó” | Chỉ một giá trị trước đó | Thấp-trung bình | Hiếm gặp — so sánh giới hạn kiểu “manager trước đây” |
Các chiến lược kết hợp (Type 6 = kết hợp 1+2+3) tồn tại cho các trường hợp nâng cao nhưng nằm ngoài phạm vi hầu hết team cần hàng ngày; hiểu khi nào dùng Type 1 vs. Type 2 đã bao phủ phần lớn các thiết kế thực tế.
Data mart
Data mart là một tập con của warehouse, được giới hạn phạm vi cho một chức năng nghiệp vụ hoặc phòng ban — mart marketing với fact về chiến dịch và attribution, mart tài chính với fact về sổ cái tổng hợp (GL) và ngân sách, mart hỗ trợ khách hàng với fact về ticket. Mart tồn tại vì ba lý do thực tiễn: hiệu năng (một mô hình nhỏ hơn, xây có mục đích, chỉ chứa fact và dimension liên quan sẽ truy vấn nhanh và rẻ hơn so với quét toàn bộ enterprise warehouse), kiểm soát truy cập (dữ liệu tài chính có thể bị giới hạn chỉ trong mart tài chính mà không cần giới hạn toàn bộ warehouse), và tính tự chủ của team (team phân tích marketing có thể tự phát triển schema của mart riêng mà không cần phối hợp với mọi team khác).
Đánh đổi ở đây là rủi ro về tính nhất quán. Nếu mỗi mart được xây dựng độc lập, với dimension “customer” hay “date” tự chế riêng, bạn sẽ gặp phải lỗi kinh điển trong BI khi con số doanh thu của tài chính và của marketing không khớp nhau, và việc đối soát chúng tốn hàng tuần công sức của analyst. Câu trả lời của Kimball chính là bus architecture đã đề cập ở trên: mart nên được xây dựng từ các conformed dimension dùng chung trên toàn warehouse, chứ không phải được phát minh độc lập cho từng mart. Một mart, nếu làm đúng, đơn giản chỉ là một view được chọn lọc, phân quyền (hoặc một tập con được vật lý hóa) trên cùng dim_customer, dim_date, dim_product conformed được dùng ở mọi nơi khác — không phải một vũ trụ song song các dimension.
Columnar storage trong các warehouse hiện đại
Database OLTP truyền thống lưu dữ liệu theo row-oriented: mọi cột của một dòng được lưu liền kề nhau trên đĩa, vì workload OLTP đọc và ghi cả dòng cùng lúc (chèn một đơn hàng, lấy một khách hàng). Các warehouse phân tích (Snowflake, BigQuery, Redshift, Databricks/Delta Lake, ClickHouse) lưu dữ liệu theo column-oriented: mọi giá trị của một cột, trên tất cả các dòng, được lưu liền kề nhau thay vào đó.
Điều này quan trọng vì hai lý do gắn trực tiếp với dạng truy vấn đã nêu ở trên:
- Chỉ đọc những gì cần. Truy vấn
SELECT SUM(net_revenue) FROM fact_sales WHERE date_key BETWEEN ... GROUP BY categorychỉ chạm đến ba hoặc bốn trong số hơn chục cột củafact_sales. Một row store vẫn phải đọc mọi cột của mọi dòng mà nó quét từ đĩa (hoặc ít nhất mọi cột trong khối liền kề của dòng đó) trước khi loại bỏ những cột không cần. Một column store chỉ đọc các cộtnet_revenue,date_key,product_key(và bất cứ thứ gìcategoryjoin phân giải ra) từ đĩa — thường giảm 5-10 lần hoặc hơn số byte cần quét đối với các fact table rộng điển hình, điều này chuyển trực tiếp thành chi phí và độ trễ thấp hơn ở các warehouse tính phí theo byte quét (BigQuery) hoặc theo giây compute (Snowflake, Redshift). - Nén tốt hơn. Một cột chứa một kiểu dữ liệu lặp lại và, thường, một số lượng giá trị khác biệt nhỏ —
regioncó thể có 5 chuỗi khác biệt trên hàng tỷ dòng,is_weekendlà boolean,categorylặp lại vài trăm giá trị liên tục. Lưu các giá trị này cùng nhau cho phép engine áp dụng các kỹ thuật như dictionary encoding, run-length encoding, và delta encoding hiệu quả hơn nhiều so với việc nén một dòng xen kẽ giữa string, integer, timestamp, và decimal. Đây cũng là lý do vì sao chi phí dư thừa storage của các cột text denormalized trong star schema ít gây đau đầu hơn nhiều trong warehouse columnar so với trong row store — chuỗicategorylặp lại được nén xuống gần như không đáng kể.
Columnar storage là một lý do lớn khiến star schema, chứ không phải snowflake schema, là khuyến nghị mặc định trong các warehouse hiện đại: lập luận truyền thống ủng hộ snowflaking (tiết kiệm storage bằng cách chuẩn hóa loại bỏ text lặp lại) không còn quan trọng nhiều khi engine đã nén sự lặp lại đó đi rồi, trong khi các join thêm mà snowflake schema đòi hỏi lại là bất lợi thuần túy.
Sự trỗi dậy của ELT so với ETL
Trước đây, quá trình biến đổi diễn ra trước khi load (ETL): extract từ nguồn, transform trên một lớp compute riêng, sau đó load dữ liệu đã sạch, đã được mô hình hóa vào warehouse. Khi compute của warehouse trở nên rẻ, co giãn, và có thể scale tách biệt với storage (đặc điểm định hình của Snowflake, BigQuery, và các engine tương tự), ngành công nghiệp chuyển sang ELT: extract từ nguồn, load dữ liệu thô/gần thô vào warehouse trước, rồi transform bên trong warehouse bằng chính compute của warehouse (thường dùng công cụ như dbt). Cách này giữ lại lịch sử thô để có thể xử lý lại, đưa logic transform vào SQL được version-control chạy ngay nơi dữ liệu đã tồn tại, và tránh phải duy trì một cluster transform riêng. Sự chuyển đổi này được trình bày sâu hơn trong ./08-etl-elt-and-data-pipelines.md; các mẫu mô hình hóa trong note này (fact, dimension, SCD) chính xác là những gì lớp transform ELT xây dựng bên trong warehouse.
Best Practices
- Chọn grain trước tiên, và chọn grain nguyên tử (atomic) nhất mà bạn có thể hỗ trợ. Mọi quyết định thiết kế khác (dimension nào áp dụng, measure nào có tính additive) đều bắt nguồn từ grain. Roll up sau này từ một grain nguyên tử thì dễ; bạn không bao giờ có thể khôi phục chi tiết từ một fact table được xây ở grain quá thô.
- Dùng surrogate key, không dùng natural key, cho mọi join dimension. Surrogate key là thứ giúp SCD Type 2 khả thi và cách ly warehouse khỏi việc tái sử dụng hoặc thay đổi định dạng key ở hệ thống nguồn.
- Conform dimension của bạn trên toàn bộ fact table. Một
dim_datehaydim_customerđược dùng bởi một fact table nên là chính xác cùng một bảng (cùng key, cùng grain, cùng thuộc tính) được dùng bởi mọi fact table khác cần nó. Đây là thứ giúp phân tích xuyên process (và data mart an toàn) trở nên khả thi. - Mặc định dùng Type 2 cho các thuộc tính quan trọng về mặt lịch sử; chỉ dùng Type 1 khi bạn chắc chắn lịch sử không có giá trị phân tích. Làm sai điều này âm thầm làm hỏng báo cáo lịch sử mà không phát sinh lỗi nào — truy vấn vẫn chạy, chỉ là nó trả lời sai câu hỏi.
- Làm cho measure có tính additive bất cứ khi nào có thể. Lưu
net_revenuetheo từng line item (additive: sum đúng theo sản phẩm, cửa hàng, và thời gian) thay vì lưu mộtaverage_order_valueđã tổng hợp sẵn (non-additive: không thể sum hoặc tổng hợp lại đúng). - Ưu tiên star hơn snowflake trong các warehouse columnar hiện đại, và chỉ snowflake một dimension khi nó vừa rất lớn vừa thực sự có tính phân cấp đủ để việc tiết kiệm join vượt trội hơn chi phí storage denormalized (mà thường cũng rẻ sau khi nén).
- Mô hình hóa dimension trước khi fact được các pipeline xây dựng. Vì dimension thay đổi chậm và fact tham chiếu đến chúng, hãy làm cho
dim_date,dim_customer, và các conformed dimension tương tự ổn định sớm; gắn thêm logic SCD vào một dimension mà fact table đã tham chiếu sẵn gây xáo trộn nhiều hơn nhiều so với thiết kế nó ngay từ đầu. - Xây mart từ conformed dimension, không bao giờ xây từ đầu. Một mart tự phát minh lại “customer” độc lập chính là cách tổ chức rơi vào tình trạng con số không thể đối soát được giữa các phòng ban.
- Ghi lại grain và cam kết conformance cho mọi fact và dimension table — trong metadata catalog của warehouse, trong mô tả model dbt, hoặc tương đương — để các kỹ sư tương lai không phải reverse-engineer từ DDL.
Tài liệu tham khảo
- Kimball Group — Dimensional Modeling Techniques
- Kimball, Ralph & Ross, Margy — The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling (3rd Edition), Wiley
- Kimball Group — Slowly Changing Dimension Techniques
- Kimball Group — Kimball’s Enterprise Data Warehouse Bus Architecture
- Snowflake Documentation — Table Structures and Data Modeling
- Google BigQuery — Data Warehousing Design Patterns
- dbt Labs — Data Modeling Techniques for More Modularity
Bài viết liên quan: ./04-relational-databases.md · ./07-data-lakes-and-modern-architectures.md · ./08-etl-elt-and-data-pipelines.md · ./13-cloud-data-platforms.md
Part of the Data Engineer Roadmap knowledge base.
Overview
A data warehouse is a centralized, structured store designed for analytical queries — the kind of question that scans months or years of history and aggregates it across many dimensions (“total revenue by region and product category for the last eight quarters”). It is the opposite of an operational database (an OLTP system like the one behind a checkout page), which is optimized for fast, narrow reads and writes on the current state of a single application: “what is this customer’s cart right now,” “did this payment succeed.”
The contrast matters because the two workloads pull database design in opposite directions:
| Dimension | Operational database (OLTP) | Data warehouse (OLAP) |
|---|---|---|
| Purpose | Run the business (one application) | Understand the business (across the org) |
| Query pattern | Short, indexed, point lookups and small writes | Long-running, full-table scans, heavy aggregation |
| Data scope | Current state | Full history, often years |
| Schema | Highly normalized (3NF), optimized to avoid update anomalies | Denormalized, optimized for read/aggregate speed |
| Users | Application code, a single service | Analysts, BI tools, data scientists, many teams |
| Update pattern | Frequent, small, transactional (ACID) | Bulk, batch loads (hourly/daily), append-heavy |
| Example engines | PostgreSQL, MySQL, Oracle | Snowflake, BigQuery, Redshift, Databricks SQL |
A warehouse is fed by pipelines that pull data out of many operational systems (and other sources — see ./03-data-sources-and-ingestion.md), reshape it, and land it in a schema built for analysis rather than for any one application’s transactions. This note covers how that schema is designed: dimensional modeling, star vs. snowflake schemas, slowly changing dimensions, data marts, and why modern warehouse engines store data column-by-column instead of row-by-row.
Fundamentals
Warehouse architecture: Kimball vs. Inmon
Two schools of thought shaped how data warehouses are built, and it’s worth knowing both even though one clearly won in practice.
Bill Inmon’s approach (top-down / Corporate Information Factory). Build a single, highly normalized (3NF) enterprise data warehouse first, modeling the entire organization’s data as one integrated whole. Department-specific data marts are then derived from this central warehouse. This gives strong consistency and a single source of truth, but it is slow and expensive to build — you effectively have to model the whole enterprise before anyone gets value — and normalized schemas are awkward for end-user query tools and ad-hoc analysis.
Ralph Kimball’s approach (bottom-up / bus architecture). Build dimensional models incrementally, one business process at a time (orders, shipments, support tickets), each exposed as a fact table joined to shared, reusable dimension tables — the “conformed dimensions.” The bus architecture is the discipline that makes this scale: every fact table that references “customer” or “date” uses the same customer and date dimension (same keys, same attributes, same grain), so that a report can join across fact tables — say, comparing marketing spend to sales revenue — and get consistent answers. You deliver value fast (one dimensional model at a time) instead of waiting for a full enterprise model, at the cost of needing real discipline around dimension conformance so the warehouse doesn’t fragment into inconsistent silos.
In practice, Kimball’s dimensional modeling is the dominant pattern used today, including inside modern cloud warehouses. Even organizations that philosophically lean Inmon (build a normalized “raw” or “integration” layer first) almost always expose a Kimball-style dimensional layer on top for BI consumption, because star schemas are what makes ad-hoc self-service analytics and BI tools (Looker, Tableau, Power BI) fast and understandable to non-engineers. The rest of this note focuses on Kimball’s dimensional modeling, since that’s what you will build day to day.
Facts and dimensions
Dimensional modeling organizes data into two kinds of tables:
- A fact table stores measurable, quantitative events — a sale, a click, a shipment, a support ticket closed. Each row is one occurrence of the business process at a defined grain (the fact table’s most important design decision: “one row per order line item,” not “one row per order,” not “one row per customer per day” — pick the most atomic grain your source data supports). Fact tables are mostly foreign keys pointing at dimensions, plus numeric measures that are additive (can be summed) across as many dimensions as possible: quantity, revenue, cost.
- A dimension table stores descriptive, mostly textual context that answers “who, what, where, when, how” for a fact: the customer, the product, the store, the calendar date. Dimensions are wide (many descriptive columns), relatively short-and-fat (thousands to low millions of rows, versus billions in a fact table), and change slowly compared to facts.
Worked example: a sales fact table
Consider a retail sales warehouse. The business process is “a customer buys a product at a store on a date.” The natural grain is one row per line item per transaction.
- date_keydim_datedate_key (PK), full_date, day_of_week, month / quarter / year, is_holiday
- customer_keydim_customercustomer_key (PK), customer_id, full_name, email, segment, city / region, valid_from / valid_to, is_current
- product_keydim_productproduct_key (PK), product_id, product_name, category, subcategory, brand, unit_cost
- store_keydim_storestore_key (PK), store_id, store_name, city, region
As SQL DDL:
CREATE TABLE dim_date (
date_key INT PRIMARY KEY, -- surrogate key, e.g. 20260719
full_date DATE NOT NULL,
day_of_week SMALLINT NOT NULL,
day_name VARCHAR(10) NOT NULL,
month SMALLINT NOT NULL,
month_name VARCHAR(10) NOT NULL,
quarter SMALLINT NOT NULL,
year SMALLINT NOT NULL,
is_weekend BOOLEAN NOT NULL,
is_holiday BOOLEAN NOT NULL
);
CREATE TABLE dim_customer (
customer_key BIGINT PRIMARY KEY, -- surrogate key
customer_id VARCHAR(50) NOT NULL, -- natural/business key from source system
full_name VARCHAR(200),
email VARCHAR(200),
segment VARCHAR(50),
city VARCHAR(100),
region VARCHAR(100),
valid_from DATE NOT NULL,
valid_to DATE,
is_current BOOLEAN NOT NULL
);
CREATE TABLE dim_product (
product_key BIGINT PRIMARY KEY,
product_id VARCHAR(50) NOT NULL,
product_name VARCHAR(200),
category VARCHAR(100),
subcategory VARCHAR(100),
brand VARCHAR(100),
unit_cost NUMERIC(10,2)
);
CREATE TABLE dim_store (
store_key BIGINT PRIMARY KEY,
store_id VARCHAR(50) NOT NULL,
store_name VARCHAR(200),
city VARCHAR(100),
region VARCHAR(100)
);
CREATE TABLE fact_sales (
sale_key BIGINT PRIMARY KEY, -- surrogate key for the fact row itself (optional)
customer_key BIGINT NOT NULL REFERENCES dim_customer(customer_key),
product_key BIGINT NOT NULL REFERENCES dim_product(product_key),
store_key BIGINT NOT NULL REFERENCES dim_store(store_key),
date_key INT NOT NULL REFERENCES dim_date(date_key),
order_id VARCHAR(50) NOT NULL, -- degenerate dimension: no dim table needed
quantity INT NOT NULL,
unit_price NUMERIC(10,2) NOT NULL,
discount_amount NUMERIC(10,2) DEFAULT 0,
net_revenue NUMERIC(12,2) NOT NULL -- additive measure
);
Notice two things that come up constantly in real models. First, order_id sits directly on the fact table with no dimension of its own — this is a degenerate dimension: an identifier that’s useful for grouping or drill-through (“show me every line item on this order”) but carries no descriptive attributes worth a separate table. Second, every join key is a surrogate key (customer_key, product_key — meaningless integers generated by the warehouse), not the natural/business key from the source system (customer_id). Surrogate keys decouple the warehouse from source-system key changes and, critically, are what make Slowly Changing Dimensions possible, since the same customer can have multiple surrogate keys representing different versions of that customer over time.
A typical analytical query against this schema:
SELECT
d.year,
d.quarter,
p.category,
r.region,
SUM(f.net_revenue) AS total_revenue,
SUM(f.quantity) AS total_units
FROM fact_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
JOIN dim_store r ON f.store_key = r.store_key
WHERE d.year = 2026
GROUP BY d.year, d.quarter, p.category, r.region
ORDER BY total_revenue DESC;
One fact table, a handful of joins, one GROUP BY — this is exactly the query shape dimensional modeling is built to make fast and readable.
Key Concepts
Star schema vs. snowflake schema
The example above is a star schema: the fact table sits in the middle, and each dimension is a single, denormalized table directly joined to it — draw it out and it looks like a star with the fact table as the hub. Denormalized means dim_product repeats category and subcategory text on every product row rather than normalizing them into separate dim_category / dim_subcategory tables.
A snowflake schema takes the same model and normalizes the dimensions further: dim_product would reference a dim_category table, which might reference a dim_department table, and so on — each dimension “snowflakes” out into sub-dimensions, reducing redundancy at the cost of extra joins.
-- Snowflaked version of dim_product
CREATE TABLE dim_category (
category_key INT PRIMARY KEY,
category_name VARCHAR(100),
department_key INT REFERENCES dim_department(department_key)
);
CREATE TABLE dim_product_snowflake (
product_key BIGINT PRIMARY KEY,
product_id VARCHAR(50) NOT NULL,
product_name VARCHAR(200),
category_key INT REFERENCES dim_category(category_key), -- normalized out
brand_key INT REFERENCES dim_brand(brand_key), -- normalized out
unit_cost NUMERIC(10,2)
);
-- Query now needs an extra hop to get category name
SELECT c.category_name, SUM(f.net_revenue)
FROM fact_sales f
JOIN dim_product_snowflake p ON f.product_key = p.product_key
JOIN dim_category c ON p.category_key = c.category_key
GROUP BY c.category_name;
| Aspect | Star schema | Snowflake schema |
|---|---|---|
| Dimension structure | Denormalized (flat, wide) | Normalized into sub-dimensions |
| Joins from fact to attribute | One hop | Multiple hops |
| Storage | More redundancy (repeated text values) | Less redundancy |
| Query complexity | Simpler SQL, fewer joins | More joins, more complex SQL |
| Query speed (row-oriented engines) | Generally faster | Can be slower (extra join overhead) |
| Query speed (columnar engines) | Fast; storage overhead of redundancy is minimized by compression | Also fast, but the extra joins rarely pay off |
| ETL/load complexity | Simpler to load and update | More tables to maintain and keep in sync |
| Best for | Ad-hoc BI, self-service analytics, most modern cloud warehouses | Very large dimensions with genuinely hierarchical, rarely-changing attributes; storage-constrained systems |
In modern columnar cloud warehouses (Snowflake, BigQuery, Redshift), the star schema is generally preferred. Storage is cheap and compresses very well for repeated dimension text (see below), so the redundancy cost of denormalization is small, while the join-simplicity and query-speed benefits are large and immediate — both for humans writing SQL and for BI tools generating SQL automatically. Snowflaking still shows up for genuinely huge, hierarchical dimensions (e.g., a retailer with a deep product taxonomy) or in on-premise row-oriented systems where storage was historically the bottleneck, but it is the exception rather than the default today.
Slowly Changing Dimensions (SCD)
Dimension attributes are called “slowly changing” because, unlike facts, they do change — just infrequently and irregularly. The canonical problem: a customer moves from Hanoi to Ho Chi Minh City. Do you overwrite their city value, losing the fact that every past sale was actually made while they lived in Hanoi? Or do you need to preserve that history for accurate historical reporting (“show revenue by customer city at the time of each sale”)? The answer depends on the business question, and different attributes on the same dimension can even use different strategies. Kimball’s SCD types are the standard vocabulary for this decision.
Type 0 — Retain original. The attribute never changes once loaded, by design (e.g., a customer’s original signup date or original acquisition channel). Any update from the source is ignored.
Type 1 — Overwrite. The new value replaces the old value in place; no history is kept. Simplest to implement, appropriate when the old value has no analytical value (correcting a typo in a name) or the business genuinely only cares about the current state.
-- SCD Type 1: overwrite in place
UPDATE dim_customer
SET city = 'Ho Chi Minh City', region = 'South'
WHERE customer_id = 'CUST-00123';
Every fact row ever joined to this customer will now report “Ho Chi Minh City,” even sales made while the customer lived in Hanoi. History is lost.
Type 2 — Add a new row (the most common in practice). Instead of overwriting, insert a new dimension row representing the new version of the customer, and expire the old row. Each version gets its own surrogate key, plus valid_from / valid_to (or effective_date / expiration_date) and an is_current flag. Fact rows loaded going forward join to the new surrogate key; fact rows loaded in the past keep pointing at the old surrogate key, so historical reports naturally reflect “the customer’s city at the time of the sale.”
-- SCD Type 2: expire the old row, insert a new one
BEGIN;
UPDATE dim_customer
SET valid_to = CURRENT_DATE - INTERVAL '1 day',
is_current = FALSE
WHERE customer_id = 'CUST-00123'
AND is_current = TRUE;
INSERT INTO dim_customer
(customer_key, customer_id, full_name, email, segment, city, region, valid_from, valid_to, is_current)
VALUES
(nextval('customer_key_seq'), 'CUST-00123', 'Nguyen Van A', 'a@example.com', 'Retail',
'Ho Chi Minh City', 'South', CURRENT_DATE, NULL, TRUE);
COMMIT;
-- Querying "current" state is just a filter
SELECT * FROM dim_customer WHERE is_current = TRUE;
-- Querying "as of a point in time" (correct historical attribution)
SELECT * FROM dim_customer
WHERE customer_id = 'CUST-00123'
AND '2025-03-15' BETWEEN valid_from AND COALESCE(valid_to, '9999-12-31');
Type 2 is the workhorse of dimensional modeling precisely because it gives you both views for free: join a fact table to dim_customer on the surrogate key stored at load time, and you automatically get point-in-time-correct attributes; filter dimension by is_current = TRUE, and you get today’s view for operational-style reporting.
Type 3 — Add a new column. Instead of a new row, add a column like previous_city alongside current_city. This keeps exactly one prior value — useful for “current vs. previous” comparisons (e.g., “sales rep’s previous vs. current territory”) but cannot capture more than one change, so it’s rarely used beyond a handful of niche attributes.
ALTER TABLE dim_customer ADD COLUMN previous_city VARCHAR(100);
UPDATE dim_customer
SET previous_city = city,
city = 'Ho Chi Minh City'
WHERE customer_id = 'CUST-00123';
| Type | Mechanism | History preserved | Complexity | Typical use |
|---|---|---|---|---|
| Type 0 | Never update | Full (frozen at load) | Trivial | Original signup date, birth date |
| Type 1 | Overwrite | None | Low | Typo fixes, attributes with no analytical history value |
| Type 2 | New row + valid_from/valid_to/is_current | Full | Medium | Address, segment, sales territory — the default choice |
| Type 3 | New “previous value” column | One prior value only | Low-medium | Rare — limited comparisons like “previous manager” |
Mixed strategies (Type 6 = combining 1+2+3) exist for advanced cases but are outside what most teams need day to day; understanding when Type 1 vs. Type 2 applies covers the overwhelming majority of real designs.
Data marts
A data mart is a subset of the warehouse scoped to one business function or department — a marketing mart with campaign and attribution facts, a finance mart with GL and budget facts, a support mart with ticket facts. Marts exist for three practical reasons: performance (a smaller, purpose-built model with only the relevant facts and dimensions is faster and cheaper to query than scanning the entire enterprise warehouse), access control (finance data can be restricted to the finance mart without restricting the whole warehouse), and team autonomy (a marketing analytics team can iterate on its own mart’s schema without coordinating with every other team).
The tradeoff is consistency risk. If each mart is built independently, with its own bespoke “customer” or “date” dimension, you get the classic BI failure mode where finance’s revenue number and marketing’s revenue number don’t match, and reconciling them consumes weeks of analyst time. Kimball’s answer is the bus architecture covered earlier: marts should be built from conformed dimensions shared across the whole warehouse, not invented independently per mart. A mart, done right, is simply a curated, permissioned view (or a physically materialized subset) over the same conformed dim_customer, dim_date, dim_product used everywhere else — not a parallel universe of dimensions.
Columnar storage in modern warehouses
Traditional OLTP databases store data row-oriented: all columns of one row are stored contiguously on disk, because OLTP workloads read and write whole rows at a time (insert one order, fetch one customer). Analytical warehouses (Snowflake, BigQuery, Redshift, Databricks/Delta Lake, ClickHouse) store data column-oriented: all values of one column, across every row, are stored contiguously instead.
This matters for two reasons that map directly onto the query shape shown earlier:
- Reading only what you need. The query
SELECT SUM(net_revenue) FROM fact_sales WHERE date_key BETWEEN ... GROUP BY categoryonly touches three or four offact_sales’s dozen-plus columns. A row store must still read every column of every row it scans off disk (or at least every column in the row’s contiguous block) before discarding the ones it doesn’t need. A column store reads only thenet_revenue,date_key,product_key(and whatevercategoryjoin resolves to) columns from disk — often a 5-10x or more reduction in bytes scanned for typical wide fact tables, which translates directly into lower cost and latency in warehouses that bill per byte scanned (BigQuery) or per compute-second (Snowflake, Redshift). - Better compression. A column holds one repeated data type and, often, a small number of distinct values —
regionmight have 5 distinct strings across a billion rows,is_weekendis boolean,categoryrepeats a few hundred values constantly. Storing these values together lets the engine apply techniques like dictionary encoding, run-length encoding, and delta encoding far more effectively than compressing a row that interleaves a string, an integer, a timestamp, and a decimal. This is also why the storage-redundancy cost of a star schema’s denormalized text columns is much less painful in a columnar warehouse than it would be in a row store — the repeatedcategorystring compresses down to almost nothing.
Columnar storage is a major reason star schemas rather than snowflake schemas are the default recommendation in modern warehouses: the traditional argument for snowflaking (save storage by normalizing out repeated text) matters much less when the engine already compresses that repetition away, while the extra joins a snowflake schema requires are pure downside.
ELT over ETL
Historically, transformation happened before loading (ETL): extract from sources, transform on a separate compute layer, then load already-clean, already-modeled data into the warehouse. As warehouse compute became cheap, elastic, and separately scalable from storage (the defining trait of Snowflake, BigQuery, and similar engines), the industry shifted toward ELT: extract from sources, load raw/near-raw data into the warehouse first, then transform it inside the warehouse using the warehouse’s own compute (commonly with a tool like dbt). This keeps raw history available for reprocessing, pushes transformation logic into version-controlled SQL that runs where the data already lives, and avoids maintaining a separate transformation cluster. This shift is covered in depth in ./08-etl-elt-and-data-pipelines.md; the modeling patterns in this note (facts, dimensions, SCDs) are exactly what ELT transformation layers build inside the warehouse.
Best Practices
- Pick the grain first, and pick the most atomic grain you can support. Every other design decision (which dimensions apply, which measures are additive) follows from the grain. Rolling up later from an atomic grain is easy; you can never recover detail from a fact table built at too coarse a grain.
- Use surrogate keys, not natural keys, for every dimension join. Surrogate keys are what make SCD Type 2 possible and insulate the warehouse from source-system key reuse or format changes.
- Conform your dimensions across fact tables. A
dim_dateordim_customerused by one fact table should be the exact same table (same keys, same grain, same attributes) used by every other fact table that needs it. This is what makes cross-process analysis (and safe data marts) possible. - Default to Type 2 for attributes that matter historically; use Type 1 only when you’re sure history has no analytical value. Getting this wrong quietly corrupts historical reports without throwing any errors — the query still runs, it just answers the wrong question.
- Make measures additive wherever possible. Store
net_revenueper line item (additive: sums correctly across product, store, and time) rather than storing a pre-aggregatedaverage_order_value(non-additive: cannot be correctly summed or re-aggregated). - Prefer star over snowflake in modern columnar warehouses, and only snowflake a dimension when it is both very large and genuinely hierarchical enough that the join savings outweigh denormalized storage (which is usually cheap after compression anyway).
- Model dimensions before facts get built by pipelines. Because dimensions change slowly and facts reference them, get
dim_date,dim_customer, and similar conformed dimensions stable early; retrofitting SCD logic onto a dimension that fact tables already reference is much more disruptive than designing it in from the start. - Build marts from conformed dimensions, never from scratch. A mart that reinvents “customer” independently is how organizations end up with irreconcilable numbers across departments.
- Document the grain and the conformance contract for every fact and dimension table — in your warehouse’s metadata catalog, in dbt model descriptions, or equivalent — so future engineers don’t have to reverse-engineer it from the DDL.
References
- Kimball Group — Dimensional Modeling Techniques
- Kimball, Ralph & Ross, Margy — The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling (3rd Edition), Wiley
- Kimball Group — Slowly Changing Dimension Techniques
- Kimball Group — Kimball’s Enterprise Data Warehouse Bus Architecture
- Snowflake Documentation — Table Structures and Data Modeling
- Google BigQuery — Data Warehousing Design Patterns
- dbt Labs — Data Modeling Techniques for More Modularity
Related notes: ./04-relational-databases.md · ./07-data-lakes-and-modern-architectures.md · ./08-etl-elt-and-data-pipelines.md · ./13-cloud-data-platforms.md