Partitioning and Clustering, Explained With One Marketing Table (one table, the whole idea)

ยท prices every query layout before praising it๐Ÿ—‚๏ธ with a $13,500-vs-$14 receipt, not a lecture

Published September 10, 2026

The 60-second version

  • Layout is cost: a 7-day question against a 3 TB table scans 3 TB unpartitioned, about 26 GB partitioned, about 3 GB clustered.
  • Partition by the date every query filters on; cluster by channel, campaign and event name, most-filtered first.
  • Pruning dies silently โ€” functions on the partition column, filters on the wrong date, a forgotten predicate. Require the filter.
bottom line: layout decides the bill, not the question

Your marketing events table is 3 TB. Your "last week's ROAS by campaign" query scans all 3 TB to answer a question about 40 GB. It runs for two minutes, costs $19, and will run again tomorrow, and the day after, forever.

Partitioning and clustering stop exactly this. They are not performance magic โ€” they are layout instructions telling BigQuery which parts of the table to skip. layout is cost, queries are just visitors One marketing table teaches both, because every marketing query filters on date first and something else second.

The mental model: BigQuery bills for bytes scanned, not rows returned. A partitioned table lets BigQuery skip whole date ranges without reading them; clustering sorts data inside each partition so filters on campaign, channel or event type skip most of what remains. Layout is cost.


Partition by date, skip the rest

Partitioning splits a table into independent segments โ€” almost always by day โ€” so a query with a date filter opens only the segments it needs. Everything else is never read, never billed, never waited for. Without partitioning, every query is a full scan by definition, no matter how narrow its WHERE clause.

For marketing data the partition column is nearly always the event or report date, because every marketing question is a date-range question: last 7 days, this month, quarter to date. Partition by the column your WHERE clause names most โ€” event_date on events, spend_date on spend, report_date on rollups. One line of DDL changes the cost profile of every future query:

-- Partition by the date column every query filters on.
-- BigQuery stores each day separately and prunes the rest at plan time.
CREATE TABLE analytics.marketing_events (
  event_date  DATE,
  event_name  STRING,
  campaign_id STRING,
  channel     STRING,
  user_id     STRING,
  revenue     NUMERIC,
  cost        NUMERIC
)
PARTITION BY event_date
OPTIONS (
  require_partition_filter = TRUE
);

require_partition_filter = TRUE deserves emphasis: BigQuery rejects any query that forgets the date filter, with a clear error instead of a full-table bill. The cheapest insurance in the warehouse โ€” it belongs on every large marketing table from day one.

What Partitioning Changes

Data Journey
Stage 1Full scan
Unpartitioned

A 7-day query opens all 800 days of history and discards 99% of what it read. You pay for the discarding.

3 TB scanned
Stage 2Pruned
Partitioned by date

The same query opens only 7 daily segments. The other 793 are skipped before any bytes move.

26 GB scanned
Stage 3Guardrailed
Partitioned + required filter

Queries without a date predicate fail at plan time with an error โ€” instead of running and billing.

0 accidents

The cost math is linear. At on-demand rates each terabyte scanned costs roughly $6.25: a 3 TB dashboard query costs ~$19 per refresh, partitioned to 7 days it scans ~26 GB at ~$0.16. Run hourly and the unpartitioned version costs ~$13,500 a month against ~$118 partitioned. Same question, same answer, two orders of magnitude apart โ€” decided by layout.


Clustering: sort inside each day

Partitioning answers the date question. But the query also filters on campaign_id = 'X' or channel = 'meta' โ€” and one day's partition can still hold 50 GB. Clustering sorts rows inside each partition by up to four columns, so BigQuery skips blocks that cannot contain matching rows.

Think of a partition as a drawer labelled with a date, clustering as the dividers inside it. Without dividers you read every folder; with them you open only the M section. The DDL adds one clause:

-- Cluster on the columns your WHERE and JOIN clauses name after the date:
-- channel, campaign, event type. Order matters: most-filtered first.
CREATE TABLE analytics.marketing_events (
  event_date  DATE,
  event_name  STRING,
  campaign_id STRING,
  channel     STRING,
  user_id     STRING,
  revenue     NUMERIC,
  cost        NUMERIC
)
PARTITION BY event_date
CLUSTER BY channel, campaign_id, event_name
OPTIONS (
  require_partition_filter = TRUE
);

