Partitioning and Clustering in BigQuery: Why You Usually Want Both
Partitioning cuts BigQuery costs for date-filtered queries, but selective filters on tenant or event type need clustering. Here's how to combine both, with a worked example and the trade-offs.
15 Jul 2026, 17:21 UTC

Your events table just crossed 500 million rows, and the finance team noticed. Every dashboard refresh scans the whole thing, and on on-demand pricing each of those scans has a real dollar figure attached. The fix most teams reach for is partitioning — and it helps — but partitioning alone leaves a surprising amount of money on the table. The practical answer for large append-heavy event tables is usually partitioning and clustering together, because they solve different problems.
Partitioning prunes by time; it does nothing for your other filters
BigQuery lets you partition a table by ingestion time or by a DATE/TIMESTAMP column. When a query includes a filter on the partition column, BigQuery only scans the matching partitions, and since on-demand pricing bills by bytes scanned, that directly cuts cost.
But think about the queries that actually run against an events table. Plenty of them filter by date — and plenty more filter by tenant_id, event_type, or user_id. A date-range filter on a partitioned table still scans every row within those partitions, even if you only care about one tenant out of ten thousand. Partitioning has no mechanism to help there.
Clustering fills the gap inside each partition
Clustering sorts data within partitions by up to four columns you choose. BigQuery organizes storage into blocks, and when a query filters on clustered columns, it can skip blocks that can't contain matching rows. The result: fewer bytes scanned and lower latency for selective filters on high-cardinality columns.
This is why the two features are complements, not alternatives. Partitioning prunes along the time axis; clustering prunes along your access-pattern axis. A query filtering "tenant 42, yesterday, click events" benefits from both at once.
Two properties make clustering low-effort to adopt:
- It's free. BigQuery re-clusters data in the background automatically as new data arrives. There's no maintenance job to run.
- It's best-effort. Block elimination isn't guaranteed — BigQuery doesn't promise exact pruning, so treat the savings as empirical, not contractual. Measure them.
A worked example
Say you have an append-only events stream. Create the table partitioned by event date and clustered by tenant and event type:
CREATE TABLE analytics.events
PARTITION BY event_date
CLUSTER BY tenant_id, event_type
OPTIONS(require_partition_filter = true)
AS SELECT * FROM staging.raw_events;Run this in the BigQuery console or via bq query; you need bigquery.tables.create permission on the dataset. Note the require_partition_filter option — more on that below.
Now compare two queries against a table with a year of data:
-- Scans only one day's partition, then skips blocks
-- that don't match the clustered columns
SELECT COUNT(*)
FROM analytics.events
WHERE event_date = DATE '2026-09-29'
AND tenant_id = 't-0042'
AND event_type = 'click';Without partitioning and clustering, this query scans the full table. With them, it scans one partition out of ~365, and within that partition only the blocks that could contain tenant t-0042 click events. To verify the difference yourself, run a dry run (in the console, or bq query --dry_run) and compare the "bytes processed" estimate against an unpartitioned copy of the same data. You can also inspect actual bytes billed per query via the INFORMATION_SCHEMA.JOBS view.
Column order in the CLUSTER BY clause should match your filter patterns: put the column most commonly used in equality filters first. Changing clustering columns later requires rewriting the table, so it's worth a quick review of your top queries before committing.
The trade-offs worth knowing upfront
Partition savings require cooperation from every query. If a dashboard or analyst writes a query with no date predicate, it scans everything, partition filter or not. The require_partition_filter = true table option turns that silent cost into an explicit error, forcing the filter. It's a blunt but effective guardrail — just confirm your BI tools inject a date filter before enabling it, or dashboards will break.
Clustering doesn't help small tables. Block skipping only pays off when each partition holds enough data for block-level pruning to matter. On small tables the gains are negligible — not harmful, just not a reason to add complexity.
Don't over-partition. Hourly partitions on low-volume data produce thousands of tiny partitions, which increases metadata overhead and can slow queries down rather than speed them up. Daily partitioning is the right default for most event workloads; go finer only when per-day volume justifies it. BigQuery also caps the number of partitions per table — check the current documentation for the exact limit before designing around it.
Verify, don't assume. Because clustering is best-effort, validate your expected savings: create clustered and unclustered copies of a representative slice of data, run the same filtered query against both, and compare bytes processed. If your real queries don't filter on the clustered columns, clustering buys you nothing.
What to do Monday morning
Pick your largest event table, partition it by the date column your queries already filter on, and cluster by the one or two columns that appear most often in selective WHERE clauses. Turn on require_partition_filter once your downstream queries are ready. Then dry-run your five most expensive queries before and after, and let the bytes-processed numbers — not intuition — tell you whether it worked.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.