Data Warehousing, from OLTP to the Modern Cloud Stack
1 September 2026 · 16 min read
A study guide built in three days with Claude, NotebookLM, ChatGPT and Perplexity: OLTP vs OLAP, star and snowflake schemas, fact and dimension types, grain, surrogate keys, SCDs, data marts, and why ELT won.
- Data Warehousing
- ETL
- OLAP
- SQL
- Data Engineering
The full 37-page study guide this post is drawn from is here as a PDF.
While working through more advanced data analytics, I kept running into the same wall: everything downstream (dashboards, KPIs, "month-over-month revenue by region") quietly assumes a data warehouse sits underneath it. So I spent three days building a proper study guide on data warehousing, from the reason warehouses exist up to how modern cloud platforms run them.
I didn't use a single resource. I used several AI tools, each for the job it's best at:
- Claude for clear explanations and getting the concepts straight.
- Gemini + NotebookLM for organising notes and turning them into reports, flashcards, quizzes, mind maps and infographics. This combination is underrated.
- ChatGPT for fact-checking, quick definitions and filling gaps.
- Perplexity for going deep on a specific technical topic with sources.
The main thing I took away: AI is far more useful as a collaborative study system than as a search engine. This post is the condensed version of what came out of it.
The problem a warehouse solves
A MySQL database behind an application is an OLTP system (Online Transaction Processing). It is built for many small, fast operations: insert a row, update a balance, fetch a customer.
Now an analyst asks: "What was the month-over-month revenue trend across all branches for the last three years?" Running that on the live transactional database is a disaster. It scans enormous tables, competes with the application for resources, and runs slowly because the schema was never designed for it.
A data warehouse exists to separate those two workloads.
| OLTP | OLAP | |
|---|---|---|
| Purpose | Run the business | Analyse the business |
| Operations | INSERT, UPDATE, DELETE | SELECT (heavy reads) |
| Query style | Simple, row-level | Aggregates across millions of rows |
| Schema | Normalised (3NF) | Denormalised (star / snowflake) |
| Users | Applications, backends | Analysts, BI tools like Power BI |
| Freshness | Real-time | Delayed (batch loaded) |
| Example | A bank recording a transaction | An analyst checking default rates by region |
A warehouse is a separate database built for analytics. Data is extracted from operational systems, transformed (cleaned and reshaped), and loaded into it. That's the ETL pipeline, and it's what feeds the warehouse.
Normalisation and why warehouses break the rules
Normalisation organises a database to remove redundancy: every fact lives in exactly one place, and tables link to each other by keys.
| Normal form | The rule, simply |
|---|---|
| 1NF | Every cell holds one atomic value. No lists in a cell. |
| 2NF | 1NF, plus every non-key column depends on the whole composite key. |
| 3NF | 2NF, plus no non-key column depends on another non-key column. |
| BCNF | 3NF, plus every determinant is a candidate key. |
| 4NF | BCNF, plus independent multi-valued facts are split apart. |
| 5NF | 4NF, plus the table can't be split further without losing information. |
The classic summary of 3NF is that every column must depend on the key, the whole key, and nothing but the key.
That is exactly right for an OLTP system, where writes must be fast and correct. A warehouse wants the opposite trade: it denormalises on purpose, accepting duplicated data so that reads need fewer joins.
| Normalisation | Denormalisation | |
|---|---|---|
| Goal | Integrity, no redundancy | Read speed |
| Tables | Many small ones | Fewer, wider ones |
| Reads | Slower (many joins) | Fast (data already together) |
| Writes | Fast and safe (one place to update) | Slower and riskier |
| Best for | OLTP (banking apps) | OLAP (reporting, dashboards) |
Star and snowflake schemas
Star schema
One central fact table surrounded by dimension tables, which looks like a star.
flowchart TB
F["fact_transactions<br/>(keys + measures)"]
F --- D1["dim_date"]
F --- D2["dim_branch"]
F --- D3["dim_customer"]
F --- D4["dim_account"]
- The fact table records measurable events (transactions, claims, sales): foreign keys plus numeric measures. It's narrow in columns and very long in rows.
- Dimension tables hold the context: who, what, when, where. They're wide in columns, relatively short in rows, and denormalised.
An analogy: the fact table is the receipt; the dimensions are the product catalogue, store directory and customer profile the receipt refers to.
Snowflake schema
The same idea, but dimensions are normalised further. dim_product points to
dim_subcategory, which points to dim_category. It saves storage and keeps
integrity clean, but every question needs more joins.
| Star | Snowflake | |
|---|---|---|
| Normalisation | Denormalised, flat dimensions | Fully normalised |
| Query speed | Fast, few joins | Slower, multi-level joins |
| Storage | Higher | Lower |
| Structure | Simple | Branches like a tree |
Which is better? "Star is always better" is the wrong answer. Star is preferred in most cases because query speed and simplicity outweigh storage savings. Snowflake makes sense when dimension tables are very large, or when dimension data changes often and you'd otherwise be updating millions of duplicated rows.
Facts, in more detail
Facts are classified by whether they can be summed:
| Type | Sum across region / product? | Sum across time? | Typical functions | Examples |
|---|---|---|---|---|
| Additive | Yes | Yes | SUM, AVG, COUNT |
Revenue, claim amount, units sold |
| Semi-additive | Yes | No, use the closing snapshot | LAST_VALUE, AVG, MAX |
Account balance, inventory, headcount |
| Non-additive | No | No | AVG, MIN, MAX, COUNT |
Ratios, percentages, unit price |
A balance of 1,000 on Monday, Tuesday and Wednesday is not 3,000 for the week, which is why balances are semi-additive. Two products with 10% and 20% margins don't make a 30% margin, which is why ratios are non-additive.
A factless fact table has no measures at all, only keys. A row existing is
the fact, and you analyse it with COUNT(). It comes in two flavours:
- Event tables, such as employee attendance (
date_key,employee_key,facility_key). Counting rows answers "how many people came to the Chicago office in Q3?" - Coverage tables, such as which products were on which promotion in which store. Comparing them against actual sales shows the gaps: promoted products that sold nothing.
Dimensions, in more detail
A dimension is the noun you slice by (Product, Customer, Store, Date). An
attribute is a column inside it (category, brand, city): the thing you
click in a dashboard filter.
| Type | What it is | Example |
|---|---|---|
| Conformed | One dimension shared across many fact tables | A single dim_date used by sales, inventory and support |
| Junk | Bundles low-cardinality flags into one table | is_approved, is_emergency, claim_channel, priority_level |
| Degenerate | Lives directly in the fact table, no table of its own | Invoice number, claim number |
| Role-playing | One table joined several times under different roles | dim_date as order date, ship date and delivery date |
| Slowly changing | Tracks changes to attributes over time | A customer moving city |
Conformed dimensions matter most: they're why marketing's "Q3" matches finance's "Q3".
Grain: decide it first
The grain is the answer to "what does one row in the fact table represent?", written as one sentence before any column is added.
| Grain | One row is | Used for |
|---|---|---|
| Transactional | One event | Point-of-sale lines, clicks, calls |
| Periodic snapshot | A summary over a fixed period | Daily inventory, monthly balances |
| Accumulating snapshot | A whole process lifecycle | One insurance claim from filing to payout, with a date key per milestone |
Two rules follow from this:
- Every dimension and measure must match the grain. A monthly-store-sales
fact can't carry
customer_name. Mixed grain doesn't error; it silently double-counts. - You can roll up, but you can't drill down. Atomic data can always be aggregated into weeks or months. Pre-aggregated monthly data can never answer "what was our busiest hour last Tuesday?" Prefer the atomic grain.
Surrogate keys vs natural keys
A natural key comes from the source system and means something
('PAT-2023-004', 'CLM-APL-00892'). A surrogate key is a meaningless
integer the warehouse generates (patient_sk = 1, 2, 3).
Natural keys look good enough until a warehouse uses them:
- Sources change their keys. A hospital migrates systems and
PAT-2023-004becomesMRN-00441, splitting one patient's history into two identities. - Sources collide. Apollo, Fortis and Max can all have a patient
1001. Load them with the natural key as primary key and two patients vanish. - String joins are slow. Joining 500 million fact rows on a
VARCHARis noticeably slower than joining on anINT. - Keys carry business logic. If a claim-number format changes or resets, everything built on it breaks.
- SCD Type 2 makes them non-unique. It needs two rows for the same patient, so the natural key can't be the primary key any more.
| patient_sk | patient_id | name | city | valid_from | valid_to |
|---|---|---|---|---|---|
| 1 | PAT-001 | Rahul | Delhi | 2020-01-01 | 2023-06-30 |
| 2 | PAT-001 | Rahul | Mumbai | 2023-07-01 | NULL |
Old claims point at patient_sk = 1 (Delhi), new ones at 2 (Mumbai), and
history stays intact.
The rule: use both. The surrogate key is the primary key and every join. The
natural key stays as an attribute for tracing a row back to its source.
dim_date is the one standard exception: its key is usually YYYYMMDD as an
integer (20260524), so it's readable and sorts chronologically.
Slowly changing dimensions
| Type | What happens | History kept? | Good for |
|---|---|---|---|
| 0 | Value is locked | None | Original hire date |
| 1 | Overwrite | None | Fixing typos |
| 2 | Insert a new row with valid-from / valid-to | Full | Customer address, plan changes |
| 3 | Add a "previous value" column | One step back | Comparing current vs last |
| 4 | Move history into a separate table | Full, outside the main table | Attributes that change rapidly |
Type 1 has a trap: if a customer moves from Chicago to Miami and you overwrite, all their past sales suddenly look as if they happened in Miami.
Data lake, warehouse, lakehouse
| Data lake | Data warehouse | |
|---|---|---|
| Data | Anything, raw | Structured, processed |
| Schema | On read | On write |
| Storage cost | Very cheap (S3, GCS) | Higher |
| Query speed | Slow without extra tooling | Fast |
| Users | Data scientists, ML engineers | Analysts, BI tools |
Each fails in its own way. A lake without governance becomes a data swamp. A warehouse is rigid and can't hold images, logs or free text.
A lakehouse combines them: cheap files (Parquet on object storage), plus a metadata and governance layer, plus a SQL engine, so lake files can be queried like warehouse tables. Databricks (Delta Lake), Apache Iceberg and Apache Hudi are the main tools.
Around them sit two smaller pieces:
- Data marts are department-scoped slices of the warehouse.
- Operational data stores (ODS) show current state (today's numbers, not trends).
The 3-tier architecture
flowchart TB
subgraph T3["Tier 3: Presentation"]
BI["Power BI / Tableau"]
DM["Data marts"]
SQL["SQL clients, APIs, ML"]
end
subgraph T2["Tier 2: Warehouse server"]
ST["Staging area"] --> ETL["ETL / ELT engine"] --> CORE["Core storage<br/>(facts + dimensions)"]
end
subgraph T1["Tier 1: Sources"]
S["MySQL, CSV, APIs, logs, external feeds"]
end
S --> ST
CORE --> BI
CORE --> DM
CORE --> SQL
Why a staging area instead of transforming straight from the source?
- Source systems are live, so heavy transforms shouldn't run against them.
- If the ETL fails halfway, staging lets it restart without hitting the source again.
- The raw copy is kept, so a bug in transform logic can be fixed and reprocessed.
- It gives an audit trail of what the raw data looked like.
Data marts
The warehouse is huge, access needs controlling, and different teams need different slices. Marts solve all of that.
- Dependent marts are built from the central warehouse (sources, then warehouse, then mart).
- Independent marts are built straight from sources, with no central warehouse.
- Physical marts copy the data, so they're faster for heavy use and cost more storage.
- Virtual marts are views over the warehouse:
CREATE VIEW finance_mart.fact_claims AS
SELECT *
FROM warehouse.fact_claims f
WHERE f.claim_type IN ('insurance', 'reimbursement');
A virtual mart has no storage overhead and is always current, but it's slower at scale. Conformed dimensions are what let separate marts still agree with each other.
Inmon vs Kimball
These are the two classic ways to build a warehouse.
- Inmon (top-down) builds a normalised enterprise warehouse first and derives dependent marts from it. It has a high upfront cost and is very consistent.
- Kimball (bottom-up) builds dimensional star-schema marts that deliver value early and ties them together with conformed dimensions.
Most real systems today are a hybrid: a central, governed layer, with star schemas on top for the people who query it.
Indexing and partitioning
| Indexing | Partitioning | |
|---|---|---|
| Idea | A separate lookup map pointing at rows | Physically splits the table into chunks |
| Best for | Finding a needle (a few rows) | Scanning big blocks (a date range) |
| Storage | Grows, since the index is extra data | Doesn't grow |
| Writes | Slower, since every index must be updated | Easier maintenance, since old partitions can be dropped |
The main benefit of partitioning is partition pruning. Filter on the partition column (usually a date) and the engine skips every partition that can't match. The two work together: partition to cut the search space, index to find rows inside it.
ETL vs ELT and the modern cloud stack
| ETL | ELT | |
|---|---|---|
| Transform happens | Outside the warehouse | Inside it, in SQL |
| Tools | Python, Spark, SSIS | SQL, dbt |
| Data loaded | Clean only | Raw first |
| Flexibility | Schema fixed upfront | Raw kept, so data can be re-transformed later |
ELT won in the cloud because platforms like Snowflake, BigQuery and Redshift separate cheap, effectively unlimited storage from elastic compute. Running transforms inside the warehouse became fast and cheap, so a separate transformation server stopped making sense. dbt is the tool to know here: if a job description mentions it, the team is doing ELT on a cloud warehouse.
The typical entry-level stack:
Source DB (MySQL) → ETL (Python / Airflow) → Warehouse (Snowflake / BigQuery / Redshift) → BI (Power BI)
TL;DR
- OLTP runs the business; the warehouse (OLAP) analyses it.
- Warehouses denormalise on purpose. The star schema (facts plus dimensions) is the default.
- Declare the grain first. Prefer atomic. Mixed grain silently double-counts.
- Surrogate keys for joins, natural keys kept for traceability.
- SCD Type 2 is how history survives change.
- Lakes store everything, warehouses store what's analysis-ready, and lakehouses try to do both.
- ELT on a cloud warehouse with dbt is the modern default.
A tip for studying this
Download the study guide PDF, upload it to NotebookLM, and generate quizzes, flashcards, summaries and mind maps from it for revision and interview prep. It works surprisingly well for remembering technical concepts.