Three rules for cluster columns:

Filter and join keys

Cluster on what you filter and join on, not what you group by. Clustering accelerates row selection, not aggregation โ€” usually channel, campaign_id, event_name.

Low cardinality first

Channel with a dozen values prunes better leading than user_id with millions. Order columns by filter frequency, broader values first.

Two or three columns

Each extra column dilutes the earlier ones. BigQuery re-sorts on write, so there is no maintenance โ€” only the initial choice, which costs nothing extra.

Clustering is free storage-wise and self-maintaining. BigQuery re-clusters in the background as data arrives. The only cost is choosing columns once โ€” the wrong choice costs nothing extra, it just prunes less. Partitioning is the big win; clustering multiplies it, typically another 3โ€“10x.

One Table, Three Layouts, Same Query

Before / after
No layout3.0 TB scannedFull table every time, ~$18.75 per run
Partitioned by date26 GB scanned7 daily segments of 800, ~$0.16 per run
+ Clustered3.1 GB scannedOne channel within those 7 days, ~$0.02 per run
Monthly, hourly refresh$13,500 vs $14Same dashboard, same answer, layout as the variable
Partitioning delivers the first 100x by skipping dates. Clustering adds ~8x by skipping campaigns and channels inside those dates. Neither changes the answer โ€” only what was read.
LIVE ยท DRAG ITPartition your own bill
3
800
7
24

Hourly is 24 runs a day

Unpartitioned, per month
$13,500
Partitioned, per month
$118.13
LAYOUT PAYS 100X

A narrow question against deep history is exactly where partitioning earns its keep. Require the filter so it stays that way.

live mathsestimates are fine โ€” this is a what-if sandbox

What defeats pruning

Partitioning works only if BigQuery can see at plan time which partitions your filter needs. Several innocent patterns hide that information and silently re-enable the full scan. Each has a trivial fix.

Functions on the partition column. WHERE DATE_TRUNC(event_date, MONTH) = DATE '2026-08-01' forces evaluation on every partition โ€” worst case, BigQuery gives up and reads all of them. Compare raw ranges instead: WHERE event_date >= DATE '2026-08-01' AND event_date < DATE '2026-09-01'. Same rows, prunable predicate.

Filtering on a different date than the partition key. Partitioned by event_date but filtering on created_at? The partitioner sees no constraint it understands and scans everything. The WHERE clause must name the partition column directly. When ingestion and event time differ, partition by the one you query โ€” usually event time.

Joins that hide the filter. A filter on a dimension table's date does not prune the fact table. With events e JOIN campaigns c ... WHERE c.launch_date > X, the events side still scans fully. Push an explicit event_date predicate onto every large table in the query, even when a join feels constraining. To the pruner, it is not.

Wildcard and suffix mistakes on sharded tables. On GA4-style date-sharded tables, wrapping _TABLE_SUFFIX in date functions โ€” or forgetting the suffix filter โ€” reopens full history. Keep a plain string comparison: _TABLE_SUFFIX BETWEEN '20260801' AND '20260807'. It prunes before any data is touched.

Keep pruning intact the pruner is literal, help it โ†’

  • Name the partition column raw โ€” event_date in the WHERE clause with no wrapping function. Range comparisons prune; expressions may not.
  • Put the predicate on the big table โ€” dimension-table filters do not prune the fact table. Every large table needs its own date predicate.
  • Require the filter structurally โ€” require_partition_filter turns a forgotten predicate into a plan-time error instead of a bill.
  • Check bytes before running โ€” the console validator and dry run show bytes to be scanned. A figure equalling table size means pruning failed.

Before and after: same query

The same question โ€” last week's Meta spend and revenue by campaign โ€” against the same 3 TB table, before and after layout. The question is fixed; only organisation and filter discipline change.

