skip to content

When does adding a ClickHouse projection speed up a query, and what does it cost?

level: seniorimportance: nice to knowfreq 34%

answer

  1. one table can only have one sort order
  2. the extra copy lives inside the same parts
  3. existing parts are not touched until you say so
  4. every insert and merge now does more work
  5. a setting can turn a silent fallback into an error

basics

~20 s

A ClickHouse projection stores an alternative sorted or pre-aggregated copy of a table's data inside the same parts, and the optimizer reads it instead of the base data when a query matches it. It costs storage, insert and merge work, and only helps queries whose shape it fits.

solid answer

~50 s

A projection is defined with `ALTER TABLE ... ADD PROJECTION`, and it materialises a second copy of the part's data in a different sort order (a *normal* projection) or pre-aggregated by a `GROUP BY` (an *aggregate* projection). Because the copy lives inside the same parts, it stays consistent with the base data automatically — unlike a separate rollup table you have to keep in sync. At query time the optimizer picks it if the query's filters and aggregation match; `optimize_use_projections` controls that, and `force_optimize_projection` makes a query fail rather than silently fall back, which is how you verify it is being used. The costs are real: every insert and every background merge must build the projection too, so write throughput drops and storage grows, and adding a projection does not touch existing parts until you run `ALTER TABLE ... MATERIALIZE PROJECTION`. It pays when one high-value query shape does not fit the table's single primary sort order.

code

sql · 17 lines
sql
-- table is sorted for time-series access
CREATE TABLE hits (
  event_date Date,
  event_time DateTime,
  domain LowCardinality(String),
  revenue Float64
) ENGINE = MergeTree
ORDER BY (event_date, event_time);

-- but the dashboard groups by domain and day
ALTER TABLE hits ADD PROJECTION p_domain_daily (
  SELECT domain, event_date, count(), sum(revenue)
  GROUP BY domain, event_date
);

-- existing parts do not have it until this runs
ALTER TABLE hits MATERIALIZE PROJECTION p_domain_daily;

go deeper

for a junior

Know that a ClickHouse projection is an extra copy of a table's data kept in another order or pre-aggregated, defined with ALTER TABLE ADD PROJECTION and used automatically when it fits.

for a middle

Explain the two kinds, why MATERIALIZE PROJECTION is needed for existing parts, and how the optimizer's silent fallback per part can hide a projection that is doing nothing.

for a senior

Weigh the trade honestly: measure read_rows before and after, then measure the ingest and merge cost you bought it with, and be able to justify a projection over a plain rollup table or a materialized view.

for a principal

Decide the pattern for the platform: how many query shapes a single table should serve, whether rollups belong inside the table or as independent artefacts with their own retention, and what write-throughput budget projections are allowed to consume.

