ClickHouse · from first principles
1 / 14 · MODULE 02 NOW
Module 01 · the reason before the machinery

Why ClickHouse Exists

ClickHouse is not “a faster Postgres.” It is the consequence of choosing a different kind of question, then arranging storage and computation around that question.

billions of rows ≠ billions of answers
today's question: what work are we optimizing for?
01
the single concept right now

Two kinds of database work

Before “columnar,” “MergeTree,” or “vectorized execution” can mean anything, we need to separate two workloads that happen to share SQL but ask computers to do almost opposite things.

A transactional system handles the live state of an application. A customer changes an address. A payment moves from pending to settled. A shopping cart gains one item. Each request usually touches a few rows, arrives concurrently with many other tiny requests, and cares intensely about the exact current state.

An analytical system asks questions about populations. How many payments failed by issuer, hour, and country over 90 days? Which release caused p95 latency to climb? What percentage of users who saw feature A returned seven days later? These queries may touch millions or billions of records, but often need only a handful of fields from each record.

OLTP · transactions

Find or change a few rows

  • Point lookups and tiny ranges
  • Frequent row-level inserts and updates
  • High request concurrency
  • Correctness between simultaneous changes
  • Typical question: “What is this order now?”
OLAP · analytics

Scan and combine many rows

  • Large ranges and time windows
  • Filters, groups, sums and percentiles
  • Append-heavy event streams
  • Throughput across lots of data
  • Typical question: “What pattern do all orders reveal?”

Same table. Opposite physical preference.

A row store wants every field for one entity close together, because reconstructing one complete row is common. An analytical store wants every value of one field close together, because applying the same operation to that field across a huge population is common.

The query shape chooses the useful neighborhood.

Workload sorter

Classify each query by its dominant shape. Do not decide from the SQL vocabulary; decide from how many rows it needs and what it does to them.

SELECT * FROM orders WHERE order_id = 'a91f';
SELECT country, quantile(.95)(latency_ms) FROM requests WHERE ts > now() - INTERVAL 7 DAY GROUP BY country;
UPDATE accounts SET balance = balance - 40 WHERE account_id = 812;
SELECT toStartOfHour(ts), count() FROM events GROUP BY 1 ORDER BY 1;
0 of 4 correctly sorted — your first pass is allowed to be messy.
02
now the storage choice has a reason

Rows versus columns

Imagine an event table with sixteen fields and ten million rows. Your dashboard needs only timestamp, country, and revenue. Where should those values live?

In a row-oriented layout, the sixteen values for event one are neighbors, followed by the sixteen values for event two, and so on. Reading three fields across every event means walking through storage blocks containing the other thirteen fields too. The database may avoid materializing unused values, but the physical pages still mingle useful and irrelevant bytes.

In a column-oriented layout, all timestamps are contiguous, all countries are contiguous, and all revenues are contiguous. The engine can open the three needed column streams and leave the other thirteen untouched. Columnar storage does not make computation free. It changes how many bytes must enter the computation in the first place.

The unnecessary-I/O lab

Move the slider to choose how many of a 16-column table your query reads. This deliberately simple model assumes 10 million rows and 8 bytes per value, with no compression or indexes, so we can isolate storage orientation.

3 / 16
row-oriented scan1,280 MB

Useful and unused fields share the pages being scanned.

column-oriented scan240 MB

Read the three selected column streams: 18.75% of the raw bytes.

Reality is richer: compression, sparse indexes, caches, storage blocks, and predicates all change the exact bytes. The invariant survives: an analytical query pays primarily for the columns and granules it actually reads.

Why columns compress well

Neighbors have the same type and often similar values. A timestamp column may rise gradually; a country column repeats a small vocabulary. Delta encoding, dictionaries, run-length patterns, and general compression have structure to exploit.

Similarity becomes fewer bytes.

Why columns compute well

The CPU can apply one operation to a batch of same-typed values. Tight loops have less per-row dispatch and are friendlier to caches and SIMD. We will make this precise in Module 7.

Sameness becomes throughput.

Columnar is necessary, not sufficient

