Blog

Databend Cluster Key Best Practices: Choosing Columns, Key Order, and Granularity

avatarzhyassAug 25, 2026
Databend Cluster Key Best Practices: Choosing Columns, Key Order, and Granularity

This is Part 2 of the Databend Cluster Key series, written for data engineers who are familiar with SQL predicates and basic OLAP query analysis.

  • Part 1 — How pruning works: From traditional partitions to micro-partitions, and how Snowflake and Databend reduce data scanning.

  • Part 2 — Best practices (this post): How to decide whether a table needs clustering, then choose columns, key order, and granularity from a real workload.

Part 1 explains why Cluster Keys can reduce data scanning. This post addresses the harder operational question: given a large production table, which expressions belong in

CLUSTER BY
, in what order, and how can you prove that the design works?

The Baseline: A Two-Day Query Scans 62.9% of All Blocks

The experiment uses

test.hits
, a wide table representative of web analytics workloads:

  • 100 million event rows;

  • 105 columns;

  • approximately 10.6 GiB after compression;

  • 17 distinct dates in July 2013;

  • no explicit Cluster Key in the baseline layout;

  • 9,044 Blocks across 10 Segments.

For this experiment, the baseline explicitly sets

ROW_PER_BLOCK = 10000
, below the default target of one million rows. The smaller block size creates enough Blocks at this data volume to make differences in pruning behavior easier to observe. Every candidate layout uses the same setting.

Start with a two-day range query:

SELECT count(*)
FROM hits
WHERE eventdate >= '2013-07-02'
AND eventdate < '2013-07-04';

The predicate matches approximately 13.54 million rows, but

EXPLAIN ANALYZE
reports:

segments: <range pruning: 10 to 10>
blocks: <range pruning: 9044 to 5693>
read rows: 63,526,217

These numbers tell us four things:

  1. Segment Range Pruning eliminates no Segments.

  2. Block Range Pruning reduces 9,044 Blocks to 5,693.

  3. The query still scans 62.9% of all Blocks.

  4. Databend reads roughly 4.7 times as many rows as the query actually matches.

The SQL predicate is not the problem. The physical layout is. Many Blocks have

EventDate
Min/Max ranges that overlap the two-day window, so Databend cannot prove that those Blocks contain no matching rows.

Now consider a query closer to a real analytics workload: finding the most popular URLs for a specific counter over the same period.

SELECT
url,
count(*) AS page_views
FROM hits
WHERE counterid = 7525
AND eventdate >= '2013-07-02'
AND eventdate < '2013-07-04'
AND dontcounthits = 0
AND isrefresh = 0
AND url <> ''
GROUP BY url
ORDER BY page_views DESC
LIMIT 10;

The baseline pruning path is:

segments: <range pruning: 10 to 10>
blocks: <range pruning: 9044 to 5667,
bloom pruning: 5667 to 243>
read rows: 2,862,793
read size: 30.80 MiB

Two complementary mechanisms are at work:

  • Range Pruning uses Block Min/Max statistics to eliminate value ranges that cannot match.

  • Bloom Pruning further eliminates Blocks that cannot contain values used in equality predicates.

A Cluster Key primarily improves Range Pruning. By reorganizing rows, it narrows the Min/Max interval of each Block, allowing more Blocks to be rejected before they are read.

High scan ratios indicate room for improvement, but they do not prove that clustering will pay for itself. The first decision is whether this table is worth clustering at all.

Step 1: Decide Whether the Table Is Worth Clustering

A large row count alone does not justify a Cluster Key. Initial data reorganization and ongoing maintenance consume compute, I/O, and temporary storage. The cumulative query-side savings must outweigh those costs.

A table is more likely to benefit when most of the following are true:

  • It is large enough to contain many Blocks that could potentially be pruned.

  • Frequent queries read only a small subset of the table.

  • Existing Range and Bloom Pruning still leave a large scan footprint.

  • The most expensive queries share one or two stable filter dimensions.

  • Those queries run often enough for per-query savings to accumulate.

  • Ingestion order and update patterns will not make clustering prohibitively expensive to maintain.

The incremental benefit is usually limited when:

  • The table has only a small number of Blocks.

  • Frequent queries need most of the data anyway.

  • Data already arrives in the order used by the main filters.

  • Expensive queries have no stable, shared access path.

  • Queries that could benefit run infrequently.

  • Backfills, out-of-order ingestion, or frequent DML continually disrupt the layout.