## The problem projections solve A MergeTree table has exactly one physical sort order, given by its `ORDER BY`. Queries that filter or group along that order are fast; queries that need a different order scan far more data. The classic bind is a table sorted by `(event_date, user_id)` that also serves a dashboard filtering by `domain` — the second query cannot prune, and you cannot give the table two sort orders. A **projection** is ClickHouse's answer: an additional, automatically maintained copy of the data stored *inside the same parts*, in its own order or pre-aggregated. ## The two kinds **Normal projection** — the same rows in a different sort order: ```sql ALTER TABLE hits ADD PROJECTION p_by_domain ( SELECT * ORDER BY domain, event_time ); ``` Queries filtering on `domain` can now prune within the projection instead of scanning the base data. **Aggregate projection** — a pre-aggregated rollup: ```sql ALTER TABLE hits ADD PROJECTION p_domain_daily ( SELECT domain, event_date, count(), sum(revenue) GROUP BY domain, event_date ); ``` A matching query reads the small rollup instead of aggregating raw rows. Either way you must materialise it for data that already exists: ```sql ALTER TABLE hits MATERIALIZE PROJECTION p_domain_daily; ``` Without that step, only newly written parts carry the projection, and queries fall back to the base data for older parts. That fallback is per part and silent, which surprises people. ## How selection works The optimizer decides, per query and per part, whether a projection can answer it — the projection must contain the columns the query needs, and its ordering or grouping must fit the query's filters and aggregation. `optimize_use_projections` enables this consideration. Because the fallback is silent, verification matters: run the query with `force_optimize_projection = 1` and it will raise an error if no projection was used, or compare `read_rows` in `system.query_log` before and after — a working aggregate projection collapses that number by orders of magnitude. ## What it costs **Write amplification.** Every insert builds the projection's data alongside the base data, and every background merge merges the projections too. On an ingest-heavy table this is a measurable throughput cost, and it multiplies with each projection you add. **Storage.** A normal projection is effectively a second copy of the columns it names — compressed, but a copy. An aggregate projection is usually small, which is why it is the better-value shape. **Merge pressure.** More data per part means longer merges and more I/O in the background pool, which competes with query execution on the same machine. **Maintenance surface.** Projections are part of the table definition, so schema evolution has to account for them. ## Projection or materialized view? They overlap, and the distinction is the interview's real target. A ClickHouse **materialized view** is a trigger on insert that writes into a *separate* target table; you query that table explicitly, and it sees only the rows that flowed through the insert path. A **projection** lives inside the source table's parts, is chosen by the optimizer transparently, and is kept consistent by the same merge machinery as the base data — including for data inserted before it existed, once materialised. Rule of thumb: choose a projection when you want one table to answer several query shapes without the application knowing; choose a separate rollup table when you want an independently sized, independently retained artefact that other systems read directly. ## When it is the wrong tool - When the query is already fast because it matches the table's sort order — you are paying writes for nothing. - When the workload is ad-hoc and unpredictable: a projection helps a *shape*, and an unpredictable shape has none. - When ingest throughput is the binding constraint. Adding projections to a table at its write ceiling makes the ceiling lower. - When a plain rollup table maintained by the pipeline is simpler to reason about and retain separately. ## The practical routine Identify the one or two expensive query shapes from `system.query_log` — sort by `read_bytes`, not duration. Add the narrowest projection that serves them, materialise it, and measure the same queries' `read_rows` again. Then measure ingest throughput and part sizes to see what you paid. If either number disappoints, drop it: `ALTER TABLE ... DROP PROJECTION` is cheap, and an unused projection is pure write tax.

  • You add a projection to a large existing ClickHouse table and see no speedup. What did you forget?
    `ALTER TABLE ... MATERIALIZE PROJECTION`. Adding a projection only changes the table definition, so newly written parts include it while every existing part does not. Queries fall back to the base data for those parts, silently. Materialising rewrites the existing parts to include the projection, which is background work proportional to the table size.
  • How is a projection different from a ClickHouse materialized view?
    A materialized view is an insert trigger writing into a separate target table that you query by name, and it only ever sees rows that pass through the insert path. A projection lives inside the source table's own parts, is selected transparently by the optimizer, and — once materialised — covers historical data too. Projections keep one table serving many shapes; views produce an independent artefact.
  • What is the main reason not to add three or four projections to an ingest-heavy table?
    Write amplification. Each projection is built on every insert and rebuilt during every background merge, so ingest throughput falls and merge I/O rises with each one added. On a table already near its write ceiling, projections lower the ceiling. Measure ingest and merge behaviour after adding one, not just query latency.

It is like keeping a second, differently sorted index card set inside the same filing box: whoever opens the box can use whichever set fits their question, but every new document now has to be filed twice.

saying these in an interview costs you the question

  • Expects an added projection to cover existing parts immediately
  • Confuses a projection with a materialized view's separate target table
  • Thinks projections are free because they are inside the table
  • Adds projections for ad-hoc queries with no stable shape
  • Assumes the optimizer errors out when no projection matches

context