A file format can store columns. A database must also ingest data, decide which blocks might match a filter, schedule work across cores, aggregate partial results, merge new data, recover from failures, and expose a usable query language. ClickHouse is the whole engine built around the analytical shape—not merely columns on disk.

03
put the name back into the picture

The ClickHouse answer

ClickHouse is a column-oriented SQL database management system for online analytical processing: analytical answers fast enough to sit inside an interactive product, dashboard, investigation, or alerting loop.

That sentence gives us four commitments. Column-oriented says it avoids reading unused fields. SQL says users describe the result rather than a procedural scan. OLAP says the common unit of work is a large population, not one mutable record. Online says the result should arrive while a human or application is still waiting—not after an overnight warehouse job.

The official documentation contrasts transactional queries that touch a few rows with analytics that may process billions or trillions. ClickHouse’s design then stacks several mutually reinforcing ideas: read only relevant columns; keep data ordered; maintain a sparse primary index over blocks rather than every row; process values in batches; parallelize work; and treat inserts as immutable parts that can be merged in the background.

We are not unpacking those mechanisms yet. The crucial causal chain is that none of them are random tricks. They all follow from accepting the analytical workload as the center of the design.

Follow one analytical question

SELECT country, sum(revenue) FROM events WHERE day = today() GROUP BY country

1 · pruneFind relevant data regions
2 · readOpen only needed columns
3 · processFilter and sum in batches
4 · combineMerge partial aggregates
First, the engine uses physical order and indexes to avoid regions that cannot contain today. The cheapest byte is the byte never read.

The non-goal is part of the design

ClickHouse can retrieve individual rows and supports updates and deletes, but those operations are not the center of gravity. If the core job is thousands of tiny, concurrent, row-level transactions with locking semantics, a transactional database is usually the natural primary store.

A specialized engine is powerful because it is willing to prefer something.

04
architecture starts with workload honesty

ClickHouse, Postgres—or both?

The mature answer is often “both.” One database owns transactional truth; another serves analytical questions. Events, changes, or CDC move data from the first world into the second.

Architecture triage

Pick a scenario, make the architecture call, then compare your reason with the workload.

Payment checkout

Create orders, reserve inventory, and guarantee that two concurrent requests cannot spend the same balance.

What is the dominant query and consistency shape?

Bad reason to choose it

“The benchmark says it is fast.” Fast at what? With which schema, query distribution, data volume, concurrency, freshness target, hardware, and operational constraints?

Good reason to choose it

“Our hot queries scan or aggregate large append-heavy datasets, touch a subset of columns, need fresh results, and we can model the storage order around those filters.”

the prerequisite chain from here

Our 14-module route

We started with the workload because every later choice needs a “why.” The rest of the course descends into storage, climbs through execution, and ends by designing a production system.

01 · complete

Why ClickHouse exists

OLTP vs OLAP, row vs column, and honest workload fit.

02 · now

Parts, granules & marks

Build the physical picture of a MergeTree table on disk.

03

The MergeTree lifecycle

Why inserts create parts and background merges are unavoidable.

04

Types, codecs & compression

Model values so storage and CPU have less work.

05

ORDER BY is architecture

Sorting keys, sparse primary indexes, locality, and pruning.

06

Ingestion that stays fast

Batches, async inserts, part pressure, and streaming pipelines.

07

Inside the query pipeline

Vectorization, parallelism, operators, memory, and partial states.

08

Aggregation-shaped SQL

ClickHouse idioms, arrays, windows, approximate functions, and states.

09

Joins & dictionaries

Denormalization, join algorithms, direct lookups, and tradeoffs.

10

Views & projections

Move work from query time to ingest time—with eyes open.

11

Changing existing facts

Mutations, lightweight deletes, ReplacingMergeTree, and FINAL.

12

Distributed ClickHouse

Shards, replicas, coordination, and separated compute/storage.

13

Observe, explain & tune

System tables, query logs, EXPLAIN, profiles, and failure modes.

14

Production capstone

Design and defend a real-time analytics system end to end.

do not peek before you answer

Understanding check

