Clustering sorts data within storage blocks based on up to four columns. This enables block-level pruning — BigQuery maintains metadata about value ranges in each block and skips blocks that can’t contain matching rows. It’s a more flexible optimization than partitioning, with fewer constraints and a wider set of use cases.
The key difference from partitioning: clustering doesn’t create physical divisions in your table. Where partitioning gives you hard boundaries (a partition either gets scanned or it doesn’t), clustering provides probabilistic optimization. BigQuery examines block metadata and skips blocks where the value range doesn’t overlap with your filter. The more sorted your data, the more blocks can be skipped.
How It Works Under the Hood
BigQuery uses an LSM tree-like approach to maintain clustering. This connects directly to the storage and compute separation in BigQuery’s architecture.
When new data arrives — through streaming inserts, batch loads, or DML operations — it lands in locally sorted “delta” blocks. These blocks are sorted according to your clustering columns, but they exist independently of the existing data. A background process continuously merges these delta blocks into fully sorted “baseline” blocks.
The critical detail: automatic re-clustering happens at no cost. No slot consumption, no charges. BigQuery handles this as a background maintenance operation. You don’t need to run OPTIMIZE or schedule any maintenance jobs. This is a significant advantage over systems like Databricks Delta Lake, where OPTIMIZE is an explicit operation that consumes compute.
This means you can add clustering to a table and forget about it. The data stays sorted automatically as new rows arrive, and the optimization improves over time as the background process merges more blocks.
Column Order Matters Critically
BigQuery sorts data hierarchically by your clustering columns in order. This is the single most important thing to understand about clustering — and the source of the most common mistakes.
Queries only benefit from clustering when filtering on a prefix of the clustering columns. If you cluster by [region, customer_id, product_id]:
- Filter on
regionalone —> optimized (first column in the prefix) - Filter on
regionANDcustomer_id—> optimized (first two columns) - Filter on
regionANDcustomer_idANDproduct_id—> fully optimized (all three) - Filter on
customer_idalone, skippingregion—> not optimized - Filter on
product_idalone —> not optimized
This prefix requirement comes from how hierarchical sorting works. Data is sorted by region first, then within each region by customer_id, then within each customer_id by product_id. Searching by customer_id without region is like looking for a word in a dictionary sorted by language first — you’d need to check every language section.
Getting the Order Right
Place your most frequently filtered column first. Don’t guess — check your actual query patterns:
-- Find which columns appear most often in WHERE clausesSELECT referenced_tables.table_id, REGEXP_EXTRACT_ALL(query, r'WHERE.*?(\w+)\s*[=<>]') AS filter_columns, COUNT(*) AS query_countFROM `region-us`.INFORMATION_SCHEMA.JOBS,UNNEST(referenced_tables) AS referenced_tablesWHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY) AND job_type = 'QUERY'GROUP BY 1, 2ORDER BY query_count DESC;In practice, the right order often follows natural drill-down patterns in your domain. For event data, it’s usually event_name then user_id. For multi-tenant SaaS, it’s tenant_id first. For marketing data, it follows the campaign hierarchy: campaign_id, then ad_group_id, then ad_id.
The Four-Column Limit
You can cluster by at most four columns. This is rarely a constraint in practice — most tables have 1-3 dominant filter patterns. If you find yourself wanting more than four clustering columns, it’s worth questioning whether the table is trying to serve too many use cases. Consider splitting into purpose-specific materialized views or marts.
Clustering vs. Partitioning: When to Choose Clustering
Clustering solves problems that partitioning can’t:
Multiple filter columns with no single dominant one. If queries filter on user_id, product_id, region, and status in various combinations, clustering handles this. Partitioning requires choosing one column.
High-cardinality columns. Columns like user_id or session_id have millions of distinct values. Partitioning would hit the 10,000 partition limit immediately. Clustering handles high cardinality gracefully because it works at the block level, not the partition level.
Tables too small for meaningful partitions. Tables in the 64MB to 10GB range benefit from block pruning but don’t have enough data to justify the metadata overhead of partitions. See the decision framework for exact size thresholds.
Partitioning would create tiny partitions. If your daily data volume is 100MB, daily partitions are wasteful — each partition is too small for BigQuery to optimize, and the metadata overhead accumulates. Clustering provides scan reduction without the partition overhead.
What Clustering Doesn’t Give You
Understanding what clustering lacks compared to partitioning helps you choose the right tool:
No predictable pre-execution cost estimates. Clustering benefits don’t show up in dry-run estimates. A dry run might estimate 50 GB for a query, but the actual scan is 5 GB thanks to cluster pruning. You can’t know the real cost until the query runs. If cost predictability before execution matters to your team, partitioning provides that.
No lifecycle management. You can’t expire clustered data automatically, delete ranges efficiently, or write to specific segments without touching others. These capabilities require partitioning.
No insert_overwrite support. The insert_overwrite strategy in dbt requires partitions. If you only cluster (no partitioning), you’re limited to merge or append strategies for incremental models.
No filter enforcement. There’s no equivalent of require_partition_filter for clustering. You can’t prevent analysts from running queries that don’t filter on clustering columns.
Clustering with dbt
In dbt, clustering is configured with the cluster_by parameter:
{{ config( materialized='table', cluster_by=['user_id', 'event_name']) }}Or combined with partitioning for large tables:
{{ config( materialized='incremental', partition_by={ 'field': 'event_date', 'data_type': 'date', 'granularity': 'day' }, cluster_by=['event_name', 'user_pseudo_id', 'traffic_source']) }}When using the merge incremental strategy, clustering significantly improves merge performance by reducing the rows BigQuery must compare. Cluster on your unique_key columns when using merge — this ensures the MERGE join can leverage block pruning on the destination table.
Measuring Clustering Effectiveness
Since dry runs don’t reflect clustering benefits, you need to check actual query performance. Compare total_bytes_billed against the table’s total size for representative queries:
SELECT job_id, total_bytes_billed, total_bytes_processed, ROUND(total_bytes_billed / POW(1024, 3), 2) AS gb_billedFROM `region-us`.INFORMATION_SCHEMA.JOBSWHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 DAY) AND referenced_tables IS NOT NULLORDER BY total_bytes_billed DESCLIMIT 20;If you’re scanning more than 5-10% of a large clustered table on queries with proper filters, the clustering isn’t effective. Check that your queries filter on a prefix of the clustering columns, not just any clustering column. See BigQuery Partition Pruning Patterns for the complete measurement approach.
On a well-designed table with proper partition + cluster configuration, you might scan 10-100 GB out of a 10 TB table. That’s 100-1000x less data billed, translating directly into lower costs under on-demand pricing.