Before: Full Scan, No Pruning
After: Partitioned, Clustered, Pruned
BeforeAfter
LayoutNo partition, no clusterPartition by event_date, cluster by channel, campaign_id, event_name
FilterChannel and event onlyPlus event_date range (required by table options)
Bytes scanned~3,000 GB~3.1 GB
Cost per run~$18.75~$0.02
Hourly dashboard, monthly~$13,500~$14
AnswerCorrectIdentical

The table is the whole argument: layout is a ~1000x cost variable that changes no answers. It stays cheap permanently โ€” every future analyst, dashboard, and AI-generated query inherits the pruning, provided the filter stays required and cluster columns match how people filter.


Frequently asked questions

Should I partition by ingestion time or event time?

Arrival time ยท nobody's question

๐Ÿ“ฅ Ingestion time

Answers when the row arrived. Marketers ask about the day the click happened, not the day the row landed โ€” arrival-time partitions prune for nobody.

  • Prunes nothing analysts filter
  • Late rows land in wrong days
  • Debugs loads, not business
Event time ยท every query's filter

๐Ÿ“… Event time

Partition by the date queries filter on. Handle late-arriving data with a two-day reprocessing window, not arrival-time partitions.

  • Matches every WHERE clause
  • Late data handled by reprocessing
  • The date the business means

How many cluster columns should I use?

Two or three, ordered by filter frequency, lower cardinality first. CLUSTER BY channel, campaign_id, event_name covers nearly all marketing filter patterns. A fourth column rarely adds pruning and weakens the first three. Never lead with a unique id โ€” it sorts beautifully and prunes nothing.

Does clustering help joins?

Yes, when the join key is a cluster column. Joining spend to events on campaign_id reads far less of the events side if clustered by it, because matching rows sit in adjacent blocks. Campaign and channel belong in the cluster key partly because they are join keys across marketing tables.

What about small tables โ€” should I partition everything?

No. Below ~1 GB, partitioning adds metadata overhead without meaningful pruning โ€” scanning 200 MB costs a fraction of a cent. Partition the events, spend, and orders tables dominating your bytes scanned; leave dimensions and lookups alone.

Can I partition and cluster an existing table?

Yes, by rewriting, not altering โ€” partitioning cannot be added in place on a populated table. Create the new partitioned table with the desired keys, copy data with SELECT, verify counts and sample aggregates, then swap dashboards. Over a terabyte, copy one month at a time to stay small and resumable. copy monthly, verify, then swap

Your partitioned table still scans everything after you add WHERE DATE_TRUNC(event_date, MONTH) = DATE '2026-08-01'. Why?


The summary

  • An unpartitioned marketing table makes every query a full scan: a 7-day question reads 800 days and bills for all of it.
  • Partitioning by the date queries filter on skips whole days before reading anything โ€” typically the first 100x saving.
  • Clustering by channel, campaign, and event name sorts rows within each partition so secondary filters skip most of what the date range kept โ€” typically another 3โ€“10x.
  • Pruning dies when the filter wraps the partition column in a function, names a different date, lives only on a joined table, or is forgotten โ€” require_partition_filter makes forgetting impossible.
  • The same query went from ~3 TB and ~$19 per run to ~3 GB and ~$0.02, with an identical answer โ€” layout is cost.

Takeaways for your next report

  • An unpartitioned marketing table makes every query a full scan โ€” a 7-day question reads 800 days and bills for all of it.
  • Partition by the date queries filter on (typically ~100x), then cluster by channel, campaign and event name (another ~3โ€“10x).
  • Pruning dies when the filter wraps the partition column, names a different date, or lives only on a joined table โ€” require the filter.
  • The same query fell from ~3 TB and ~$19 per run to ~3 GB and ~$0.02 with an identical answer: layout is cost.
stick this on your Monday report
Free tool

BigQuery Cost Estimator

Estimate what a BigQuery query will cost before you run it, from validator bytes, with a table-scan simulator and a paste-your-query cost audit.

Chinmay Raibagkar

Chinmay Raibagkar

About author โ†’

Founder of DataLens AI. He helps non-technical teams read their ad and database numbers with confidence โ€” which number to trust, what to do next, and what to ignore.

Glossary terms referenced