The goal is not to remember “ClickHouse is columnar.” The goal is to be able to derive why columnar follows from the workload.

1. A query reads three columns across 500 million rows. Why can columnar layout help even before indexes or faster CPUs enter the story?

Because the physical read can be limited to the three needed column streams. A row-oriented scan pulls storage blocks containing the rest of each row too. The first win is fewer irrelevant bytes moving from storage into memory.

2. Why is “ClickHouse is faster than Postgres” an incomplete—and often dangerous—sentence?

Performance is workload-specific. ClickHouse is arranged for large analytical scans and aggregations; Postgres is arranged around transactional access, point lookups, row-level changes, and concurrency semantics. Either can lose badly when judged on the other system’s preferred work.

3. Your application needs transactional truth and sub-second product analytics over that truth. What architecture should you consider first?

A transactional database as the source of truth plus ClickHouse as the analytical serving system, connected through event emission or change data capture. The boundary lets each engine keep its preferred workload.

Say it back in one sentence

ClickHouse exists because analytical queries touch huge populations but usually only a few fields, so an engine can win by arranging storage and computation around columns, batches, pruning, and parallel aggregation.

If that sentence feels earned rather than memorized, Module 1 has done its job.

Primary sources for this module

Module 02 · open the black box

Parts, Granules & Marks

Columnar storage explains which fields ClickHouse can avoid. Parts, granules, and marks explain which regions of those fields it can avoid—and what it must still read.

part = independently sorted island
mark = a synchronized doorway into every column
05
the first durable object is not a row

One insert becomes a part

Now that we know why ClickHouse stores columns separately, we can ask the physical question: when a batch arrives, where do those column streams actually live?

In a MergeTree table, an insert produces a new data part. Think of a part as an immutable, self-contained directory holding one batch after ClickHouse has sorted that batch by the table’s sorting key. A second insert produces a second independently sorted part; it does not splice rows into the first one.

This word matters because a query does not search one globally sorted file. It considers all active parts that survive partition pruning, applies the sparse index inside each one, then combines their streams. Background merges can later replace several small parts with a larger sorted part, but that lifecycle belongs to Module 03.

What is a “part,” in normal-person language?

A part is a sealed bundle of rows created from one incoming batch. ClickHouse sorts the batch, writes everything needed to read it later, and then treats that bundle as finished.

Picture a print shop

You hand the shop 1,000 index cards. The shop sorts those cards by (service, time), prints several organized mini-books, adds a tiny table of contents, and shrink-wraps the result. That shrink-wrapped package is one part.

When another 1,000 cards arrive, the shop does not cut open the first package and insert each new card into its perfect position. It makes a second sorted package. Later, a background worker may open several packages, combine their cards in order, and produce one larger replacement package.

Incoming cardsrows in one insert batch
Shrink-wrapped packagean immutable data part
Cards sorted insidethe table’s ORDER BY key
Combining packages latera background merge
Where the analogy breaks: a part is not merely a backup archive. It is live table data that queries actively read. “Immutable” means ClickHouse replaces parts with new parts instead of editing their contents in place.
part 202607_41_41_0 · sorted by (service, ts)wide format · conceptual
service.bin
ts.bin
status.bin
latency.bin
The aligned cyan starts are granule boundaries. A real wide part stores column data in separate files; a compact part packs columns together. The logical model—sorted rows, granules, and synchronized marks—survives both formats.
wide part

Columns have separate files

Large parts normally use separate column streams. Reading service and latency can leave unrelated column files alone.

compact part

Columns share one file

Small parts can pack all columns into one file to reduce filesystem overhead. Marks still locate the selected columns and granules inside that packed representation.

Partition and part are different nouns

A partition is a logical grouping produced by PARTITION BY, often a month. A partition can contain many parts. Parts from different partitions are not merged together. Partition pruning can reject a whole month; the primary index then works at finer granule boundaries inside each surviving part.

Partition, part, granule, row—please untangle these

These are four nested levels. The names are confusing because everyday English makes “partition” and “part” sound identical. In ClickHouse they are not.

