Before you begin
Begin with a repeatable baseline and a hypothesis about the bottleneck. If you have not identified one yet, start with Diagnose slow queries and Isolate query bottlenecks. The examples in this guide use thenyc_taxi.trips_small_inferred table. To run them as written, create and load the table if you have not already done so:
Set up the example dataset
Set up the example dataset
The source Parquet file is approximately 5.8 GB. Loading it can take several minutes, depending on your network and available resources.
Choose an approach
Use the evidence you collected to choose where to begin. Prefer the least specialized change that addresses it:
If the evidence does not match one of these categories, return to the query plan rather than forcing the query into an approach.
Reduce the data read
- Use when: The query reads wide columns or columns it does not need.
- Change: Reduce the size or number of columns read by the query.
- Validate: Compare
read_bytes, memory use, and duration under the same conditions.
Review column types
Choose precise types Choose types that preserve the range and precision the workload requires without storing more data than necessary. Use numeric and date types rather than a general-purposeString for those values, and choose the smallest signed or unsigned numeric type that safely represents the expected range. For temporal columns, use Date or DateTime unless you need the wider range or fractional precision of Date32 or DateTime64.
Use nullable columns deliberately
A Nullable column stores a separate null mask in addition to its values, which ClickHouse must also read and process. Use it when the distinction between a null value and the type’s default value is meaningful. If a column is guaranteed to contain a value, a non-nullable type avoids that additional work.
Before changing a column, check the source data and ingestion path rather than assuming that observed non-null data will always remain non-null. The worked optimization example demonstrates how to identify columns that contain null values and measure the effect of changing the schema.
Use dictionary encoding for repeated values
LowCardinality uses dictionary encoding and is often effective for string columns such as status values, country codes, or other dimensions with substantially fewer distinct values than rows. Approximately 10,000 distinct values is a useful starting point for identifying candidates, not a fixed limit. Avoid identifiers and other mostly unique columns, and compare measurements before and after changing the type.
See Selecting data types for more detailed guidance.
Read only the required columns
Because ClickHouse stores data by column, selecting fewer columns directly reduces the data read. List the required columns instead of usingSELECT *, particularly for wide tables or queries that return only a small subset of each row.
Use read_bytes from system.query_log to compare the amount of data read before and after narrowing the selected columns. If read_bytes remains high, inspect the query plan for expressions, filters, joins, or nested queries that still require additional columns.
For example, if a dashboard needs only the pickup time, payment type, and total amount, select those columns rather than the complete row:
SELECT *. The number of returned rows is unchanged, but read_bytes should reflect the smaller set of columns read.
Align the data layout with the query
- Use when: A selective filter still reads many parts or granules.
- Change: Align the physical layout with the filters used by recurring queries.
- Validate: Compare the parts and granules selected by
EXPLAIN indexes = 1, then checkread_rows,read_bytes, and duration.
Start with the ordering key
For tables in theMergeTree family, the ordering key determines how rows are arranged on disk. By default, it also serves as the primary key that defines the sparse primary index. Unlike a primary key in an OLTP database, a ClickHouse primary key does not enforce uniqueness. Its performance benefit comes from allowing ClickHouse to skip granules that cannot satisfy a query’s filters.
Prioritize columns that appear frequently in selective filters, including their order in the key. Grouping related values can also improve compression. When a query’s grouping or sorting order aligns with the key, ClickHouse might use in-order optimizations for GROUP BY or ORDER BY.
Compare the parts and granules selected by EXPLAIN indexes = 1 before and after testing a different ordering key. Also compare read_rows, read_bytes, and duration under the same conditions. See Choosing a primary key for detailed selection guidance.
The example table uses ORDER BY (), so the following selective date filter has no ordering key that can eliminate granules:
On ClickHouse 25.9 and later, these settings ensure that
EXPLAIN reports the indexes used and the parts and granules they eliminate.pickup_datetime, then run the same EXPLAIN against it. The primary-key section of the plan should show fewer selected granules before you use duration or memory measurements to evaluate the overall change.
Evaluate additional indexing and data-layout options
If the ordering key cannot efficiently support an important access pattern, evaluate the more specialized options that follow. Partition for data management and pruning Partitioning is primarily a data-management mechanism for operations such as retention, movement, and deletion. It can reduce query work when filters allow ClickHouse to exclude complete partitions, but it should not be the first mechanism used to accelerate a query. For example, monthly partitions can support dropping complete months when retention is also managed by month. Consider partitioning only when the partition key aligns with data lifecycle requirements or a well-understood access pattern. Keep its cardinality low: a high-cardinality key creates many parts that cannot be merged across partitions and can degrade performance. UseEXPLAIN indexes = 1 to confirm that the query actually prunes partitions.
Add a data-skipping index for a localized filter
A data-skipping index stores metadata that lets ClickHouse avoid reading blocks that cannot match a filter. It is most useful when the ordering key does not support an important filter and matching values are sufficiently localized within blocks.
For example, a bloom filter index can help with equality lookups when most blocks do not contain the searched value. Use skipping indexes after reviewing data types and the ordering key. An index that rarely excludes a block adds storage and evaluation overhead without reducing much work. Test the index type and granularity with representative data, then use EXPLAIN indexes = 1 to compare selected granules and check read_rows, read_bytes, and duration.
Use projections selectively
Projections store alternative data layouts alongside a table. They can provide another ordering key or a precomputed result, and ClickHouse can select an applicable projection without requiring the query to reference it directly.
For example, a projection ordered by payment_type can support a recurring filter that the base table’s ordering does not. Use a small number of projections for important access patterns that the base ordering cannot serve efficiently.
Projections store additional index or column data and add work during insertion and merging; a full-column projection duplicates the columns that it stores. Heavy projection use can also increase the work required to choose an optimal projection at query time. For large deployments with many distinct access patterns, fewer projections or separate purpose-built tables are often easier to operate. See Materialized views versus projections when choosing between these mechanisms.
Add an alternative ordering for queries that filter by payment type and pickup time while continuing to query the source table:
EXPLAIN projections = 1 to confirm whether ClickHouse selects the projection and reads fewer rows or bytes. Also measure insertion and storage overhead before applying the pattern broadly.
Precompute repeatable work
- Use when: The same transformations or aggregations repeatedly dominate query time.
- Change: Move repeatable computation to ingestion, a scheduled refresh, or a purpose-built data layout.
- Validate: Confirm that the query reads a smaller result and performs less computation at query time, while ingestion or refresh work remains acceptable.
Each section includes a basic implementation, the main operational trade-off, and a way to validate the result.
Incremental materialized view
Use an incremental materialized view when a recurring filter, transformation, or aggregation must remain current as data arrives. It processes each newly inserted block and writes the transformed result to a target table. The trade-off is additional ingestion work and an explicit target table. For example, a dashboard that repeatedly counts trips by day can read from a small aggregate table instead of grouping the source data for every request:sum(trip_count) grouped by pickup_date so that rows awaiting a background merge are combined at query time. The view processes new inserts only, so backfill existing source data separately. Validate the change by comparing duration and rows read with the original aggregation, then confirm that the additional insertion work is acceptable.
Refreshable materialized view
Use a refreshable materialized view when slightly stale results are acceptable and the complete result can be recomputed at a practical interval. It reruns its query on a schedule. The trade-off is result freshness and the cost of each refresh. For example, a report can rebuild trip totals by payment type every hour:system.view_refreshes to confirm that refresh duration, status, and frequency suit the workload.
Purpose-built table
Use a purpose-built table when a separate workload needs a materially different schema, ordering key, or lifecycle. It provides explicit control over the physical design and can be clearer than maintaining many projections. The trade-off is additional storage and pipeline management. Repeated joins or transformations can also move into the ingestion pipeline when the source data and freshness requirements make that practical. See Use materialized views and Denormalizing data for detailed design guidance. For example, create a narrower table ordered for a dashboard that filters trips by payment type and pickup time:Nullable from those two target columns. Confirm that this treatment matches the workload’s data requirements. The dashboard must query this table explicitly, and the ingestion pipeline must keep it current. Validate the change by comparing rows and bytes read, memory use, and duration with the source-table query. Include the additional storage and pipeline maintenance in the decision.