Skip to main content
This guide applies two optimization approaches to the NYC Taxi dataset. First, it reduces the amount of data stored and processed by choosing more precise column types. It then introduces an ordering key that allows ClickHouse to skip data for selective queries. Each change is measured against the same baseline. See the query optimization overview for the broader workflow that this example follows.

Before you begin

The examples use the nyc_taxi.trips_small_inferred table. Create and load it if you have not already done so:
The source Parquet file is approximately 5.8 GB. Loading it can take several minutes, depending on your network and available resources.
The source Parquet file contains approximately 329 million rows. The timings in this guide were recorded on one deployment and will vary with available compute resources. Compare the relative change between stages rather than expecting identical durations. When applying this method to your own workload, use Diagnose slow queries to identify a recurring query pattern and choose a representative run before changing the query or schema.

Process overview

The example uses the following three stages:
  1. Run three independent workload queries against the inferred schema to establish a baseline.
  2. Create a table with more precise column types, load the same data, and rerun the queries.
  3. Create another table with the same optimized schema and an ordering key, then rerun the queries again.
Changing the schema and ordering key in separate stages makes their effects easier to distinguish. Optimization approaches explains when to consider these changes and how to validate them. For more guidance on collecting comparable measurements, see Isolate query bottlenecks.

Define the baseline workload

In the same client session used to run the workload, disable the filesystem cache for remote data, the query cache, and the query-condition cache:
These settings help make repeated runs comparable while testing. Restore their previous values after completing the measurements.
The following three independent queries form the baseline workload. Run all three against each table created in the following stages. Execute each query several times under comparable conditions and record a representative duration, such as the median, along with rows read and peak memory usage. See Establish a repeatable baseline for the complete measurement workflow, including how to retrieve these values from system.query_log.

Filter on calculated trip speed

This query calculates trip duration and speed before finding the distribution of trip distances for rides faster than 30 miles per hour:

Aggregate trips in a date range

This query calculates ride counts, distance, and average payment amounts for the first quarter of 2009:

Filter by passenger count

This query calculates the average trip duration for trips with one or two passengers:
The original measurements were: All three queries read approximately 329 million rows, which is close to the number of rows in the table. This establishes the opportunity to improve two different aspects of the workload: make the selected columns cheaper to process, then reduce the number of rows selected when the filters permit it.

Optimize the schema

Schema inference is a practical way to begin exploring a dataset, but the inferred types can be wider or more permissive than the workload requires. Inspect the data before changing the schema rather than assuming that an inferred type is unnecessary.

Avoid unnecessary Nullable columns

A Nullable column stores a null mask in addition to its values. Keep Nullable when the distinction between a null value and the type’s default value is meaningful, but avoid it for columns that are guaranteed to contain a value. Count null values in the columns used by the example schema:
Only ratecode_id, mta_tax, and payment_type contain null values in this dataset. The optimized schema retains Nullable for those columns and removes it from the others.

Use LowCardinality for repeated values

LowCardinality uses dictionary encoding and can reduce storage and processing for columns with many repeated values. Check the number of distinct values before applying it:
These four columns contain substantially fewer distinct values than rows. They are reasonable candidates for LowCardinality, although the effect should still be measured for the workload. Approximately 10,000 distinct values is a useful starting point for identifying candidates, not a fixed limit.

Choose more precise data types

Use the narrowest type that safely preserves the required range and precision. For example, inspect the minimum and maximum values of numeric columns before replacing an inferred Int64 or Float64:
Both integer columns fit in UInt8, although passenger_count reaches its maximum value of 255. The example also uses Float32 for trip_distance and Decimal32 for monetary values. All values in this dataset fit the target ranges, and the example accepts the reduced floating-point precision and cent-scale monetary precision because its workload compares aggregate results. Retain the wider source types when exact source values are required. The example replaces the inferred DateTime64 columns with DateTime in the same UTC timezone because the example queries do not require fractional-second precision. These choices are specific to this dataset. Confirm the range, precision, and nullability requirements of production data before applying the same changes.

Apply the schema changes

Create a table without an ordering key so that this stage measures the schema changes independently:
In each workload query, replace nyc_taxi.trips_small_inferred with nyc_taxi.trips_small_no_pk, then rerun all three queries. The original example recorded the following representative results: The queries still read the same number of rows, but the optimized schema reduces the amount of data represented by those rows. Query duration and peak memory therefore improve without changing data selection. Compare the on-disk size of the two tables:
For this dataset, the optimized schema reduces compressed storage by approximately 34%, from 7.38 GiB to 4.89 GiB.

Optimize the ordering key

In the MergeTree family, the ordering key determines how rows are arranged on disk. ClickHouse builds a sparse primary index over that order so that it can skip granules that cannot satisfy a query’s filters. Unlike a primary key in many transactional databases, it does not enforce uniqueness. The ordering key should reflect the filters used by important recurring queries. Column order matters: a key is most effective when the query filters on a useful prefix. Lower-cardinality columns sometimes make effective leading entries when they are commonly filtered, and a time component is often useful for time-based workloads. For detailed selection guidance, see Choosing a primary key. For this example, use (passenger_count, pickup_datetime, dropoff_datetime). passenger_count has few distinct values and appears in the passenger-count filter, while pickup_datetime appears in the date-range aggregation. Although pickup_datetime is not the first column, ClickHouse can still use values from later key columns to exclude data when the leading column is unconstrained. Filtering on a useful prefix of the ordering key generally provides stronger pruning.

Apply the ordering-key change

Create a table with the same optimized schema used in the previous stage. Change only the ordering key:
In each workload query, replace the table name with nyc_taxi.trips_small_pk, then rerun all three queries.

Compare the results

The original guide recorded the following measurements across the three stages: The schema optimization reduces storage and makes the selected values cheaper to process. The ordering key provides the largest additional improvement for the date-range aggregation because ClickHouse can skip granules outside its date range. The passenger-count filter also reads fewer rows because it filters on the first key column. The calculated-speed filter still reads the entire table because its filter is derived from pickup_datetime, dropoff_datetime, and trip_distance rather than a useful prefix of the ordering key. Inspect the date-range aggregation with EXPLAIN indexes = 1:
On ClickHouse 25.9 and later, these settings ensure that EXPLAIN reports the indexes used and the parts and granules they eliminate.
The primary index selects 5,061 of 40,167 granules. That reduction corresponds with the date-range aggregation processing 41.46 million rows instead of the full 329.04 million.

Apply the method to your workload

Use the same sequence for your own workload:
  1. Record baseline duration, rows and bytes read, and peak memory.
  2. Inspect whether selected columns use unnecessarily wide or permissive types.
  3. Apply and measure schema changes without changing the data layout.
  4. Test an ordering key based on the filters used by important recurring queries.
  5. Compare data selected with EXPLAIN indexes = 1, then rerun the baseline queries under comparable conditions.
Do not assume that the types or ordering key from this example will suit another dataset. Use the observed values and query filters to make those decisions.

Next steps

Return to Optimization approaches to evaluate projections, materialized views, data-skipping indexes, or precomputation when schema and ordering-key changes do not address the measured bottleneck.
Last modified on August 27, 2026