Projections: hidden tables that speed up the base table
A projection stores the same data re-sorted or pre-aggregated inside the table's own parts, and ClickHouse picks it automatically. How to add, backfill, verify and when to prefer a materialized view.
A table has one primary key, so it is fast for one access pattern. A projection is an extra copy of the data, kept inside the same table, that is sorted differently or pre-aggregated. You query the table as usual; the optimizer decides, per query, whether a projection can answer it more cheaply than the base data.
A good mental model: a projection is a hidden table that lives in each data part of the table, is written and merged together with it, and is chosen automatically. You never name it in a query.
Two kinds
- A re-sorted copy:
SELECT * ORDER BY other_column(or a subset of columns). Speeds up filters on a column that is not at the front of the table’sORDER BY. - A pre-aggregation:
SELECT ... GROUP BY .... Speeds up repeated aggregations without a separate target table.
Re-sorted copy
The demo table is sorted by (event_type, event_time). A lookup by user_id cannot use that index:
CREATE TABLE events (event_time DateTime, user_id UInt64, event_type LowCardinality(String),
url String, duration_ms UInt32, country LowCardinality(String))
ENGINE = MergeTree ORDER BY (event_type, event_time);
INSERT INTO events
SELECT toDateTime('2026-03-01 00:00:00') + number, number % 5000,
['view','click','purchase'][1 + number % 3], concat('/p/', toString(number % 7)),
10 + (number * 7) % 500, ['DE','TR','US'][1 + number % 3]
FROM numbers(1000000);
EXPLAIN indexes = 1 SELECT count() FROM events WHERE user_id = 42;
ReadFromMergeTree (default.events)
Indexes:
PrimaryKey
Condition: true
Parts: 1/1
Granules: 123/123
Condition: true and 123/123 granules: the whole table is read. Add a projection sorted by the column:
ALTER TABLE events ADD PROJECTION by_user (SELECT * ORDER BY user_id);
ADD PROJECTION applies only to parts written from now on. For existing parts, materialize it (a mutation that rewrites the parts’ projection data):
ALTER TABLE events MATERIALIZE PROJECTION by_user SETTINGS mutations_sync = 2;
Now the same query:
ReadFromMergeTree (by_user)
Indexes:
PrimaryKey
Keys:
user_id
Condition: (user_id in [42, 42])
Parts: 1/1
Granules: 1/123
Search Algorithm: binary search
The plan now reads from by_user and touches 1 granule out of 123. That ReadFromMergeTree (projection_name) line is how you confirm a projection is used.
Pre-aggregation
ALTER TABLE events ADD PROJECTION daily_by_country (
SELECT toDate(event_time) AS day, country, count() AS c, sum(duration_ms) AS total_ms
GROUP BY day, country
);
ALTER TABLE events MATERIALIZE PROJECTION daily_by_country SETTINGS mutations_sync = 2;
EXPLAIN SELECT toDate(event_time) AS day, country, count() FROM events GROUP BY day, country;
The plan ends in ReadFromMergeTree (daily_by_country). A query that groups by the same (or a coarser) key and uses aggregates the projection can supply (count(), sum(duration_ms), and what can be derived from them) reads the tiny aggregated data instead of all rows. In the demo, the projection holds 36 rows for a million-row table:
SELECT name, rows, parent_name FROM system.projection_parts WHERE table = 'events' AND active;
┌─name─────────────┬────rows─┬─parent_name─┐
│ by_user │ 1000000 │ all_1_1_0_3 │
│ daily_by_country │ 36 │ all_1_1_0_3 │
└──────────────────┴─────────┴─────────────┘
Note the first projection stores all one million rows again, i.e. roughly doubles the stored data for those columns, while the aggregate one is tiny. Projections are not free.
How the optimizer chooses
- It considers a projection for a query when the projection can fully answer it: filters on the projection’s key, or the right grouping and aggregates.
- Among candidates (including the base table), it picks the one estimated to read the least data.
SET optimize_use_projections = 0turns the feature off for a query, useful for comparing timings.force_optimize_projection = 1makes a query fail if it cannot use a projection, handy in tests;force_optimize_projection_nameforces a particular one.EXPLAIN(as above) is the ground truth. Don’t assume.
Managing projections
ALTER TABLE events DROP PROJECTION by_user;
ALTER TABLE events CLEAR PROJECTION by_user; -- delete data, keep definition
SELECT * FROM system.projection_parts WHERE table = 'events' AND active;
Costs and limits
- Storage and write amplification. Every insert and every merge also writes the projection data. A projection with
SELECT *costs about as much as another copy of the table. - Merge semantics. Projections are built per part. With engines that rewrite rows during merges (
ReplacingMergeTree,CollapsingMergeTree), a projection can disagree with the base table, and some combinations are restricted or controlled by settings. Check the docs for your engine before using projections there. - Lightweight deletes and mutations. Updates and deletes interact with projections, and by default some of them are refused or need a setting. Check
lightweight_mutation_projection_mode. - No cross-table logic. A projection reads only its own table. There are no joins.
- Pre-aggregates are one grain. Different
GROUP BYshapes need different projections.
Projection or materialized view?
| Projection | Materialized view | |
|---|---|---|
| Lives | inside the table’s parts | in a separate target table |
| Queried | transparently, same table name | you query the target explicitly |
| Consistency | always in sync with the parts (same merges, same mutations) | updated per insert block; mutations on the source don’t reach it |
| Flexibility | one table, same engine | any engine, joins (left-side trigger), different TTL, different retention |
| Keeps raw data out | no, the base table stays | yes, you can drop or TTL the source |
Choose a projection when you want “the same table, but fast for another filter or a fixed aggregation,” and you want ClickHouse to keep it correct without any pipeline. Choose a materialized view when the aggregate needs its own engine, TTL or retention, or a join, or when the raw table should expire.
If your only goal is to skip data for a selective filter on another column, also consider a data-skipping index: it costs far less storage than a full re-sorted projection.
Reference: projections in the docs.
Discussion
Comments live in GitHub Discussions. Sign in with GitHub to join in.