Table = filing cabinetthe whole events table
Partition = one drawerperhaps all of July 2026
Part = one sealed folderone sorted batch inside that July drawer
Granule = one page groupthe smallest row neighborhood ClickHouse reads

Put numbers on it

Suppose July has received 30 insert batches and no merges have happened yet. The July partition can contain 30 parts. If each part has 819,200 rows and uses 8,192-row granules, each part has roughly 100 granules. Every granule contains individual rows.

A date filter may discard every drawer except July. ClickHouse must then inspect the folders inside July and use each folder’s index to choose page groups.

Do not memorize the furniture. Memorize the nesting: table → partition → part → granule → row. The furniture only gives those levels something physical to attach to.
06
the atom of reading is a group

A granule is the minimum bet

ClickHouse does not maintain one primary-index entry per row. It samples the sorted key at granule boundaries, trading perfect precision for an index small enough to stay useful at enormous scale.

A granule is the smallest indivisible row range ClickHouse reads from a MergeTree part. The familiar default setting is index_granularity = 8192, so a granule is often described as 8,192 rows. That is a ceiling, not a universal promise: adaptive granularity can use fewer rows when they are wide, governed by index_granularity_bytes. A single unusually large row can form its own granule.

The consequence is subtle and essential. If one row in a granule might satisfy the predicate, ClickHouse reads the relevant columns for that granule and evaluates the real filter afterward. The sparse index may admit false positives; it must never create false negatives.

Why not build an exact index for every single row?

Because the map can become almost as troublesome as the territory. An entry for every row gives precise lookups, but billions of entries consume memory and create more work to store and maintain.

The phone-book version

Imagine a 1,000-page phone book. One strategy is a separate index entry for every name. That index is enormous. Another is a tab every eight pages saying “names beginning around MARTIN start here.” You jump close to MARTIN, then scan a few pages.

ClickHouse chooses the second shape because analytical queries usually want many rows anyway. With one billion rows and a simple 8,192-row granularity, the rough order of magnitude is about 122,071 key samples instead of one billion row pointers. The exact number varies by parts and adaptive granularity, but the size difference is the point.

Fewer index entriessmall enough to search and often keep in memory
Extra nearby rowsthe price paid for that compact index
Expert truth hiding underneath: this is a deliberate throughput trade. ClickHouse gives up row-perfect navigation so the index remains tiny relative to the analytical dataset.
smallergranules
more precise pruning
moremarks
larger index + metadata
largergranules
more extra rows read

Common misconception

“The primary index found my matching rows.” Not quite. It found a conservative range of granules that may contain matching rows. The query still reads selected columns for those granules and applies the predicate to every candidate row.

The index chooses neighborhoods, not answers.

Does “read the whole granule” mean reading every column?

No. It means reading the granule’s row range for the columns the query needs. Column pruning and granule pruning are separate savings that stack together.

A concrete query

Your table has 50 columns. The query needs service, status, and latency. The sparse index decides that granule 14 might match. ClickHouse reads the granule-14 ranges for those three columns—not all 50 columns—but it normally reads all candidate rows inside those three ranges.

Column pruning askswhich vertical fields do we need?
Granule pruning askswhich horizontal row neighborhoods might match?

If the granule contains 8,192 rows and only 40 truly match, the remaining rows are filtered after the selected column data is read. That is still far cheaper than reading every granule or every column in the part.

The phrase to keep: whole candidate granule, selected query columns.
07
one boundary, synchronized across columns

Marks turn order into jumps

Sorting places related keys near one another. A mark makes that locality addressable: it tells ClickHouse where a granule begins in the primary-key index and in each column stream.

At every granule boundary, the sparse primary index records the sorting-key tuple of the first row. The corresponding mark information for each column maps that same logical boundary to offsets in compressed data. During a query, ClickHouse searches the small in-memory key samples, determines candidate mark ranges, and seeks directly into only the selected columns.

That synchronized boundary is the bridge between logical pruning and physical I/O. Without the key sample, the engine would not know which ranges might match. Without per-column offsets, it would know the range but still lack a direct doorway into the relevant compressed streams.

Index, mark, and granule sound like the same thing

