Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free intro, Pro: 5 of 20 minutes free
Free: Row stores suit lookups, column stores suit scans. With a pass: the other 8 sections and 3 interview answers, about 15 minutes.

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:

OLTP and OLAP workloads
Criterion OLTPOLAP
Typical query Place this orderRevenue per city per week
Rows touched A few, by keyMillions, scanned
Latency MillisecondsSeconds to minutes
Concurrency Thousands of queriesA few large ones
Layout Row storeColumn store
Freshness Exact, nowSeconds to a day behind
When to choose The system of recordReports 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:

Orders stored row by row in pages, and column by column; revenue per city reads every row page but only two columns.Row layoutColumn layoutRevenue per city reads every pageof the row layout, but only the cityand amount columns of the column layoutPage 1: whole rows(1, Pune, delivered, 340, …)(2, Delhi, cancelled, 160, …)Page 2: whole rows(3, Pune, delivered, 520, …)(4, Kochi, delivered, 280, …)id1, 2, 3, 4cityPune, Delhi,Pune, Kochistatusdelivered,cancelled, …amount340, 160,520, 280

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.

  1. 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.
  2. 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.
  3. 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 aggregation on a row layout and a column layout Python · layout/layout_bytes.py
# 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

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

  1. Question 1 of 2 Which of these queries belongs on the analytical store rather than the OLTP database?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Average delivery time per city per week over the last two years

    Two years of history grouped by city and week is a large scan and aggregation. The other three touch a few rows by key and must be exact and fast, which is the OLTP database's job.

  2. Question 2 of 2 A table has 20 columns of 8 bytes each and 10 million rows. A query needs 2 of the columns. Ignoring compression, how many megabytes does a column store read?

    Type a number.

    A hint

    A column store reads only the columns the query names, every row of each.

    Show the answer: Question 2 of 2

    Answer: 160 MB

    2 columns × 8 bytes × 10,000,000 rows = 160,000,000 bytes, or 160 MB. A row store would read every column of every row: 20 × 8 × 10,000,000 = 1,600 MB.

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.

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).

Read a complete sample first: Design a unique ID generator

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.