Before you begin
The examples use thenyc_taxi.trips_small_inferred table. Create and load it 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.
Process overview
The example uses the following three stages:- Run three independent workload queries against the inferred schema to establish a baseline.
- Create a table with more precise column types, load the same data, and rerun the queries.
- Create another table with the same optimized schema and an ordering key, then rerun the queries again.
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.
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:
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
ANullable 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:
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:
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 inferredInt64 or Float64:
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: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:
Optimize the ordering key
In theMergeTree 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: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.Apply the method to your workload
Use the same sequence for your own workload:- Record baseline duration, rows and bytes read, and peak memory.
- Inspect whether selected columns use unnecessarily wide or permissive types.
- Apply and measure schema changes without changing the data layout.
- Test an ordering key based on the filters used by important recurring queries.
- Compare data selected with
EXPLAIN indexes = 1, then rerun the baseline queries under comparable conditions.