System designCore8 min
Data warehouses, lakes & pipelines
The questions a business asks cut across every system it runs: orders, app events, ads, deliveries, support. A data warehouse or lake gathers copies of all of them in one place built for analysis, and a pipeline keeps those copies fresh, cleaned, modeled and tested. Most of the engineering is in the pipeline, not the storage.
1 · The idea
Analysis needs every system's data in one place
A meal-kit subscription company wants to know which ad channel brings customers who are still subscribed six months later. The answer needs data from five systems that were never meant to meet:
Source | Holds | Shape |
|---|---|---|
PostgreSQL | Customers, subscriptions, boxes ordered | Current state; old values overwritten |
App event logs | Sign-ups, recipe views, skips | JSON files, billions of rows |
Two ad platforms | Spend and clicks per campaign | Daily exports through their APIs |
Delivery partner | Delivered, late, damaged | CSV files dropped each night |
Support tool | Complaints and refunds | A SaaS API |
None of these can join with another, and the database can't answer questions about the past, since a customer's plan is overwritten when it changes. Running big scans on the production database would also slow the app (OLTP vs OLAP & columnar storage). So the company copies all five into one analytical store, keeps the history, and reshapes it for questions.
2 · Warehouse or lake
A warehouse, a lake or a lakehouse holds the copy
There are three common homes for that copy, and they've been converging:
Store | Holds | Strength | Catch |
|---|---|---|---|
Warehouse | Tables in a managed columnar SQL database | Fast SQL, managed storage | Data must be loaded into it first |
Lake | Files, often Parquet, in object storage | Cheap, any format, any engine | Files alone have no transactions |
Lakehouse | Lake files plus a table format | Tables with commits on cheap storage | One more layer to run |
Cloud warehouses separate storage from compute. BigQuery stores tables in its own columnar format and scales storage and compute independently. Snowflake runs queries on virtual warehouses, independent compute clusters that don't affect each other's performance, so the finance team's month-end queries don't slow the dashboards.
A lake is just files, so two jobs writing at once, or a reader arriving mid-write, can see a mess. Table formats such as Apache Iceberg and Delta Lake fix that. Each change writes new files plus a new metadata file, then swaps the table's pointer to it atomically, so readers never see partial changes. Every version is a snapshot, which enables time travel and rollback to a good state.
3 · ELT and layers
Load the raw data first, then transform it in layers
The traditional order was ETL: extract from the source, transform on the way, load the cleaned result. Cloud warehouses made ELT common: load the raw data as it is, then transform it inside the warehouse with its own compute. The main reason: the raw source data stays available, so transformations can be changed and rerun later. ETL still fits industries such as finance and healthcare, where data must be validated before it enters the warehouse.
The medallion layout. Raw keeps every load as received, so any later layer can be rebuilt. Cleaned joins sources into one view of customers and boxes. Marts are shaped for one team's reports.
The raw layer is a historical archive that allows reprocessing without re-reading the source systems. For the meal-kit company that matters: a bug in the cleaning step is fixed by rerunning it from raw, without asking the ad platforms or the delivery partner for old data again (Batch processing). Changes from PostgreSQL arrive through change data capture rather than nightly full copies (Change data capture).
4 · Star schemas
Model the marts as facts surrounded by dimensions
Marts are usually dimensional models. A fact table records measurement events, one row per event, with numeric measures and a key for each dimension. Dimension tables hold the descriptive context: who, what, where, when. Laid out with the fact in the middle and dimensions around it, the result is called a star schema:
The fact holds measures: price, discount, delivery minutes. Every question becomes a join from the fact to the dimensions it filters or groups by.
The first decision is the grain: exactly what one fact row represents. The grain is the binding contract of the design, and the usual advice is the lowest level the business process captures, one row per box delivered, not per customer per month. Summaries can always be built from detail, but detail can't be recovered from summaries, and different grains belong in different fact tables.
5 · History
Keep history on purpose, because sources overwrite it
In March, a customer named Ines switches from the two-person plan to the family plan. PostgreSQL updates the customer row; the old plan is gone. A report of revenue by plan for February would now wrongly count Ines's February boxes as family boxes. The warehouse must keep what was true when.
A type 2 slowly changing dimension does it by adding a row instead of updating one. Each version of the customer gets its own surrogate key, plus three columns: when the row became effective, when it expired, and whether it's current. New facts point to the new key, and old facts keep pointing to the old one:
dim_customer after Ines changes plan
customer_key customer_id plan effective expires current
------------ ----------- ---------- ---------- ---------- -------
10441 C-2207 two-person 2025-06-02 2026-03-14 no
11893 C-2207 family 2026-03-14 9999-12-31 yesFebruary's boxes reference key 10441 and stay two-person; April's reference 11893. Table formats such as Delta Lake support the merge operations this needs, and also let a whole table be read as of an earlier snapshot.
6 · Pipelines
A pipeline is code, so test it before anyone sees its output
The transformations are code: in dbt, each model is a SQL SELECT, and the tool builds tables from them in dependency order. Data changes under that code every day, so the pipeline also tests the data. dbt ships four generic tests, unique, not_null, accepted_values and relationships, and any SQL query that returns failing rows can be a test of its own:
A new delivery-partner export arrived with unknown customer IDs. The tests catch it before the dashboards switch to the new build, and the fix is a rerun from raw.
Because each run can be repeated over the same raw partition, a fix or a backfill is simply another run. The company also tracks freshness: if the ad platform export hasn't arrived by 6 a.m., the morning dashboards say so instead of quietly showing yesterday's numbers.
7 · In practice
Copy everything raw, model for questions, test every run
The storage choice matters less than the habits around it. Personal data copied into the warehouse inherits every duty of the source, from access control to deletion requests (Privacy, residency & retention).
- Land raw data unchanged
Append-only, partitioned by load date; it's the archive every layer rebuilds from.
- Transform inside the warehouse
ELT with versioned SQL models, in layers from raw to marts.
- Declare the grain first
One fact row per real event, at the lowest level captured.
- Keep history in dimensions
Type 2 rows for attributes reports group by.
- Test and publish atomically
Failing tests hold the old version; table formats swap in the new one at once.
Check yourself
4 questions
Takeaways
Remember this
- A warehouse or lake gathers copies of every system's data so questions can join them and see history.
- Warehouses are managed columnar databases; lakes are files in object storage; table formats add atomic commits and snapshots to lakes.
- Load raw data first and transform it inside, in layers from raw to cleaned to marts, so everything can be rebuilt.
- Model marts as facts at the lowest grain with dimensions around them, and keep dimension history with type 2 rows.
- Pipelines are code: test the data on every run and publish only builds that pass.