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?”
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.
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.
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.
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;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.
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.
Useful and unused fields share the pages being scanned.
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.
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.
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.
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.
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.
SELECT country, sum(revenue) FROM events WHERE day = today() GROUP BY country
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.
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.
Pick a scenario, make the architecture call, then compare your reason with the workload.
Create orders, reserve inventory, and guarantee that two concurrent requests cannot spend the same balance.
“The benchmark says it is fast.” Fast at what? With which schema, query distribution, data volume, concurrency, freshness target, hardware, and operational constraints?
“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.”
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.
OLTP vs OLAP, row vs column, and honest workload fit.
Build the physical picture of a MergeTree table on disk.
Why inserts create parts and background merges are unavoidable.
Model values so storage and CPU have less work.
Sorting keys, sparse primary indexes, locality, and pruning.
Batches, async inserts, part pressure, and streaming pipelines.
Vectorization, parallelism, operators, memory, and partial states.
ClickHouse idioms, arrays, windows, approximate functions, and states.
Denormalization, join algorithms, direct lookups, and tradeoffs.
Move work from query time to ingest time—with eyes open.
Mutations, lightweight deletes, ReplacingMergeTree, and FINAL.
Shards, replicas, coordination, and separated compute/storage.
System tables, query logs, EXPLAIN, profiles, and failure modes.
Design and defend a real-time analytics system end to end.
The goal is not to remember “ClickHouse is columnar.” The goal is to be able to derive why columnar follows from the workload.
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.
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.
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.
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.
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.
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.
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.
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.
ORDER BY keyLarge parts normally use separate column streams. Reading service and latency can leave unrelated column files alone.
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.
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.
These are four nested levels. The names are confusing because everyday English makes “partition” and “part” sound identical. In ClickHouse they are not.
events tableSuppose 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.
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.
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.
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.
“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.
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.
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.
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.
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.
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.
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.
(billing, 08)”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.
Ignore the file jargon for one minute. Make three moves and predict the highlighted boxes before looking.
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.
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
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.
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.
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.
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.
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.
SELECT * when it is unnecessary.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.
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.
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.
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.
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.
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.
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.
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.
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.