System Design (High-Level Design) Module 6 – Data modelling and storage engines
OLTP vs OLAP: row stores, column stores, warehouses
Separate transactional and analytical workloads, see why column stores scan and compress so well, and feed a warehouse without slowing production.
What you will learn
- Separate transactional and analytical workloads by the shape of their queries
- Calculate what a row layout and a column layout read for one aggregation, and how encodings shrink a column
- Use min/max statistics and sort order to skip data a query does not need
- Choose a route from an OLTP database to an analytical store that does not slow production
- Choose where analytical data lives, from a warehouse, a lakehouse, an embedded engine or HTAP
Before you start
What you’ll learn · Before you start
What you will learn
- Separate transactional and analytical workloads by the shape of their queries
- Calculate what a row layout and a column layout read for one aggregation, and how encodings shrink a column
- Use min/max statistics and sort order to skip data a query does not need
- Choose a route from an OLTP database to an analytical store that does not slow production
- Choose where analytical data lives, from a warehouse, a lakehouse, an embedded engine or HTAP
Before you start
On this page
Placing an order reads and writes a handful of rows by key and must finish in milliseconds; revenue per city over the last 30 days reads 3 columns of 100,000 orders. The first is OLTP (online transaction processing): The work behind every click: many small reads and writes by key, each touching a few rows of current data and expected to finish in milliseconds., online transaction processing: the work behind every click, thousands of small queries a second on current data. The second is OLAP (online analytical processing): The work behind reports and dashboards: a few large queries that scan and aggregate many rows of history, in seconds to minutes., online analytical processing: the work behind reports and dashboards, a few large queries that scan and aggregate history.
The two want opposite storage layouts. A row store: A storage layout that keeps all the columns of a row side by side in a page, so reading or writing one whole row touches one place. It suits lookups and writes by key. keeps each row together, which suits OLTP; a column store: A storage layout that keeps each column separately, so a query reads only the columns it names and similar values sit together, where they compress well. keeps each column together, which suits OLAP, and builds compression, data skipping and batch execution on that layout. Running both workloads on one database lets a report slow the checkout, so systems keep them apart and move data from one to the other.
Row stores suit lookups, column stores suit scans
Set the two workloads side by side and they differ on every line:
| Criterion | OLTP | OLAP |
|---|---|---|
| Typical query | Place this order | Revenue per city per week |
| Rows touched | A few, by key | Millions, scanned |
| Latency | Milliseconds | Seconds to minutes |
| Concurrency | Thousands of queries | A few large ones |
| Layout | Row store | Column store |
| Freshness | Exact, now | Seconds to a day behind |
| When to choose | The system of record | Reports and dashboards |
A quick test sorts most queries: count the rows a query touches and how often it runs. A few rows found by key, thousands of times a second, is OLTP. Millions of rows, a few times an hour, is OLAP. The same business question can take both forms: “is this restaurant open?” is an OLTP lookup on every page view, while “which restaurants close early most often?” is an OLAP question over a year of history. Look at what each layout puts in a page:
The same orders in a row layout and a column layout
Text description of the diagram
The diagram shows two ways to store the same four orders, and what one query reads from each.
- Row layout: each page holds whole rows. Page 1 holds order 1 (Pune, delivered, 340) and order 2 (Delhi, cancelled, 160); page 2 holds order 3 (Pune, delivered, 520) and order 4 (Kochi, delivered, 280), with their other columns.
- Column layout: each column is stored in its own pages: the ids 1 to 4; the cities Pune, Delhi, Pune, Kochi; the statuses; and the amounts 340, 160, 520 and 280.
- A query for revenue per city must read every page of the row layout, because every page holds whole rows, but only the city and amount columns of the column layout.
A row store puts all of a row’s columns side by side in a page, so reading one order is one page read, and an OLTP query that wants a few whole rows reads little. An aggregation that needs 3 of 12 columns reads every page anyway, because every page holds whole rows. ClickHouse’s documentation describes the other side: a columnar database stores each column separately, so a query reads only the columns it uses, while operations on whole rows become more expensive (ClickHouse). The script lays the same 100,000 orders out both ways in 8 KiB pages and runs one aggregation on each:
# One table of 100,000 orders with 12 columns, laid out two ways in 8 KiB pages: row by row, and column by column.
# An analytical query (revenue per city over the last 30 days) runs on both layouts; each run counts the pages it
# has to read. The answers are the same; the bytes read are not.
import random
import struct
N, PAGE = 100_000, 8_192
CITIES = ["Pune", "Delhi", "Mumbai", "Kochi", "Indore", "Jaipur", "Surat", "Nagpur"]
STATUSES = ["delivered", "cancelled", "refunded"]
# name: struct format (fixed-width fields: text is padded, as fixed-width storage would hold it)
COLUMNS = {"id": "q", "created_at": "q", "user_id": "q", "restaurant_id": "q", "rider_id": "q", "city": "16s",
"status": "12s", "payment": "12s", "amount": "q", "tip": "q", "distance_m": "q", "rating": "i"}
rng = random.Random(4)
start = 1_750_000_000
rows = []
for i in range(N):
rows.append((i + 1, start + i * 78, rng.randrange(1, 40_000), rng.randrange(1, 3_000), rng.randrange(1, 9_000),
rng.choice(CITIES).encode(), rng.choice(STATUSES).encode(), b"card", rng.randrange(100, 2_000),
rng.randrange(0, 60), rng.randrange(300, 12_000), rng.randrange(1, 6)))
cutoff = start + N * 78 - 30 * 86_400 # the last 30 days of the data
# Row layout: whole rows, one after another.
row_format = struct.Struct("<" + "".join(COLUMNS.values()))
row_store = b"".join(row_format.pack(*r) for r in rows)
# Column layout: each column in its own run of pages.
column_store = {}
for j, (name, fmt) in enumerate(COLUMNS.items()):
column_store[name] = b"".join(struct.pack("<" + fmt, r[j]) for r in rows)
def pages(nbytes):
return -(-nbytes // PAGE) # whole pages, rounded up
def query_rows():
totals = {}
for (_, created_at, *_, city, status, payment, amount, tip, dist, rating) in row_format.iter_unpack(row_store):
if created_at >= cutoff:
name = city.rstrip(b"\0").decode()
totals[name] = totals.get(name, 0) + amount
return totals, pages(len(row_store)) # every page holds whole rows, so every page is read
def query_columns():
needed = ("created_at", "city", "amount")
created = [x for (x,) in struct.iter_unpack("<q", column_store["created_at"])]
city = [x.rstrip(b"\0").decode() for (x,) in struct.iter_unpack("<16s", column_store["city"])]
amount = [x for (x,) in struct.iter_unpack("<q", column_store["amount"])]
totals = {}
for c, name, a in zip(created, city, amount):
if c >= cutoff:
totals[name] = totals.get(name, 0) + a
return totals, sum(pages(len(column_store[n])) for n in needed) # only three columns are read
by_rows, row_pages = query_rows()
by_columns, column_pages = query_columns()
assert by_rows == by_columns
print(f"{N:,} orders, {len(COLUMNS)} columns, {row_format.size} bytes per row")
print("Revenue per city, last 30 days: " + ", ".join(f"{c} {v:,}" for c, v in sorted(by_rows.items())))
print(f"row layout: reads {row_pages:>5,} pages ({row_pages * PAGE / 1e6:.1f} MB), every column of every row")
print(f"column layout: reads {column_pages:>5,} pages ({column_pages * PAGE / 1e6:.1f} MB), 3 of the 12 columns")
print(f"The column layout reads {column_pages / row_pages:.0%} of the bytes for the same answer.") Output
100,000 orders, 12 columns, 108 bytes per row Revenue per city, last 30 days: Delhi 4,466,753, Indore 4,263,998, Jaipur 4,392,555, Kochi 4,289,533, Mumbai 4,379,126, Nagpur 4,425,615, Pune 4,506,963, Surat 4,259,669 row layout: reads 1,319 pages (10.8 MB), every column of every row column layout: reads 392 pages (3.2 MB), 3 of the 12 columns The column layout reads 30% of the bytes for the same answer.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 layout_bytes.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The script checks that both layouts give the same revenue for every city. The three columns the query names,
created_at, city and amount, take 32 of each row’s 108 bytes, so the column layout read 392 pages where the row
layout read all 1,319: 30 % of the bytes, before any compression. Change the query to name more columns and the gap
closes; a query that wants whole rows is where the row layout wins.
Analytical data usually gets a home of its own, such as a data warehouse: A column store with SQL, usually managed, loaded from an organisation's sources and used for shared reporting with governed access.: a managed SQL service built on a column store and loaded from the operational databases. Reports leave the primary for a read replica: A copy of a database that follows its primary's changes and serves read-only queries, so that reads such as reports move off the primary., or for a warehouse fed by nightly exports or a change stream. Its alternatives are a lakehouse: Columnar files in object storage with a table format on top, so that several engines can query very large or long-kept history as tables. of columnar files in object storage, an embedded engine such as DuckDB, and HTAP (hybrid transactional and analytical processing): A database product that keeps row and column copies of the same data, so analytics run on fresh operational data without a separate pipeline., one database holding both layouts.
Checkpoint
Check: OLTP, OLAP and the layout each wants
Columns compress because neighbouring values are alike Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Min/max statistics skip row groups, but only when the data is ordered
- Column engines aggregate in batches and pay on every single-row write
- Feed analytics from a replica, an export or a change stream, not the primary
- Which home for analytical data: warehouse, lakehouse, embedded engine or HTAP?
- Practise: encode a column, then route the queries
- Run-length encode a column and count without decoding
- Interview questions
- Every night a finance report runs on the orders database, and checkout p99 latency rises from 120 ms to 900 ms while it runs. Design a fix for this week and one for this quarter.
- Product asks you to move the live orders table into a column store, so that the app and the dashboards use one system. What goes wrong for the app, and what would you propose instead?
- Operations wants a dashboard of delayed deliveries per city, at most one minute behind, over 40 million orders a day. Design the path from the orders database to the dashboard and size it.
- Key takeaways
3 diagrams · 2 runnable examples (Python) · 1 exercise with sample tests · 3 interview answers · about 44 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- bit-packing
- Storing small integers side by side in exactly as many bits as their range needs, instead of a whole byte or word each.
- column store
- A storage layout that keeps each column separately, so a query reads only the columns it names and similar values sit together, where they compress well.
- data warehouse
- A column store with SQL, usually managed, loaded from an organisation's sources and used for shared reporting with governed access.
- delta encoding
- Storing the differences between consecutive values instead of the values, so a column of close numbers, such as timestamps in arrival order, needs few bits a value.
- dictionary encoding
- Storing each distinct value of a column once and putting a small integer code in place of every value.
- hot standby
- A PostgreSQL replica that keeps applying its primary's changes while it accepts read-only queries.
- HTAP (hybrid transactional and analytical processing)
- A database product that keeps row and column copies of the same data, so analytics run on fresh operational data without a separate pipeline.
- lakehouse
- Columnar files in object storage with a table format on top, so that several engines can query very large or long-kept history as tables.
- logical decoding
- PostgreSQL's extraction of the persistent changes to tables from the write-ahead log into a format other programs can consume, the basis of its change streams.
- min/max statistics (zone map)
- The smallest and largest value of a column in each block or row group, kept so that a query can skip the blocks whose range cannot match its filter.
- OLAP (online analytical processing)
- The work behind reports and dashboards: a few large queries that scan and aggregate many rows of history, in seconds to minutes.
- OLTP (online transaction processing)
- The work behind every click: many small reads and writes by key, each touching a few rows of current data and expected to finish in milliseconds.
- read replica
- A copy of a database that follows its primary's changes and serves read-only queries, so that reads such as reports move off the primary.
- row group
- A horizontal slice of a columnar file, such as a Parquet file, holding one chunk of every column for a range of rows.
- row store
- A storage layout that keeps all the columns of a row side by side in a page, so reading or writing one whole row touches one place. It suits lookups and writes by key.
- run-length encoding (RLE)
- Storing a run of repeated values once, with the length of the run, so a column with long runs of the same value shrinks to a few pairs.
- vectorised execution
- Running each step of a query on a batch of values from one column at a time, rather than on one value or one row at a time.
References
- What is a columnar database? (ClickHouse documentation) (ClickHouse, Inc.)
- What is OLAP? (ClickHouse documentation) (ClickHouse, Inc.)
- Why DuckDB (DuckDB Foundation)
- DuckDB documentation: Reading and writing Parquet files (DuckDB Foundation)
- Apache Parquet concepts (The Apache Software Foundation)
- Apache Parquet encodings (The Apache Software Foundation)
- Apache Parquet page index (The Apache Software Foundation)
- PostgreSQL documentation: Hot standby (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Logical decoding concepts (The PostgreSQL Global Development Group)
- Apache Iceberg (The Apache Software Foundation)
- TiFlash overview (TiDB documentation) (PingCAP)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress