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:
Set up the example dataset
CREATE DATABASE IF NOT EXISTS nyc_taxi;
USE nyc_taxi;
CREATE TABLE nyc_taxi.trips_small_inferred
ORDER BY () EMPTY
AS SELECT *
FROM s3(
'https://datasets-documentation.s3.eu-west-3.amazonaws.com/nyc-taxi/clickhouse-academy/nyc_taxi_2009-2010.parquet',
NOSIGN,
Parquet
);
INSERT INTO nyc_taxi.trips_small_inferred
SELECT *
FROM s3(
'https://datasets-documentation.s3.eu-west-3.amazonaws.com/nyc-taxi/clickhouse-academy/nyc_taxi_2009-2010.parquet',
NOSIGN,
Parquet
);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:
- 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.
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:
SET enable_filesystem_cache = 0;
SET use_query_cache = 0;
SET use_query_condition_cache = 0;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:
WITH
dateDiff('s', pickup_datetime, dropoff_datetime) AS trip_time,
(trip_distance / trip_time) * 3600 AS speed_mph
SELECT quantiles(0.5, 0.75, 0.9, 0.99)(trip_distance)
FROM nyc_taxi.trips_small_inferred
WHERE speed_mph > 30
FORMAT JSON;Aggregate trips in a date range
This query calculates ride counts, distance, and average payment amounts for the first quarter of 2009:
SELECT
payment_type,
count() AS trip_count,
formatReadableQuantity(sum(trip_distance)) AS total_distance,
avg(total_amount) AS total_amount_avg,
avg(tip_amount) AS tip_amount_avg
FROM nyc_taxi.trips_small_inferred
WHERE pickup_datetime >= '2009-01-01'
AND pickup_datetime < '2009-04-01'
GROUP BY payment_type
ORDER BY trip_count DESC;Filter by passenger count
This query calculates the average trip duration for trips with one or two passengers:
SELECT avg(dateDiff('s', pickup_datetime, dropoff_datetime))
FROM nyc_taxi.trips_small_inferred
WHERE passenger_count = 1 OR passenger_count = 2
FORMAT JSON;The original measurements were:
| Workload | Duration | Rows read | Peak memory |
|---|---|---|---|
| Calculated-speed filter | 1.699 sec | 329.04 million | 440.24 MiB |
| Date-range aggregation | 1.419 sec | 329.04 million | 546.75 MiB |
| Passenger-count filter | 1.414 sec | 329.04 million | 451.53 MiB |
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:
SELECT
countIf(vendor_id IS NULL) AS vendor_id_nulls,
countIf(pickup_datetime IS NULL) AS pickup_datetime_nulls,
countIf(dropoff_datetime IS NULL) AS dropoff_datetime_nulls,
countIf(passenger_count IS NULL) AS passenger_count_nulls,
countIf(trip_distance IS NULL) AS trip_distance_nulls,
countIf(ratecode_id IS NULL) AS ratecode_id_nulls,
countIf(fare_amount IS NULL) AS fare_amount_nulls,
countIf(extra IS NULL) AS extra_nulls,
countIf(mta_tax IS NULL) AS mta_tax_nulls,
countIf(tip_amount IS NULL) AS tip_amount_nulls,
countIf(tolls_amount IS NULL) AS tolls_amount_nulls,
countIf(total_amount IS NULL) AS total_amount_nulls,
countIf(payment_type IS NULL) AS payment_type_nulls,
countIf(pickup_location_id IS NULL) AS pickup_location_id_nulls,
countIf(dropoff_location_id IS NULL) AS dropoff_location_id_nulls
FROM nyc_taxi.trips_small_inferred
FORMAT VERTICAL;Row 1:
──────
vendor_id_nulls: 0
pickup_datetime_nulls: 0
dropoff_datetime_nulls: 0
passenger_count_nulls: 0
trip_distance_nulls: 0
ratecode_id_nulls: 167200929
fare_amount_nulls: 0
extra_nulls: 0
mta_tax_nulls: 137946731
tip_amount_nulls: 0
tolls_amount_nulls: 0
total_amount_nulls: 0
payment_type_nulls: 69305
pickup_location_id_nulls: 0
dropoff_location_id_nulls: 0Only 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:
SELECT
uniq(ratecode_id),
uniq(pickup_location_id),
uniq(dropoff_location_id),
uniq(vendor_id)
FROM nyc_taxi.trips_small_inferred
FORMAT VERTICAL;Row 1:
──────
uniq(ratecode_id): 6
uniq(pickup_location_id): 260
uniq(dropoff_location_id): 260
uniq(vendor_id): 3These 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:
SELECT
min(payment_type),
max(payment_type),
min(passenger_count),
max(passenger_count)
FROM nyc_taxi.trips_small_inferred; ┌─min(payment_type)─┬─max(payment_type)─┬─min(passenger_count)─┬─max(passenger_count)─┐
1. │ 1 │ 4 │ 0 │ 255 │
└───────────────────┴───────────────────┴──────────────────────┴──────────────────────┘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:
CREATE TABLE nyc_taxi.trips_small_no_pk
(
vendor_id LowCardinality(String),
pickup_datetime DateTime('UTC'),
dropoff_datetime DateTime('UTC'),
passenger_count UInt8,
trip_distance Float32,
ratecode_id LowCardinality(Nullable(String)),
pickup_location_id LowCardinality(String),
dropoff_location_id LowCardinality(String),
payment_type Nullable(UInt8),
fare_amount Decimal32(2),
extra Decimal32(2),
mta_tax Nullable(Decimal32(2)),
tip_amount Decimal32(2),
tolls_amount Decimal32(2),
total_amount Decimal32(2)
)
ENGINE = MergeTree
ORDER BY tuple();
INSERT INTO nyc_taxi.trips_small_no_pk
SELECT *
FROM nyc_taxi.trips_small_inferred;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:
| Workload | Inferred schema | Optimized schema | Rows read | Optimized peak memory |
|---|---|---|---|---|
| Calculated-speed filter | 1.699 sec | 1.353 sec | 329.04 million | 337.12 MiB |
| Date-range aggregation | 1.419 sec | 1.171 sec | 329.04 million | 531.09 MiB |
| Passenger-count filter | 1.414 sec | 1.188 sec | 329.04 million | 265.05 MiB |
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:
SELECT
table,
formatReadableSize(sum(data_compressed_bytes)) AS compressed,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed,
sum(rows) AS rows
FROM system.parts
WHERE active = 1
AND database = 'nyc_taxi'
AND table IN ('trips_small_inferred', 'trips_small_no_pk')
GROUP BY database, table
ORDER BY sum(data_compressed_bytes) DESC; ┌─table────────────────┬─compressed─┬─uncompressed─┬──────rows─┐
1. │ trips_small_inferred │ 7.38 GiB │ 37.41 GiB │ 329044175 │
2. │ trips_small_no_pk │ 4.89 GiB │ 15.31 GiB │ 329044175 │
└──────────────────────┴────────────┴──────────────┴───────────┘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:
CREATE TABLE nyc_taxi.trips_small_pk
(
vendor_id LowCardinality(String),
pickup_datetime DateTime('UTC'),
dropoff_datetime DateTime('UTC'),
passenger_count UInt8,
trip_distance Float32,
ratecode_id LowCardinality(Nullable(String)),
pickup_location_id LowCardinality(String),
dropoff_location_id LowCardinality(String),
payment_type Nullable(UInt8),
fare_amount Decimal32(2),
extra Decimal32(2),
mta_tax Nullable(Decimal32(2)),
tip_amount Decimal32(2),
tolls_amount Decimal32(2),
total_amount Decimal32(2)
)
ENGINE = MergeTree
ORDER BY (passenger_count, pickup_datetime, dropoff_datetime);
INSERT INTO nyc_taxi.trips_small_pk
SELECT *
FROM nyc_taxi.trips_small_no_pk;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:
| Workload | Measurement | Inferred schema | Optimized schema | Optimized schema and ordering key |
|---|---|---|---|---|
| Calculated-speed filter | Duration | 1.699 sec | 1.353 sec | 0.765 sec |
| Rows read | 329.04 million | 329.04 million | 329.04 million | |
| Peak memory | 440.24 MiB | 337.12 MiB | 444.19 MiB | |
| Date-range aggregation | Duration | 1.419 sec | 1.171 sec | 0.248 sec |
| Rows read | 329.04 million | 329.04 million | 41.46 million | |
| Peak memory | 546.75 MiB | 531.09 MiB | 173.50 MiB | |
| Passenger-count filter | Duration | 1.414 sec | 1.188 sec | 0.431 sec |
| Rows read | 329.04 million | 329.04 million | 276.99 million | |
| Peak memory | 451.53 MiB | 265.05 MiB | 197.38 MiB |
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:
EXPLAIN indexes = 1
SELECT
payment_type,
count() AS trip_count,
formatReadableQuantity(sum(trip_distance)) AS total_distance,
avg(total_amount) AS total_amount_avg,
avg(tip_amount) AS tip_amount_avg
FROM nyc_taxi.trips_small_pk
WHERE pickup_datetime >= '2009-01-01'
AND pickup_datetime < '2009-04-01'
GROUP BY payment_type
ORDER BY trip_count DESC
SETTINGS
use_query_condition_cache = 0,
use_skip_indexes_on_data_read = 0;ReadFromMergeTree (nyc_taxi.trips_small_pk)
Indexes:
PrimaryKey
Keys:
pickup_datetime
Condition: and((pickup_datetime in (-Inf, 1238543999]), (pickup_datetime in [1230768000, +Inf)))
Parts: 9/9
Granules: 5061/40167The 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:
- 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.
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.