BigQuery's on-demand pricing charges by bytes scanned, not rows returned. A SELECT that filters to one day but scans the whole table still pays for the whole table. Partitioning and clustering fix that by letting BigQuery skip data it doesn't need. Here is how.
Partition on the Column You Filter By
Partitioning splits a table into segments by a column โ usually a date. A query filtered on that column reads only the matching partitions:
CREATE TABLE analytics.events ( event_time TIMESTAMP, user_id STRING, action STRING ) PARTITION BY DATE(event_time);
Now this query scans one day, not the whole history:
SELECT action, COUNT(*) FROM analytics.events WHERE DATE(event_time) = '2026-04-28' -- prunes to one partition GROUP BY action;
The filter must be on the partition column for pruning to happen. Filtering on a different column scans everything.
Cluster on Columns You Filter or Group By
Clustering sorts data within each partition by up to four columns, so BigQuery can skip blocks that can't match:
CREATE TABLE analytics.events (...) PARTITION BY DATE(event_time) CLUSTER BY user_id, action;
A query filtering on user_id now reads only the relevant blocks inside each partition โ a second layer of savings on top of partitioning.
Verify the Savings Before You Run
BigQuery tells you the scan size before you spend a cent. Use the dry-run estimate:
bq query --dry_run --use_legacy_sql=false \ 'SELECT * FROM analytics.events WHERE DATE(event_time) = "2026-04-28"' # Prints bytes that would be processed โ check it dropped
If the estimate didn't shrink, your filter isn't hitting the partition column.
Summary
BigQuery costs scale with bytes scanned, so the goal is to scan less. Partition on the date column you filter by, cluster on the columns you filter or group by, and always confirm with a dry run that pruning actually happened. On large tables this turns full-table scans into targeted reads โ and your bill follows the bytes down.