One subtle but important point is that a table without an explicit Cluster Key is not necessarily unordered. If data arrives in time order and most queries filter by time, existing Block ranges may already be compact. Explicit clustering may then deliver little additional pruning while still imposing maintenance work.

Evaluate the economics over a representative period rather than looking at one faster query:

query frequency × scan reduction per query
>
initial reorganization + ongoing clustering cost

In the

hits
baseline, a two-day query still scans 62.9% of Blocks, while the important queries share stable time and business dimensions. That is enough evidence to continue with candidate designs.

Step 2: Choose Candidate Columns from the Workload

Wide tables contain many business-critical columns, but business importance does not imply pruning value. Candidate expressions should come from query history, not from browsing the schema and guessing.

A practical sequence is:

  1. Identify parameterized query families with high execution frequency and cumulative scan volume.

  2. Extract the predicates those queries consistently share.

  3. Evaluate the selectivity and access pattern of each predicate.

  4. Create a small number of candidate Cluster Key expressions.

  5. Validate them with controlled experiments.

For

hits
, the initial candidates fall into four access patterns:

Access patternExample columnsQuestion to answer
Range filters
EventDate
,
EventTime
Does the granularity match typical query windows?
Business ranges
CounterID
,
RegionID
Do they cover the most expensive query families?
High-cardinality equality filters
UserID
,
WatchID
,
URLHash
Are point lookups common, and do queries also provide the leading key columns?
Low-cardinality dimensions
IsRefresh
,
DontCountHits
Can the frequently queried value eliminate enough data?

The main queries in this workload filter on

CounterID
and a time range, so
CounterID
,
EventDate
, and
EventTime
become the first candidates.

Low cardinality does not automatically make a good key

In this dataset,

IsRefresh = 0
matches approximately 93.33 million rows, or 93.3% of the table. Even though the predicate appears frequently, clustering around that common value would not eliminate much data.

The relevant question is not whether a column is popular, important, or low-cardinality. It is:

When a common value or range is queried, can this expression help Databend eliminate most Blocks?

Frequency, business relevance, and cardinality are useful screening signals. None of them replaces selectivity analysis and scan validation.

Step 3: Match Time Granularity to the Query Window

Time is one of the most common Cluster Key candidates and one of the easiest to choose by intuition alone. A raw timestamp, hour, day, week, and month create very different physical organizations. Finer is not always better, and coarser is not automatically cheaper.

Start by estimating the cardinality of candidate expressions:

SELECT
min(eventdate) AS min_date,
max(eventdate) AS max_date,
approx_count_distinct(eventdate) AS approx_days,
approx_count_distinct(to_start_of_hour(eventtime)) AS approx_hours,
approx_count_distinct(counterid) AS approx_counters,
approx_count_distinct(userid) AS approx_users
FROM test.hits;

The dataset has the following distribution:

Date range: 2013-07-02 to 2013-07-31
Distinct dates: 17
Distinct hours: approximately 406
Distinct CounterIDs: approximately 6,477
Distinct UserIDs: approximately 17.97 million

Because the table covers only one month, this expression is almost constant in the experiment:

CLUSTER BY (to_start_of_month(eventtime))

It provides almost no additional partitioning. More plausible candidates are:

CLUSTER BY (eventtime)
CLUSTER BY (to_start_of_hour(eventtime))
CLUSTER BY (eventdate)

Each favors a different access pattern:

  • Raw

    EventTime
    preserves the finest time order and has the highest cardinality.

  • to_start_of_hour(eventtime)
    better matches minute-to-hour analysis.

  • EventDate
    is a natural fit for one-day to multi-day range queries.

The important queries in this experiment span one to several days, so the candidate tests use

EventDate
. This is a workload-specific choice, not a universal recommendation.

At minimum, time granularity should account for:

typical query window
+ number of Blocks covered by each time bucket
+ whether data naturally arrives in time order
+ frequency and size of historical backfills

Expressions must preserve the ordering you want to optimize

Functions such as

to_start_of_hour
,
to_date
, and
to_start_of_month
preserve temporal order and therefore work well for range organization.

The same principle applies to strings. If the meaningful order is in the prefix,