They cooperate at one boundary, but they have different jobs: the index stores a clue, the mark stores a location, and the granule is the data neighborhood reached through that location.

Use a cookbook

The cookbook is ordered alphabetically by recipe name. A tiny card says “Pasta begins around page 240.” That card is like the sparse primary-index sample: it gives you a key value near a boundary. The ribbon physically tucked into page 240 is like a mark: it tells you where to open. The recipes on pages 240–247 are like the granule: the chunk you actually pull into view.

Primary-index sample“the first key here is roughly (billing, 08)
Mark information“that boundary starts at these offsets in each column stream”
Granulethe rows between this boundary and the next
Exact predicatethe final test applied after candidate rows arrive
Where the cookbook breaks: ClickHouse columns are separate compressed streams, so it needs a synchronized location for every selected column—not one literal page number for an entire row.

The mark-pruning lab

This toy part has 48 rows sorted by (service, minute), split into six 8-row granules. Choose a service and move the time cutoff. Watch ClickHouse read whole candidate granules—even when only some rows match.

2 / 6 granules read · 16 rows examined · 12 rows match
mark 0
(auth,00)
auth · 00auth · 07
mark 1
(auth,08)
auth · 08auth · 15
mark 2
(billing,00)
billing · 00billing · 07
mark 3
(billing,08)
billing · 08billing · 15
mark 4
(search,00)
search · 00search · 07
mark 5
(search,08)
search · 08search · 15
The second auth granule is a necessary false positive at the row level: minutes 08–11 match, but 12–15 ride along and are rejected after reading.
How should I use the simulator without getting lost?

Ignore the file jargon for one minute. Make three moves and predict the highlighted boxes before looking.

  1. Choose auth and stop at minute 07. Exactly one 8-row granule should light up. The cutoff lands on its final row, so eight rows are read and all eight match.
  2. Move the cutoff to minute 11. The second auth granule must light up because it contains minutes 08–15. ClickHouse reads 16 auth rows total; minutes 12–15 are extra passengers and fail the exact filter.
  3. Switch auth to search. The number of granules stays the same, but different boxes light up. The first sorting-key field groups each service into its own contiguous neighborhood.

The one question to answer

Why can ClickHouse not read only minutes 08–11 from that second toy granule? Because the sparse boundary gets it to the 08–15 neighborhood. The row-level predicate, not the sparse index, separates 08–11 from 12–15.

If this clicks: a mark is not a magical answer pointer. It is a fast doorway into a deliberately chunky read unit.

Read the query path backward

Result rows come from filtering candidate rows. Candidate rows come from selected granules. Selected granules come from mark ranges. Mark ranges come from comparing the predicate with sampled sorting-key values inside every surviving part.

predicate → key range → marks → column offsets → granules → rows

08
the model predicts operational pain

Every part repeats the search

A sparse index is local to a part. If a partition contains 400 active parts, the query has 400 separately sorted islands to inspect, schedule, seek into, and combine.

This does not mean “400 parts equals 400 full scans.” Partition metadata and each part’s min/max and primary index can reject work. But part count still creates fixed overhead: more metadata, more index checks, more stream setup, more tasks, and often smaller reads. That is why a flood of tiny inserts can damage read performance even though every insert succeeds quickly.

The physical cost can be remembered as a product: surviving parts × candidate granules per part × selected column bytes per granule, plus CPU for predicates and aggregation. Module 01 reduced selected columns. This module reduced granules. Module 03 will explain how merges reduce the number and fragmentation of parts.

Why are lots of tiny parts annoying?

Because every part is its own little sorted world. ClickHouse cannot inspect one master index and be done; it must ask each surviving part what it might contain.

Imagine searching 400 short books

All 400 books are alphabetized internally, and each has a good table of contents. Finding “zebra” in any one book is easy. But to find every zebra across the collection, you still pick up each relevant book, inspect its table of contents, possibly open it, and later combine the answers.

One larger anthology may contain the same pages, yet it has fewer covers, fewer tables of contents, fewer open/close operations, and longer contiguous passages to read. That is the intuition behind merging parts.