substr(column, 1, N)
may produce a useful range expression. Extracting characters from the middle or end usually destroys the leading order and makes continuous ranges harder to form.

Step 4: Test Composite Key Order Under Controlled Conditions

Once both

EventDate
and
CounterID
have demonstrated potential value, the next question is which one should lead.

Databend organizes a multi-column Cluster Key lexicographically. These definitions contain the same columns but optimize different access paths:

-- Build global date ranges first, then subdivide by CounterID.
CLUSTER BY (eventdate, counterid)
-- Build global CounterID ranges first, then subdivide by date.
CLUSTER BY (counterid, eventdate)

Experimental setup

The same 100 million rows were organized into three layouts:

LayoutCluster Key
BaselineNo explicit Cluster Key
Date first
(eventdate, counterid)
Counter first
(counterid, eventdate)

All tables use

ROW_PER_BLOCK = 10000
. The tests ran on Databend
v1.2.932-nightly
, using a local filesystem with Databend Data Cache disabled.

The purpose is to compare changes in pruning and scan volume caused by physical layout. Wall-clock time is too sensitive to hardware, concurrency, cache state, and background tasks to serve as the primary selection criterion here.

After loading and reorganizing the data, the three layouts contain 9,044, 9,261, and 9,258 Blocks, respectively. Reorganization changes Block and Segment boundaries, so the comparison includes pruning at each level, final Block ratios, and actual read volume rather than relying on absolute Block counts alone.

Query 1: Date filter only

LayoutSegment Range PruningBlock Range PruningBlocks remainingRead Rows
Baseline10 → 109,044 → 5,69362.9%63,526,217
(eventdate, counterid)
8 → 22,234 → 1,26013.6%13,608,944
(counterid, eventdate)
9 → 88,245 → 1,81819.6%19,873,404

The date-first layout retains fewer Blocks for a date-only query, as expected from the leading column.

Query 2: Counter filter only

SELECT count(*)
FROM hits
WHERE counterid = 7525;
LayoutSegment Range PruningBlock Range PruningBloom PruningFinal Block ratioRead Rows
Baseline10 → 109,044 → 8,9318,931 → 4595.1%5,269,162
(eventdate, counterid)
8 → 78,144 → 210210 → 840.9%948,139
(counterid, eventdate)
9 → 11,013 → 7979 → 750.8%843,147

The counter-first layout is stronger during Range Pruning. Once Bloom Pruning runs, however, the two candidates retain 75 and 84 Blocks, narrowing the final difference substantially.

Query 3: Counter and date filters together

LayoutSegment Range PruningBlock Range PruningBloom PruningFinal Block ratioRead RowsRead Size
Baseline10 → 109,044 → 5,6675,667 → 2432.7%2,862,79330.80 MiB
(eventdate, counterid)
8 → 11,117 → 6464 → 380.4%426,9142.66 MiB
(counterid, eventdate)
9 → 11,013 → 4848 → 440.5%490,5852.79 MiB

When both predicates are present, the two candidate layouts retain 38 and 44 Blocks and produce similar scan volumes.

Why this workload chooses date first

The three queries reveal the leading-column effect:

  • Date first provides a clear advantage for date-only queries.

  • Counter first has stronger Range Pruning for counter-only queries.

  • Bloom Pruning reduces the final difference between the two layouts for equality filters.

  • Both layouts perform similarly when queries provide both predicates.

Given the relative importance of these query families, the selected layout is:

CLUSTER BY (eventdate, counterid)

The reason is not that time should always come first. Date first provides a better compromise for this workload: it improves date-only scans more, retains the benefit of Bloom Pruning for counter-only queries, and scans slightly less in the combined query.

A reasonable starting hypothesis is to place a commonly used range column before a high-cardinality business key, provided both have demonstrated pruning value. If precise high-cardinality lookups dominate the workload, test the reverse order instead of applying that pattern mechanically.

Step 5: Handle High-Cardinality IDs and Long Strings Carefully

High cardinality is not an automatic rejection criterion. Maintenance cost depends on how values are generated, how data arrives, and whether queries usually constrain the leading key columns.

Monotonic and random IDs behave differently

  • Monotonically increasing or time-ordered IDs tend to append near the end of an existing range and are easier to keep locally ordered.

  • Random UUIDs, trace IDs, and hashes continually land in historical ranges. This increases overlap between old and new Blocks and creates more Recluster work.

Do not place a random, high-cardinality column at the front of a Cluster Key only because equality filters are common. Test it when full-history point lookups are a core, frequent workload and Range Pruning adds enough value beyond Bloom Pruning to justify the maintenance cost.

Later columns depend on leading predicates

The

hits
table contains approximately 17.97 million distinct
UserID
values. To isolate the effect of a third key column, another test uses:

CLUSTER BY (counterid, eventdate, userid)

Within

CounterID = 62
and
EventDate = '2013-07-15'
, there are still approximately 63,000 distinct UserIDs.

Query predicatesSegment Range PruningBlock Range PruningBloom PruningFinal BlocksFinal Block ratio
CounterID + EventDate
9 → 11,014 → 7272 → 70700.8%
CounterID + EventDate + UserID
9 → 11,014 → 1919 → 11<0.1%
UserID
only
9 → 99,212 → 2,7982,798 → 16160.2%

When a query supplies the first two key columns, Databend first narrows the scan to a continuous CounterID and date range. Adding

UserID
then reduces the candidate range further, and Bloom Pruning leaves only one Block.

With

UserID
alone, Segment Range Pruning eliminates nothing. Most of the remaining work is done by ordinary Block Min/Max statistics and Bloom Pruning.

This is the key lesson:

A composite Cluster Key is not a set of independent indexes. The value of a later high-cardinality column depends heavily on whether queries also constrain its leading columns.

Distinguish string Cluster Key statistics from ordinary column statistics

Long strings such as

URL
and
Referer
require one more distinction:

ObjectDatabend defaultExplicit adjustment
String Cluster KeyCluster Statistics use the first 8 bytes
CLUSTER BY (substr(column, 1, N))
Ordinary string Column StatisticsBased on 16 characters and may adapt to 32 when a shared prefix exists
VARCHAR STATS_TRUNCATE_LEN N

Approximate prefix cardinalities for

hits.URL
are:

ExpressionApproximate cardinality
substr(url, 1, 8)
66
substr(url, 1, 16)
58,332
substr(url, 1, 32)
2,115,680
Full
url
18,388,647

Using

CLUSTER BY (url)
is valid SQL, but the current Cluster Statistics behavior uses only the first 8 bytes. In this dataset, those eight bytes produce a cardinality of just 66, so many distinct URLs fall into the same Cluster Key range.

If the workload's filtering semantics align with a prefix, test an explicit expression:

CLUSTER BY (substr(url, 1, N))

The first 32 characters produce an approximate cardinality of 2,115,680 in this dataset, but

N = 32
is an experimental candidate, not a fixed recommendation. Validate both pruning and maintenance cost with representative queries.

Changing

STATS_TRUNCATE_LEN
does not change the default eight-byte behavior of a string Cluster Key. To change the Cluster Key expression, specify
substr
explicitly in
CLUSTER BY
.

Step 6: Validate Layout, Scanning, and Maintenance Cost

Cluster Key validation has at least two technical layers and one economic layer. Query latency alone is noisy, while

average_depth
alone says nothing about how much a particular query reads.

Inspect physical organization with
clustering_information

SELECT
cluster_key,
info:total_block_count,
info:constant_block_count,
info:average_overlaps,
info:average_depth,
info:p95_depth,
info:p99_depth
FROM clustering_information(
'database_name',
'table_name'
);

These metrics help answer:

  • How many Blocks exist?

  • How many have reached a Constant state?

  • How many Block ranges overlap on average?

  • Across how many overlapping ranges does the same key value appear?

  • Does high Depth come from disordered data or from hot values distributed across multiple Constant Blocks?

The experiment produces the following results:

LayoutBlocksConstant BlocksConstant ratioAverage OverlapsAverage DepthP95 / P99 Depth
Baseline, evaluated as
(eventdate, counterid)
9,04470.1%9,042.04849,022.55979,023 / 9,023
(eventdate, counterid)
, after Recluster
9,2617,08776.5%168.1164164.8168565 / 565
(counterid, eventdate)
, after Recluster
9,2587,12777.0%168.7695164.0238566 / 566
(counterid, eventdate, userid)
, after Recluster
9,2121<0.1%21.402111.923812 / 12