Each extra part addsmetadata, index checks, stream setup, and scheduling
Merging can reducethe number of independently searched objects
Important correction: ClickHouse does not fully scan every book. Good metadata may reject a part immediately. The overhead is repeated decision-making and fragmented work, not automatically repeated full scans.
partsthat survive partition pruning
×
granulesadmitted by sparse indexes
×
columnsand compressed ranges requested
Can you work the cost formula with tiny numbers?

Treat the formula as a counting lens, not a promise of exact milliseconds.

Suppose a date filter leaves 12 parts. Inside each part, the sparse index admits 4 granules. The query needs 3 columns.

12 × 4 = 48candidate granule neighborhoods across all parts
48 × 3 = 144conceptual granule-column ranges to obtain

Compression blocks, caching, coalesced reads, parallelism, and file format affect the real I/O, so ClickHouse may not issue 144 literal disk requests. The multiplication still tells you where optimization can remove work.

  1. Reject more parts with partition or part-level metadata.
  2. Reject more granules with a sorting key aligned to predicates.
  3. Request fewer columns by avoiding SELECT * when it is unnecessary.
Connection to the course: Module 01 explained the third lever. Module 02 explains the first two physical levels. Module 03 will show why part count changes over time.

Inspect active parts

SELECT name, partition, rows, marks, bytes_on_disk, part_type FROM system.parts WHERE database = currentDatabase() AND table = 'events' AND active ORDER BY partition, name;

The marks count is a practical bridge from the logical model to the stored parts.

Inspect pruning

EXPLAIN indexes = 1 SELECT count() FROM events WHERE service = 'auth' AND ts >= now() - INTERVAL 1 HOUR;

Look for how many parts and granules survive. Fast SQL starts becoming an accounting problem you can see.

rebuild the read path without peeking

Module 02 check

The target is a physical story: from a predicate to a small set of byte ranges, including the extra work a sparse index deliberately accepts.

1. What is the difference between a partition, a part, and a granule?

A partition is a logical grouping from PARTITION BY. It contains parts. A part is an immutable, independently sorted batch created by an insert or merge. A granule is the smallest indivisible row range read inside a part.

2. A mark points near one matching row. Why might ClickHouse still read thousands of rows?

Marks identify granule boundaries, not individual rows. If a granule may contain the row, ClickHouse reads the selected columns for the whole granule and applies the predicate afterward. With a typical 8,192-row granularity, extra rows are an intentional precision-for-index-size trade.

3. Why must marks be synchronized across every column, including columns outside the sorting key?

The primary-key samples decide which logical granules may match. The query may need other columns for filtering or output, so their mark data must map the same granule boundary to offsets in their compressed streams. That is what turns a logical key range into direct physical reads.

4. Why can many tiny parts slow reads even when the sorting key is excellent?

The sparse index is per part. More parts mean more metadata and index checks, more stream setup and scheduling, and often smaller fragmented reads. Merges exist partly to collapse those independently sorted islands into fewer, larger ones.

Give me the entire module as one slow story

An insert arrives as a batch. ClickHouse sorts that batch and seals it into a part. Inside the part, rows are divided into granules. At each granule boundary, the primary index saves a sample key and every column keeps location information for that boundary.

Later, a query asks for a few columns and includes a filter. ClickHouse first rejects irrelevant partitions and parts when it can. Inside every surviving part, it compares the filter with sparse key samples. Those samples identify candidate granules. Marks translate the chosen boundaries into locations within the requested column streams. ClickHouse reads those whole granule ranges, then applies the exact filter to discard the extra rows that rode along.

If there are many parts, this decision process repeats many times. If granules are large, more neighbor rows may ride along. If the query asks for many columns, more vertical data streams must be read.

The whole mental model: parts divide batches, granules divide row ranges, and marks make granule boundaries reachable inside each column.

Say the path in one breath

A query prunes partitions, searches every surviving part’s sparse key samples, converts candidate mark ranges into offsets for selected column streams, reads whole granules, then applies the exact predicate.

If you can explain where false positives enter that path, the storage model is working.

Primary sources for Module 02