Three cautions matter when interpreting these values.

High Depth can reveal an improvable, disordered layout. In the baseline evaluated against

(eventdate, counterid)
, only seven Blocks are Constant, while
average_depth
is close to the total Block count. Many key ranges are broad and overlap heavily. After Recluster, both two-column candidates reduce
average_depth
to approximately 164–165, showing that range overlap has contracted substantially.

Remaining Depth can come from hot values that cannot be split further. In both two-column layouts, approximately 76.5%–77.0% of Blocks are Constant: the complete Cluster Key has the same Min and Max within each Block. If one hot key genuinely fills several size-limited Blocks, those identical point ranges still count toward overlap and Depth. They are already a terminal Recluster state; rewriting them again cannot remove the overlap caused by the actual data distribution.

Depth is not directly comparable across different key definitions. The three-column layout has an

average_depth
of only 11.9238, much lower than either two-column layout. Adding high-cardinality
UserID
, however, splits previous hot point ranges and changes the dimensional space in which Depth is calculated. A lower number does not prove that the three-column key is better. The query measurements above show that
UserID
consistently improves Range Pruning only when the leading predicates are present.

clustering_information
describes how data ranges are organized. It does not predict exactly how much any one query will scan.

Measure real scans with
EXPLAIN ANALYZE

Read the pruning pipeline in this order:

Segments before and after Segment Range Pruning

Blocks before and after Block Range Pruning

Blocks before and after Bloom Pruning, when applicable

Read Rows and Read Size

This sequence shows which layer produced the improvement and prevents Bloom Pruning gains from being incorrectly attributed to the Cluster Key.

Defining or changing a Cluster Key does not automatically reorder every historical Block. Before evaluating historical data, reorganize it according to the maintenance strategy used by your deployment:

ALTER TABLE hits
CLUSTER BY (counterid, eventdate);

When running explicit Recluster operations, prefer limiting the operation to the affected range:

ALTER TABLE hits RECLUSTER
WHERE eventdate >= '2013-07-14'
AND eventdate < '2013-07-16';

RECLUSTER FINAL
may run longer and rewrite more data. It is useful in a controlled experiment, but it should not be the unconditional default for large production tables.

Put maintenance cost on the same balance sheet

Reorganization consumes compute, I/O, and temporary storage. Out-of-order ingestion, historical backfills, and frequent

UPDATE
,
DELETE
, or
MERGE
operations can increase range overlap again.

Evaluate a complete business period and ask:

  • How much cumulative

    Read Rows
    and
    Read Size
    did the important queries avoid?

  • Did query frequency amplify those savings enough to matter?

  • How much compute did automatic or explicit clustering consume?

  • Do new writes continually degrade the layout?

  • Can maintenance target only the affected time range?

The goal is not to rewrite the entire table on a fixed schedule. Recluster when layout degradation begins to affect important queries, and restore sufficient pruning at a controlled cost.

Derive the Cluster Key from the Workload

There is no universal best column order for a Cluster Key. What transfers across workloads is the decision process:

Find the query families with the highest cumulative scan cost

Extract selective predicates they consistently share

Match time granularity to typical query windows

Build candidate layouts from leading predicates and ingestion order

Run controlled tests with identical physical settings

Use clustering_information to inspect data organization

Use EXPLAIN ANALYZE to measure actual scans

Confirm that long-term query savings exceed maintenance cost

Five principles summarize the method:

  1. A frequently filtered column does not necessarily have pruning value.

  2. Neither low nor high cardinality is a sufficient selection criterion on its own.

  3. The leading columns of a composite key define its primary physical ranges.

  4. Lower Depth does not necessarily mean lower scan cost.

  5. Adding more Cluster Key columns does not necessarily improve the layout.

The objective is not to make

average_depth
look impressive or to include every filter column in the Cluster Key. It is to ensure that the most important queries consistently read only the Blocks that may contain matching rows—without allowing maintenance cost to erase the benefit.

The experiment ran on Databend

v1.2.932-nightly
. This article also references official documentation available in August 2026. Clustering behavior, string statistics, and
EXPLAIN ANALYZE
output may change across versions; validate against the documentation and the behavior of the version you deploy.

Share this post

Subscribe to our newsletter

Stay informed on feature releases, product roadmap, support, and cloud offerings!