⏱ 44 min readTopics chapter readerLevel · Advanced
01 · Orientation
What You'll Master Here
A warehouse query costs what it scans. Optimization is the art of scanning fewer columns and fewer partitions while returning the same answer.
⏱ 6 min · Topic 1 of 15
A correct query can still be a bad query. On a cloud warehouse, the same answer can cost a few cents or many dollars depending on how much data it scans — and the difference is almost entirely under your control.
This chapter is about that control: how columnar storage, partitioning, and clustering decide what gets read, how to read a query plan, and how to reason about scan cost the way a warehouse bills it.
The mental shift is from “does it run?” to “how much does it read?”. On engines like BigQuery, Snowflake, and Redshift, bytes scanned — not rows returned — is the unit of both time and money.
To keep everything runnable, the Practice Lab works on two small metadata tables you would really query to tune a warehouse: table_partitions (per-partition row and byte counts) and query_runs (a query-history log with bytes scanned and partitions touched).
Core mental model
A warehouse query costs what it scans. Optimization is the art of scanning fewer columns and fewer partitions while returning the same answer.
Why data engineers care
Performance is a feature and cost is a budget. The engineer who can cut a daily report from 200 GB to 5 GB scanned saves real money every day and makes every dashboard faster — without changing a single number.
columnar storage
Data stored column-by-column, so a query only reads the columns it names.
partition
A physical slice of a table (often one per day) that can be skipped if a filter excludes it.
pruning
Skipping partitions or columns the query cannot possibly need, so they are never read.
bytes scanned
The amount of data a query reads — the basis of on-demand cost and most of its runtime.
on-demand cost is driven by bytes scanned, not rows returned
SELECT two columns, one day1.5 GB
SELECT *, one day12 GB
SELECT *, all partitions30 GB
The same logical answer can cost 1.5 GB or 30 GB depending on how many columns and partitions it scans. Pruning columns and partitions is the single biggest lever on the bill.
Two queries, same partitions, very different costworked example
SQL
Input data
query_runs2 rows
query_id
query_label
partitions_scanned
gb_scanned
1
daily_active_users
8
12
2
orders_export
8
240
Both scan all 8 partitions; query 2 (an export with SELECT *) reads 240 GB vs 12 GB.
-- both touch all 8 partitions, but one reads far more dataselectquery_label,partitions_scanned,gb_scannedfromquery_runswherequery_idin(1,2)orderbyquery_id;
Result · 2 rows
query_label
partitions_scanned
gb_scanned
daily_active_users
8
12
orders_export
8
240
Same partitions, 20× the bytes. Partitions are not the whole story — columns scanned matter just as much.
query_runs is a query-history log. These two queries scan the same number of partitions, yet one reads 20× the bytes — the difference is how many columns it touched.
Common mistake
Judging a query by how many rows it returns. A query returning 10 rows can scan 200 GB to get them. Cost tracks bytes read, not the size of the result.
Better habit
Ask “how much will this scan?” before running a query on a large table.
Treat columns and partitions as the two cost levers.
Measure in bytes scanned, not rows returned.
Interview note
When asked to optimize a query, start with “what is it scanning?” — name the partitions and columns it reads. That framing beats jumping straight to indexes or rewrites.
Study tip
Use the topic menu as a cost checklist: columns, partitions, clustering, the plan, and the bill. Each topic is a lever you can pull to scan less.
Remember this
A warehouse query costs what it scans. Optimization means reading fewer columns and partitions for the same answer — measured in bytes, not rows.
02 · Columns
Columnar Storage & Column Pruning
Each column is a separate file. You pay for the files you open, so SELECT * opens all of them and SELECT a, b opens two.
⏱ 5 min · Topic 2 of 15
Analytical warehouses store data column-by-column, not row-by-row. Each column lives in its own compressed blocks, so a query only reads the columns it actually names.
That is why SELECT * is expensive: it forces the engine to read every column, including wide ones like JSON payloads, even if you only display two. Naming the columns you need is the cheapest optimization there is.
Column pruning is automatic once you stop asking for everything. The skill is simply the discipline to select the narrow set of columns the question requires.
Core mental model
Each column is a separate file. You pay for the files you open, so SELECT * opens all of them and SELECT a, b opens two.
Why data engineers care
On a wide table, SELECT * can scan ten times the bytes of the two columns you need. Column pruning often cuts cost by an order of magnitude with no logic change at all.
Bytes per partition exposes wide scansworked example
SQL
Input data
query_runs6 rows
query_id
query_label
gb_scanned
partitions_scanned
1
daily_active_users
12
8
2
orders_export
240
8
3
revenue_by_country
3
2
4
event_funnel
85
6
5
late_event_audit
6
1
6
adhoc_select_star
180
8
gb_scanned divided by partitions_scanned approximates the bytes read per partition.
-- gb per partition is high when a query reads many (wide) columnsselectquery_id,query_label,printf('%.2f',gb_scanned*1.0/partitions_scanned)asgb_per_partitionfromquery_runsorderbygb_scanned*1.0/partitions_scanneddesc,query_id;
Result · 6 rows
query_id
query_label
gb_per_partition
2
orders_export
30.00
6
adhoc_select_star
22.50
4
event_funnel
14.17
5
late_event_audit
6.00
1
daily_active_users
1.50
3
revenue_by_country
1.50
orders_export and adhoc_select_star read 15–20× more per partition than the column-pruned queries — the signature of SELECT *.
Dividing bytes scanned by partitions touched isolates how much each query reads per partition — a proxy for how many columns it pulled.
Common mistake
Defaulting to SELECT * in production queries. It reads every column, including the heaviest, multiplying bytes scanned and cost even when you only use a few fields.
Better habit
Select only the columns the question needs.
Treat wide columns (JSON, text blobs) as expensive to read.
Use SELECT * only for ad-hoc inspection, never in pipelines.
Dialect note
Column pruning is core to columnar engines (BigQuery, Snowflake, Redshift, Parquet-on-Spark). SQLite is row-stored, so it does not prune columns — the cost idea is what transfers, not the byte counts.
Production note
A view defined as SELECT * silently re-reads every column for every consumer. Define views with explicit column lists so downstream queries inherit the pruning.
Remember this
Columnar storage means you pay per column read. Naming the few columns you need, instead of SELECT *, is the cheapest and most reliable cost cut.
03 · Partitions
Partitioning A Table
A partition is a drawer in a filing cabinet labelled by date. A date filter opens only the labelled drawers it needs and leaves the rest closed.
⏱ 6 min · Topic 3 of 15
Partitioning splits a table into physical slices, usually one per day on an event or fact table. Each partition can be read or skipped independently, which is what makes pruning possible.
You choose a partition key — most often a date column like event_day — when the table is created. A filter on that key then lets the engine touch only the matching partitions.
Partitioning is a storage-layout decision, not a query you write each time. Its payoff shows up in every later query that filters on the partition key.
Core mental model
A partition is a drawer in a filing cabinet labelled by date. A date filter opens only the labelled drawers it needs and leaves the rest closed.
Why data engineers care
A well-partitioned table turns a query over years of data into a query over a few days of data. The wrong partition key — or none — means every query scans the whole table.
Declaring a partitioned table (warehouse DDL)worked example
SQL
-- BigQuery: partition an events table by the event daycreatetableevents(event_idint64,user_idint64,event_timetimestamp,payloadjson)partitionbydate(event_time);-- Snowflake uses clustering keys instead; Postgres uses-- declarative partitions (partition by range on a date column).
Partitioning is declared in DDL, once, per table. After this, any filter on date(event_time) can prune partitions automatically.
Which partitions does a date range touch?worked example
SQL
Input data
table_partitions8 rows
partition_day
row_count
size_mb
2026-01-01
900
36
2026-01-02
1100
44
2026-01-03
800
32
2026-01-04
4200
168
2026-01-05
1000
40
2026-01-06
3000
120
2026-01-07
1200
48
2026-01-08
800
32
Eight daily partitions for the events table, with row and byte counts.
Only the three in-range partitions are read; the other five are pruned and never scanned.
table_partitions records each partition’s size. A date-range filter selects exactly the partitions a query over that range would read.
Common mistake
Partitioning on a high-cardinality column like user_id. Millions of tiny partitions create huge metadata overhead and rarely help pruning. Partition on a low-cardinality key like a date.
Better habit
Partition large fact and event tables by date.
Choose a low-cardinality partition key (day, not id).
Remember partitioning pays off on every filtered query, not just one.
Dialect note
BigQuery has native date/ingestion partitioning; Snowflake leans on micro-partitions plus clustering keys; Postgres uses declarative range/list partitions. The pruning concept is shared even though the DDL differs.
Production note
Match the partition grain to how the table is queried. Daily partitions suit daily reporting; hourly partitions only pay off if queries routinely filter to hours.
Remember this
Partitioning slices a table by a low-cardinality key (usually a date) so filters can skip whole slices. It is a one-time layout choice that speeds up every filtered query.
04 · Pruning
Partition Pruning (And What Defeats It)
Pruning works only when the engine can compare the partition key directly to constants. Hide the key inside a function and it has to open every drawer to check.
⏱ 6 min · Topic 4 of 15
Pruning is the engine skipping partitions a filter cannot match. A clean predicate on the partition key — partition_day BETWEEN x AND y — lets it read only the needed slices.
Pruning is fragile: wrapping the partition key in a function, or comparing it to a non-constant, can force a full scan because the engine can no longer reason about which partitions match.
The habit is to filter the raw partition key with constant bounds. date(partition_col) = … or substr(partition_col, …) often defeats pruning even though the query still returns the right answer — just much more slowly and expensively.
One clarification that trips people up: only the WHERE clause decides pruning. The same date() call in SELECT or GROUP BY is free, and a table declared as partition by date(event_time) still prunes perfectly from a plain range filter on event_time itself — the function in the DDL is not the problem, a function around the key in the predicate is.
Core mental model
Pruning works only when the engine can compare the partition key directly to constants. Hide the key inside a function and it has to open every drawer to check.
Why data engineers care
A query that should scan 3 partitions but accidentally scans all 365 is correct and ruinously expensive. Most “why is this query so slow?” incidents are a predicate that quietly broke pruning.
A pruning-friendly range scan, summarisedworked example
SQL
Input data
table_partitions5 rows
partition_day
size_mb
2026-01-01
36
2026-01-03
32
2026-01-04
168
2026-01-05
40
2026-01-06
120
5 of 8 partitions shown; the full table totals 8 partitions and 520 MB.
-- how much is read vs how much existsselectcount(*)aspartitions_scanned,sum(size_mb)asmb_scanned,(selectcount(*)fromtable_partitions)aspartitions_total,(selectsum(size_mb)fromtable_partitions)asmb_totalfromtable_partitionswherepartition_daybetween'2026-01-03'and'2026-01-05';
Result · 1 row
partitions_scanned
mb_scanned
partitions_total
mb_total
3
240
8
520
Reads 3 of 8 partitions and 240 of 520 MB. A function on the partition key would instead force all 8 / 520 MB.
A direct range filter on the partition key reads 3 of 8 partitions. The scalar subqueries show the full table for comparison.
A predicate that defeats pruningworked example
SQL
-- BAD: wrapping the partition key in a function reads every partitionselectcount(*)fromeventswheresubstr(cast(event_dayasstring),1,7)='2026-01';-- GOOD: a direct range keeps the key prunableselectcount(*)fromeventswhereevent_day>='2026-01-01'andevent_day<'2026-02-01';
Both return January’s count, but only the second can prune. The function in the first hides the partition key from the optimizer.
Predicates and pruning
Predicate
Prunes?
Why
day BETWEEN '..' AND '..'
Yes
Direct comparison to constants
day >= '..' AND day < '..'
Yes
Half-open range on the raw key
date(day) = …
Often no
Function hides the key from the optimizer
day = other_table.col
Maybe not
Non-constant; engine may not prune
date(day) in SELECT / GROUP BY
Harmless
Only WHERE predicates decide pruning
Common mistake
Wrapping the partition key in CAST/SUBSTR/DATE inside the WHERE clause. The optimizer can no longer map the predicate to partitions, so it scans them all — correct result, full-table cost.
Better habit
Filter the raw partition key against constant bounds.
Prefer half-open ranges over functions on the key.
Verify the scan size dropped, not just that the query is correct.
Watch out
A query can be perfectly correct and still scan the whole table. Correctness and pruning are independent — always check what was actually read.
Production note
Most warehouses report bytes scanned (BigQuery’s dry-run estimate, Snowflake’s query profile). Use it to confirm a predicate actually pruned instead of assuming it did.
Remember this
Pruning needs a direct comparison of the raw partition key to constants. Functions on the key still return the right answer but force a full, expensive scan.
05 · Clustering
Clustering, Sort Keys & Skew
Partitioning sorts the filing cabinet into dated drawers; clustering sorts the folders inside each drawer and writes each folder’s range on its tab, so you can skip a folder without opening it. Skew is one folder so fat it jams the drawer.
⏱ 7 min · Topic 5 of 15
Clustering (or sort keys) physically orders rows within a partition by a column you filter on, like country or customer_id. Every storage block records the smallest and largest value it holds, so once matching values sit together the engine can read that range and skip whole blocks without ever opening them — a second layer of pruning below the partition level.
Clustering helps most for high-cardinality columns you filter or join on frequently — the ones partitioning cannot handle. A filter on a clustered column reads only the blocks where those values live. Order the key by how you actually query: a key of (country, user_id) serves a filter on country, or on both, far better than one on user_id alone.
Skew is the enemy of both: one giant partition (a hot day) or one giant cluster value processes far more data than the rest and becomes the slow straggler that dominates runtime.
Core mental model
Partitioning sorts the filing cabinet into dated drawers; clustering sorts the folders inside each drawer and writes each folder’s range on its tab, so you can skip a folder without opening it. Skew is one folder so fat it jams the drawer.
Why data engineers care
Partitioning prunes at the day level; clustering prunes within the day. Together they decide how little of a partition a query has to read — and a single skewed partition can wreck the parallelism that makes a warehouse fast.
Declaring a cluster key (warehouse DDL)worked example
SQL
-- BigQuery: partition by day, then order rows inside each daycreatetableevents(event_idint64,user_idint64,countrystring,event_timetimestamp,payloadjson)partitionbydate(event_time)clusterbycountry,user_id;-- up to 4 columns; the order matters-- Snowflake: micro-partitions already exist, you only declare the keyaltertableeventsclusterby(country,user_id);-- Redshift: a compound sort key plays the same rolecreatetableevents(...)compoundsortkey(country,user_id);
Partitioning and clustering are declared together: the partition key slices the table into days, the cluster key orders rows inside each day. Unlike a partition key, a cluster key is high-cardinality on purpose — country and user_id would make terrible partition keys.
How a clustered layout reads fewer blocksworked example
SQL
Input data
block_stats8 rows
layout
block_id
min_country
max_country
unclustered
1
GB
US
unclustered
2
GB
US
unclustered
3
GB
US
unclustered
4
IN
US
clustered
1
GB
GB
clustered
2
IN
IN
clustered
3
US
US
clustered
4
US
US
The same rows in the same partition, laid out two ways. Unclustered, every block holds a mix of countries, so every block’s range still reaches US — and a block can only be skipped when its range cannot contain the value you asked for.
-- block_stats is a zone map: one row per storage block, with the-- smallest and largest country value that block holds.selectlayout,count(*)asblocks_total,sum(casewhen'US'betweenmin_countryandmax_countrythen1else0end)asblocks_readfromblock_statsgroupbylayoutorderbyblocks_read;
Result · 2 rows
layout
blocks_total
blocks_read
clustered
4
2
unclustered
4
4
Same partition, same answer, half the blocks read. Note what does the work: not the sorting itself but the narrow min/max range sorting produces. A block whose range still reaches US can never be ruled out, however few US rows it actually holds.
This is the min/max check the engine runs for where country = 'US'. A block whose range excludes US cannot contain a US row, so it is skipped unread.
Flag the hot (skewed) partitionsworked example
SQL
Input data
table_partitions8 rows
partition_day
row_count
2026-01-01
900
2026-01-02
1100
2026-01-03
800
2026-01-04
4200
2026-01-05
1000
2026-01-06
3000
2026-01-07
1200
2026-01-08
800
Average row count is 1625; two days sit well above it.
-- a partition is "hot" when its row_count is above the averageselectpartition_day,row_count,casewhenrow_count>(selectavg(row_count)fromtable_partitions)then'hot'else'normal'endasskew_flagfromtable_partitionsorderbyrow_countdesc,partition_day;
Result · 8 rows
partition_day
row_count
skew_flag
2026-01-04
4200
hot
2026-01-06
3000
hot
2026-01-07
1200
normal
2026-01-02
1100
normal
2026-01-05
1000
normal
2026-01-01
900
normal
2026-01-03
800
normal
2026-01-08
800
normal
Two partitions are above the 1625 average. Those hot days do the most work and gate a full scan’s completion time.
Comparing each partition to the average row count surfaces the skewed days that will dominate a full scan’s runtime.
Partitioning vs clustering
Technique
Granularity
Best for
Partitioning
Whole slices (per day)
Low-cardinality date filters
Clustering / sort key
Blocks within a partition
High-cardinality filter/join columns
Both together
Day, then block
Date-filtered queries that also filter an id/region
Which filters a cluster key of (country, user_id) actually helps
Filter
Benefit
Why
country = 'US'
Full
Leading column of the key
country = 'US' and user_id = 42
Best
Matches the key prefix, in order
user_id = 42
Little to none
Blocks are ordered by country first, so user ids stay scattered
lower(country) = 'us'
None
A function hides the clustered column, exactly as it does a partition key
Common mistake
Clustering on a column you never filter or join on. Clustering only helps queries that filter the clustered column. Clustering on an unused column adds maintenance cost for no pruning benefit.
Setting a cluster key and assuming the table stays sorted. New rows arrive unsorted, so blocks widen and pruning decays as the table grows. Re-clustering is the price of keeping it: BigQuery re-clusters in the background at no charge, Snowflake automatic clustering consumes credits, and Redshift needs VACUUM SORT or automatic table sort. Budget for that upkeep before clustering a high-churn table.
Better habit
Cluster on the high-cardinality columns you filter or join on, leading column first.
Expect pruning to decay as rows arrive, and budget for re-clustering.
Treat a hot partition or key value as a defect to fix, not just a number to note.
Dialect note
BigQuery clusters on up to four columns; Snowflake auto-clusters via clustering keys; Redshift uses sort keys (compound or interleaved). The block-skipping idea is the same across them.
Production note
Skew often comes from a default or junk value (a NULL bucket, a “0” customer). Watch for one partition or key value that is orders of magnitude larger than the rest — then fix it rather than noting it: exclude or bucket the junk value, salt the key (append a small random suffix) so one hot value spreads across workers, or use a finer grain for the hot range only.
Remember this
Clustering orders rows inside a partition so every block carries a narrow min/max range, and a filter on the leading key column skips the blocks whose range cannot match. It is neither free nor permanent: pruning decays as new rows arrive, re-clustering has a per-engine cost, and one hot partition or key value can still dominate runtime.
06 · Plans
Reading A Query Plan
A plan is a tree you read from the leaves up. The leaves tell you what pruning cost you; every step above tells you what the engine had to move, hash, or spill to answer the question.
⏱ 5 min · Topic 6 of 15
A query plan is the engine’s step-by-step recipe: scan these partitions, apply this filter, join in this order, then aggregate. Reading it tells you where the time and bytes actually go.
Read it bottom-up. The leaves are table scans, and work flows upward through filters, joins, and aggregations to the final output. The scan is where cost concentrates when pruning failed — but once a scan is well pruned, the joins and shuffles above it are usually what dominate.
You are looking for three things: how much each scan reads, whether a join explodes the row count, and where the optimizer’s estimate diverged from what actually happened. A large gap between estimated and actual rows is the usual root cause of a plan that went wrong.
Core mental model
A plan is a tree you read from the leaves up. The leaves tell you what pruning cost you; every step above tells you what the engine had to move, hash, or spill to answer the question.
Why data engineers care
Optimizing without the plan is guessing. The plan shows whether your filter pruned, whether a join order is sane, and which scan dominates — so you fix the expensive step, not a cheap one.
Finding the step that actually dominatesworked example
SQL
Input data
plan_steps5 rows
step
operator
rows_est
rows_actual
bytes_gb
spill_gb
1
Scan events (12 of 400 partitions)
8000000
8200000
4
0
2
Filter country = 'US'
800000
790000
0
0
3
Join → users (shuffle)
800000
96000000
0
38
4
Aggregate group by country
5
5
0
0
5
Output
5
5
0
0
A real profile, trimmed to the columns that matter. rows_est is what the optimizer predicted before running; rows_actual is what the step really produced.
-- plan_steps is one row per step from the engine's profile:-- what the optimizer estimated, and what actually happened.selectstep,operator,round(rows_actual/nullif(rows_est,0),1)asest_error_x,bytes_gb,spill_gbfromplan_stepswherebytes_gb+spill_gb>0orderbybytes_gb+spill_gbdesc;
Result · 2 rows
step
operator
est_error_x
bytes_gb
spill_gb
3
Join → users (shuffle)
120
0
38
1
Scan events (12 of 400 partitions)
1
4
0
The scan is fine — 12 of 400 partitions, 4 GB, estimate dead on. The join is the problem: the optimizer expected 800k rows and got 96M, a 120× miss, so the hash build outgrew memory and spilled 38 GB to disk. Deduplicating users to one row per user_id before the join is worth roughly ten times more than anything you could do to the scan.
Sorting the plan by the work each step did — bytes read plus bytes spilled — answers 'what do I fix?' before you change a line of SQL.
What to look for in a plan
Signal
Means
Action
Full table scan on a big table
No pruning happened
Add/fix a partition-key filter
Row count explodes after a join
Fan-out (one-to-many)
Aggregate to grain before joining
Large scan feeding a small result
Reading too many columns
Prune columns / pre-aggregate
Actual rows ≫ estimated rows
Optimizer misjudged cardinality
Refresh stats; pre-aggregate to grain
Bytes spilled to local or remote disk
A join or sort outgrew memory
Cut the fan-out, or size the warehouse up
Common mistake
Optimizing the cheap step because it is easy to see. Tuning a join while a 200 GB scan sits below it wastes effort. The plan tells you which step actually dominates.
Trusting EXPLAIN’s estimates as if they were measurements. A plain EXPLAIN shows the compile-time plan built from table statistics — it does not know what happened at runtime. Use EXPLAIN ANALYZE, the Query Profile, or job stats to see actual rows, bytes, and spill. Otherwise you are tuning against a guess.
Better habit
Read the plan bottom-up, starting at the scans.
Find the step that dominates bytes or rows before changing anything.
Re-read the plan with actuals — not estimates — after a change to confirm it helped.
Dialect note
SQLite has EXPLAIN QUERY PLAN; Postgres has EXPLAIN ANALYZE; BigQuery shows a graphical plan plus a dry-run byte estimate; Snowflake has EXPLAIN and the Query Profile. The vocabulary differs, the reading strategy does not.
Interview note
Say you would read the plan before optimizing: “let me check whether the filter pruned and which scan dominates.” It shows you tune with evidence, not folklore.
Remember this
A query plan is a tree read from the scans up. Find the step that dominates bytes, rows, or spill — it is not always the scan — check where the estimate diverged from the actual, fix that one step, then re-read the plan with actuals to confirm.
07 · Cost
Scan Cost: Bytes Are Dollars
you pay for what the query had to read, not for what it returned.
⏱ 4 min · Topic 7 of 15
On on-demand pricing, a query’s cost is a direct function of the bytes it scans — independent of how many rows it returns. Read 1 TB, pay for 1 TB, whether the result is one row or a million.
That makes cost estimable before you run: bytes scanned × the per-byte rate. Most warehouses even give you a dry-run estimate so you can see the bill before committing.
Not every warehouse bills this way. BigQuery on-demand charges for bytes read; Snowflake and Databricks charge for how long the compute runs (warehouse size × seconds). Under bytes-billed pricing, reading less is directly cheaper; under compute-billed pricing, reading less finishes sooner, and the clock is the bill. Either way the lever is identical — scan less — which is why every technique in this chapter is also a cost technique.
Core mental model
Cost = bytes scanned × rate on a bytes-billed engine, and warehouse size × seconds on a compute-billed one. Under both, the result size is irrelevant: you pay for what the query had to read, not for what it returned.
Why data engineers care
Take the 240 GB query below and run it hourly for a dashboard tile: that is about 173 TB a month, roughly $1,000 at BigQuery on-demand rates — for one tile nobody thought was expensive. A handful of those is real money. Reasoning about bytes-to-dollars is how you catch it before finance does.
Estimating cost per query from bytes scannedworked example
SQL
Input data
query_runs6 rows
query_id
query_label
gb_scanned
1
daily_active_users
12
2
orders_export
240
3
revenue_by_country
3
4
event_funnel
85
5
late_event_audit
6
6
adhoc_select_star
180
gb_scanned is the cost driver; a rate converts it to dollars. $5/TB keeps the arithmetic readable — substitute your engine’s actual rate in real work.
-- assume $5 per TB for round numbers: cost = gb_scanned / 1024 * 5selectquery_id,query_label,gb_scanned,printf('%.2f',gb_scanned/1024.0*5)ascost_usdfromquery_runsorderbygb_scanneddesc,query_id;
Result · 6 rows
query_id
query_label
gb_scanned
cost_usd
2
orders_export
240
1.17
6
adhoc_select_star
180
0.88
4
event_funnel
85
0.42
1
daily_active_users
12
0.06
5
late_event_audit
6
0.03
3
revenue_by_country
3
0.01
The two heaviest queries cost more than the other four combined. Cost concentrates in a few un-pruned queries.
Bytes scanned convert straight to dollars. The two costliest queries here are simply the two that read the most data — what they return has nothing to do with it.
Two ways a warehouse bills you
Billing model
You pay for
Does a small result make it cheap?
Bytes scanned — BigQuery on-demand, Athena
Data read, per TB
No — a one-row answer over a full table still bills the whole scan
Compute time — Snowflake, Databricks, BigQuery Editions
Warehouse size × seconds running
Only indirectly — reading less finishes sooner, and the clock is the bill
Common mistake
Assuming a small result means a cheap query. A COUNT(*) over an un-pruned year of data returns one row and still scans terabytes. The bill follows bytes, not output rows.
Assuming LIMIT makes a query cheap. LIMIT caps what comes back, not what gets read. On a bytes-billed engine, select * from events limit 10 over an unpartitioned table still scans — and still bills for — the entire table. Only a partition filter and fewer columns cut bytes; LIMIT just trims the output.
Better habit
Estimate bytes × rate before running heavy queries.
Use the engine’s dry-run / cost estimate when available.
Attack the few queries that dominate the bill first.
Dialect note
BigQuery on-demand bills $6.25 per TiB scanned in US regions, with the first 1 TiB each month free and a dry-run estimate before you commit; rates vary by region and its capacity (Editions) pricing bills slots instead, so confirm your own rate. Snowflake and Databricks bill compute time (warehouse size × seconds), so there “cost” tracks runtime more than raw bytes. The lever — scan less — helps under every one of these models. The $5/TB used in the examples here is a round number for the arithmetic, not a current price.
Production note
Set cost guardrails: BigQuery maximum-bytes-billed limits, scheduled-query budgets, and alerts on bytes scanned. A guardrail stops one bad query from becoming a five-figure surprise.
Remember this
Cost follows what a query reads, not what it returns — bytes × rate on an on-demand engine, warehouse size × seconds on a compute-billed one. Estimate before running, remember that LIMIT does not shrink the scan, and target the few queries that dominate the bill.
08 · Full scans
Detecting & Avoiding Full Scans
partitions_scanned ÷ partitions_total is a pruning score. Anything at 1.00 opened every drawer — then ask whether it had to.
⏱ 5 min · Topic 8 of 15
A full scan reads every partition of a table. It is not itself a bug — it is a symptom, and the same symptom has two completely different causes. Either a predicate failed to prune, or the query genuinely needs the whole history. Telling those apart is the skill this topic is about.
The detection is arithmetic. Warehouses log how many partitions each query touched, and you know how many the table has. If a query touched all of them, it skipped nothing. Dividing the two gives a pruning ratio: 0.13 means the query read one drawer of eight, and 1.00 means it opened every one.
Once a query is flagged, the fork is what matters. If the filter is broken, the repair belongs to Partition Pruning (And What Defeats It) — rewrite the predicate so the key stays comparable to constants. But if the query really does need every partition, no predicate will save it, and the only remaining lever is to stop reading the raw table at all: pre-aggregate it once, and let consumers read the summary.
Core mental model
partitions_scanned ÷ partitions_total is a pruning score. Anything at 1.00 opened every drawer — then ask whether it had to.
Why data engineers care
Full scans on big tables are where budgets go to die, and the pruning ratio is the cheapest way to find them — it needs no query plan, no profiler, just two numbers you already have. Knowing which flagged queries deserve a rewrite and which deserve a rollup is what separates a cost review that saves money from one that just generates a list.
Score every query by how much it prunedworked example
Filtering straight to the full scans hides the shape of the problem. Scoring every query instead shows the spectrum — and makes the 1.00 rows obvious without being told.
SQL
Input data
query_runs6 rows
query_id
query_label
partitions_scanned
1
daily_active_users
8
2
orders_export
8
3
revenue_by_country
2
4
event_funnel
6
5
late_event_audit
1
6
adhoc_select_star
8
A query-history log: one row per run, recording how many partitions that run actually touched.
table_partitions8 rows
partition_day
2026-01-01
2026-01-02
2026-01-03
2026-01-04
2026-01-05
2026-01-06
2026-01-07
2026-01-08
The events table has eight daily partitions, so count(*) here is 8 — the denominator of the ratio.
-- pruning ratio: 1.00 means the query skipped nothing at allselectquery_label,partitions_scanned,(selectcount(*)fromtable_partitions)aspartitions_total,printf('%.2f',partitions_scanned*1.0/(selectcount(*)fromtable_partitions))aspruning_ratiofromquery_runsorderbypruning_ratiodesc,query_id;
Result · 6 rows
query_label
partitions_scanned
partitions_total
pruning_ratio
daily_active_users
8
8
1.00
orders_export
8
8
1.00
adhoc_select_star
8
8
1.00
event_funnel
6
8
0.75
revenue_by_country
2
8
0.25
late_event_audit
1
8
0.13
Three queries sit at 1.00 — but they are not the same problem. daily_active_users aggregates every day by definition, so its full scan is correct and a rewrite would achieve nothing; it is a candidate for the rollup in the next example. adhoc_select_star and orders_export almost certainly meant to filter, so they belong in Partition Pruning. Note also that 0.75 is a weak score that this report will never flag — event_funnel skipped only two of eight partitions, and a threshold set at 1.00 lets it through.
The scalar subquery supplies the table’s partition count once for every row. Dividing turns a raw count into a comparable score, so queries over tables of different sizes can sit in the same report.
Pre-aggregating when the full scan is legitimateworked example
For daily_active_users above, no predicate helps — it is supposed to read every day. The fix is to move the scan off the read path so it happens once instead of once per viewer.
SQL
Input data
events7 rows
event_day
country
user_id
2026-01-01
US
1
2026-01-01
US
1
2026-01-01
IN
2
2026-01-02
US
3
2026-01-02
US
1
2026-01-02
IN
2
2026-01-02
IN
4
Raw event rows — one per event. This table grows without bound, which is why anything reading it directly gets more expensive every day.
-- roll the raw events up to one row per day per country,-- then point dashboards at the summary instead of the raw tablecreatetableevents_dailyasselectevent_day,country,count(*)asevents,count(distinctuser_id)asusersfromeventsgroupbyevent_day,country;
Result · 4 rows
event_day
country
events
users
2026-01-01
IN
1
1
2026-01-01
US
2
1
2026-01-02
IN
2
2
2026-01-02
US
2
2
Seven rows collapse to four, and the ratio only widens with scale — the summary grows with days × countries while the raw table grows with every event. Do the arithmetic on the eight-partition, 520 MB events table: a dashboard refreshed fifty times a day scans about 26 GB against the raw history, or roughly one scheduled 520 MB scan against the rollup. Two costs are real, though. The grain is now fixed — a daily summary cannot answer a question about one hour or one user, so those queries still hit the raw table — and count(distinct user_id) does not re-aggregate, because summing daily uniques double-counts anyone who returns. A monthly figure has to be recomputed from raw.
The full scan does not disappear — it moves. One scheduled job absorbs it nightly, and every dashboard refresh afterwards reads a summary that is orders of magnitude smaller.
A query scored 1.00 — now what?
What you find
Verdict
Where the fix lives
No predicate on the partition key at all
Defect
Add a partition-key filter
A predicate exists but hides the key from the optimizer
Defect
Partition Pruning — rewrite to a constant range
A report that genuinely aggregates every partition
Legitimate
Pre-aggregate; no predicate can help
A dashboard re-reading raw history on every refresh
Legitimate but wasteful
Materialize a summary, point consumers at it
A one-off backfill or full export
Legitimate
Leave it — accept the cost, or schedule it off-peak
Common mistake
Treating every full scan as a bug. Some reports genuinely need all partitions, and “optimizing” them wastes effort or quietly changes the answer. Use the flag to start an investigation, not to file a ticket.
Flagging only at a ratio of exactly 1.00. A query reading six of eight partitions is nearly as expensive and never appears. On a year-partitioned table the interesting failures sit at 0.8, not 1.0 — set the threshold below one.
Comparing partition counts across tables without normalising. Eight partitions scanned is a full scan of one table and a rounding error on another. Ratios are comparable across tables; raw counts are not.
Better habit
Score queries by partitions_scanned ÷ partitions_total, not by raw counts.
Set the flag threshold below 1.00 so near-full scans surface too.
For each flagged query, decide first whether the scan is legitimate.
Pre-aggregate the legitimate ones; rewrite the predicates of the rest.
Dialect note
The ratio needs a scanned count and a total. BigQuery exposes total_bytes_processed in INFORMATION_SCHEMA.JOBS and partition metadata in INFORMATION_SCHEMA.PARTITIONS; Snowflake reports partitions_scanned and partitions_total together in ACCOUNT_USAGE.QUERY_HISTORY, so the division is already available. On engines that report only bytes, use bytes scanned over table size — the same score with a different denominator.
Watch out
A rollup silently inherits whatever was wrong upstream. If the raw table has duplicate events, the summary has inflated counts and nobody sees the raw rows again to notice. Validate the aggregate against the source the first time you build it, then keep a reconciliation check on it.
Production note
Build the pruning-ratio report as a scheduled query and alert when a new query lands above the threshold. Catching a full scan the day it ships is far cheaper than finding it on the monthly invoice.
Interview note
Frame it as triage, and say the quiet part: “I’d rank query history by pruning ratio, then split the full scans into broken filters and legitimate whole-table reads — those need a rollup, not a rewrite.” Naming the fork reads as senior; jumping straight to “add a WHERE clause” does not.
Remember this
A full scan is a symptom, not a diagnosis. Score queries by partitions scanned over partitions total, flag below 1.00 so near-misses surface, then split the results: broken predicates get rewritten, legitimate whole-table reads get pre-aggregated so the scan happens once instead of once per refresh.
09 · Monitoring
Monitoring Cost From Query History
rank by bytes, group by owner, and the optimization targets fall out.
⏱ 4 min · Topic 9 of 15
Warehouses log every query with how much it scanned and how long it took. That history is itself a table you can query — and it is where cost monitoring lives.
Two reports cover most of the value: the most expensive queries (rank by bytes scanned) and cost by owner (sum bytes per analyst or service account). Together they tell you what to fix and who to talk to.
The point is to make cost visible and attributable. A recurring expensive query you can name is a query you can optimize or schedule down.
Core mental model
Query history is a fact table of cost. Aggregate it like any other: rank by bytes, group by owner, and the optimization targets fall out.
Why data engineers care
You cannot manage what you cannot see. Ranking queries and owners by bytes turns a vague “the warehouse bill went up” into a short, actionable list.
Cost by analyst from query historyworked example
SQL
Input data
query_runs6 rows
run_by
gb_scanned
ana
12
ben
240
ana
3
cara
85
ben
6
cara
180
Six logged runs across three analysts; bytes are summed per owner.
-- sum bytes per owner, then convert to dollars at $5 per TBselectrun_by,sum(gb_scanned)asgb_scanned,printf('%.2f',sum(gb_scanned)/1024.0*5)asestimated_cost_usdfromquery_runsgroupbyrun_byorderbysum(gb_scanned)desc,run_by;
Result · 3 rows
run_by
gb_scanned
estimated_cost_usd
cara
265
1.29
ben
246
1.20
ana
15
0.07
cara and ben drive almost all the scan volume; ana’s pruned queries are an order of magnitude cheaper.
Grouping the history by owner attributes scan volume — and therefore cost — to the people and jobs driving it.
Two cost-monitoring reports
Report
Aggregation
Answers
Most expensive queries
Rank by bytes scanned
What to optimize first
Cost by owner
Sum bytes per analyst/job
Who to talk to
Trend over time
Bytes per day
Is cost growing?
Common mistake
Only watching the total warehouse bill. A single number hides which query or team caused a spike. Aggregating history by query and owner makes the cause findable.
Better habit
Rank queries by bytes scanned to find optimization targets.
Attribute scan volume to an owner or job.
Track bytes-per-day to catch cost creep early.
Dialect note
BigQuery exposes history via INFORMATION_SCHEMA.JOBS_BY_PROJECT (total_bytes_billed); Snowflake via ACCOUNT_USAGE.QUERY_HISTORY (bytes_scanned, credits). Querying that history is the same skill as querying query_runs here.
Production note
Schedule the cost report and alert on outliers. Catching a newly-deployed full-scan query the day it ships is far cheaper than finding it on the monthly invoice.
Remember this
Query history is a cost fact table. Rank by bytes to find what to fix and group by owner to find who to ask — then watch the trend over time.
10 · Views
Views & Materialized Views
A view is a name for a query. A materialized view is a name for a table plus a promise to keep it current.
⏱ 4 min · Topic 10 of 15
A view is a saved query. It stores no data and costs nothing until somebody selects from it, at which point the engine substitutes the definition and plans the whole thing as one query.
A materialized view stores the result. Reads are fast because the work is already done, and the price is staleness plus a refresh you have to own.
The decision between them is not about speed. It is about whether you can tolerate a stale answer, and who is on the hook when the refresh fails.
Core mental model
A view is a name for a query. A materialized view is a name for a table plus a promise to keep it current.
Why data engineers care
Views are the most common way a warehouse accumulates invisible cost: a view over a view over a view, each one adding a join, all planned as a single query nobody has ever read end to end.
The same logic, two objectsworked example
SQL
-- A view: no storage, no staleness, full cost on every readcreateviewpaid_ordersasselectorder_id,buyer_id,total_amount,created_atfromorderswherestatus='paid';-- A materialized view: storage, staleness, cheap readscreatematerializedviewdaily_paid_revenueasselectdate(created_at)asday,count(*)asorders,sum(total_amount)asrevenuefromorderswherestatus='paid'groupbydate(created_at);
Result · 4 rows
View
Materialized view
Storage
None
Full result set
Read cost
The underlying query, every time
A table scan of the stored result
Freshness
Always current
As of the last refresh
Failure mode
The query is slow
The data is quietly stale
The last row is the one that matters operationally. A slow view is visible; a materialized view whose refresh has been failing for a week looks perfectly healthy.
The first is a naming convenience. The second is a small pipeline with an owner, a schedule and a failure mode.
Refresh strategies
Strategy
Cost
When it fits
Full refresh on a schedule
Recomputes everything each run
Small results, or sources that change everywhere at once.
Incremental refresh
Only the changed partitions
Large append-mostly facts. Needs the engine to support it and the query to qualify.
Automatic (Snowflake, BigQuery)
Managed by the platform, billed continuously
Simple aggregations the engine can maintain itself. Read the restrictions before relying on it.
A table built by your pipeline instead
Whatever you write
Anything complex. At that point you own it explicitly, which is usually the honest answer.
Common mistake
Stacking views on views to organise logic. The engine expands every layer into one query, so a four-deep stack can join the same table repeatedly. It reads as clean modelling and plans as one enormous statement.
Treating a materialized view as free speed. It is a pipeline with no dashboard. When the refresh fails, queries keep working and return last week’s numbers.
Materializing something that is queried once a day. You pay refresh cost continuously to save one query’s worth of work. Materialize what is read far more often than it changes.
Better habit
Use views for naming and access control; materialize only for a measured read pattern.
Give every materialized view a freshness check, not just a refresh job.
Read the fully expanded plan of a view stack once before defending it.
Dialect note
PostgreSQL materialized views are refreshed manually or by a scheduler and are stale until you do. Snowflake maintains its own automatically but restricts what the query can contain. BigQuery does the same with its own restriction list. Nothing about materialized views is portable except the idea.
Interview note
"View or materialized view?" is really "what is your tolerance for staleness, and who owns the refresh?" Answering with those two questions rather than with a definition is the senior answer.
Remember this
A view costs time on every read; a materialized view costs freshness and an owner. Pick the one whose cost you can afford.
11 · Habits
Everyday Optimization Habits
which columns, which partitions, and is there a summary I can read instead of the raw table?
⏱ 4 min · Topic 11 of 15
Most warehouse savings come from a handful of habits applied consistently, not from exotic tricks. Select the columns you need, filter the partition key, and avoid reading raw history when a summary exists.
When the same heavy aggregation runs repeatedly, materialize it — a scheduled table or materialized view turns a daily full scan into a one-time cost that every consumer then reads cheaply.
These habits compound. A query that prunes columns and partitions and reads a pre-aggregate can be a hundred times cheaper than the naive version that returns the identical numbers.
Core mental model
Before running, ask three questions: which columns, which partitions, and is there a summary I can read instead of the raw table?
Why data engineers care
Consistent habits beat heroic one-off optimizations. A team that prunes by default never accumulates the expensive queries that a team relying on cleanup has to hunt down later.
A pruned, summarised query beats a raw scanworked example
SQL
-- expensive: raw scan, all columns, no filter-- select * from events;-- cheaper: only needed columns, partition filter, groupedselectdate(event_time)asevent_day,count(*)aseventsfromeventswhereevent_time>='2026-01-03'andevent_time<'2026-01-06'groupbydate(event_time)orderbyevent_day;
The same daily-count answer, but with a partition-key filter and two columns instead of SELECT * over all history.
Optimization habits, ranked by payoff
Habit
Saves
Effort
Name columns (no SELECT *)
Column bytes
Trivial
Filter the partition key
Whole partitions
Trivial
Let the result cache serve repeats
The whole repeated query
Free
Pre-aggregate / materialize hot queries
Repeated full scans
Medium
Avoid cross/fan-out joins
Row explosions
Medium
Common mistake
Re-deriving the same heavy aggregate in many queries. Each consumer re-scans the raw table. Materializing the aggregate once lets them all read a small summary instead.
Better habit
Name columns and filter the partition key by default.
Materialize aggregations that many queries reuse.
Aggregate to grain before joining to avoid fan-out.
Study tip
Before optimizing exotic things, check the basics: is it SELECT *? Is the partition key filtered? Those two account for most warehouse waste.
Production note
Materialized views and scheduled summary tables trade a little storage and freshness for large, repeated scan savings — usually a good trade for dashboards. Before reaching for either, check the result cache: BigQuery serves a byte-identical repeat query from cache at no charge, and Snowflake has a 24-hour result cache. Both are invalidated by any write to the underlying table and by non-deterministic SQL, so current_timestamp() in a dashboard query quietly disables the cheapest optimization you have.
Remember this
Prune columns, filter the partition key, and materialize repeated aggregations. A few consistent habits beat occasional heroic tuning.
12 · Judgement
When Not To Optimize
Bytes per run × runs per day = bytes per day. That last number is the one worth ranking by.
⏱ 5 min · Topic 12 of 15
Every fix costs something. It costs your time to write, and it costs everyone else’s time to read for as long as the query lives. So the real question is not “can this be faster?” — almost anything can. It is “will the saving be bigger than the fix?”
To answer that you need one number: how much a query reads in a day. Not how much it reads in one run. A 240 GB export that runs once a day reads 240 GB. An 85 GB dashboard that refreshes every hour reads 2,040 GB — over eight times more — even though each run looks three times smaller. Judge by the daily total and you will pick a different query than if you judge by size alone.
This is why a big, wasteful query can be safe to leave alone. If it runs once a quarter, cleaning it up saves a few cents a year, and leaves behind a more complicated query that someone has to maintain forever. That is a bad trade. Spend the effort where the daily total is large.
One warning before you tune anything. A clever rewrite that returns the wrong number is worse than a slow query that returns the right one. Make it correct first, then make the expensive ones cheap.
Core mental model
Bytes per run × runs per day = bytes per day. That last number is the one worth ranking by.
Why data engineers care
An hour spent tuning a rare query saves pennies. The same hour spent on a query that runs every hour can save thousands over a year. Choosing the right target matters more than knowing more optimization tricks.
The biggest query is not the most expensive oneworked example
The same six queries, ranked two ways. Ranking by single-run size puts the export on top. Multiplying by how often each one runs moves it almost to the bottom.
SQL
Input data
query_runs5 rows
query_id
query_label
gb_scanned
runs_per_day
2
orders_export
240
1
6
adhoc_select_star
180
1
4
event_funnel
85
24
1
daily_active_users
12
48
3
revenue_by_country
3
96
Sorted by gb_scanned, largest first — the order you would naturally start from. orders_export is twenty times bigger per run than revenue_by_country.
-- what a query really costs in a day = one run x how many runsselectquery_label,gb_scanned,runs_per_day,gb_scanned*runs_per_dayasgb_per_dayfromquery_runsorderbygb_per_daydesc,query_id;
Result · 5 rows
query_label
gb_scanned
runs_per_day
gb_per_day
event_funnel
85
24
2040
daily_active_users
12
48
576
revenue_by_country
3
96
288
orders_export
240
1
240
adhoc_select_star
180
1
180
The order flips almost completely. The two largest queries land last, because each runs once. event_funnel is now first by a wide margin — it is a third the size of the export but runs 24 times. And revenue_by_country, at just 3 GB a run, still beats the 240 GB export. Tune event_funnel and you are working on 2 TB a day; tune adhoc_select_star, which looked alarming, and you are working on 180 GB.
gb_scanned is what one run reads; runs_per_day comes from your scheduler or query history. Multiplying them is the whole technique — there is nothing more to it than that.
Common mistake
Fixing a query because it looks wasteful. Wasteful and expensive are not the same thing. A query can read ten times more than it needs and still be irrelevant if it runs twice a year. Check the daily total before you start.
Ignoring a small query because the number is small. This is the same error in reverse. A 3 GB query that runs every fifteen minutes reads more per day than a 240 GB one that runs once. Small and frequent adds up quietly.
Making a query cheaper without re-checking the answer. A rewrite that prunes more can also silently drop rows — a changed date boundary, a filter moved into a join. Compare the output against the original before you ship it.
Better habit
Rank by bytes per day, not bytes per run.
Ask how often it runs before you ask how big it is.
Leave rare or small queries simple and readable.
Check the new query returns the same answer as the old one.
Interview note
Say the frequency part out loud: “this one is huge but runs once a quarter, so I would leave it — the hourly one is smaller and costs more.” Interviewers hear that as judgement. Jumping straight to a rewrite sounds like someone who tunes whatever is in front of them.
Watch out
The worst outcome is not a slow query. It is a fast query that quietly returns the wrong number, because nobody re-checks a change that was supposed to be about speed.
Production note
Scheduled queries are the easy win here, because you already know how often they run. Ad-hoc queries need the run count counted from history first, and until you have it, size is the only signal you have.
Remember this
What a query costs is what it reads in a day: bytes per run times runs per day. Rank by that, not by size — the biggest single query is often not the expensive one. Leave rare and small queries alone, and always confirm a cheaper query still returns the same answer.
13 · Pitfalls
Common Performance Mistakes
A good warehouse query reads the fewest columns and partitions that can possibly answer the question — and you have checked that it did.
⏱ 4 min · Topic 13 of 15
Warehouse cost bugs are predictable: SELECT * on wide tables, a missing or broken partition filter, a function hiding the partition key, and re-scanning raw history that should have been pre-aggregated.
The cure is the same discipline each time — name your columns, filter the raw partition key, confirm the scan size dropped, and materialize what many queries reuse.
Core mental model
A good warehouse query reads the fewest columns and partitions that can possibly answer the question — and you have checked that it did.
Why data engineers care
These mistakes do not error; they just cost money and time. Recognising them by reflex is what keeps a warehouse fast and affordable.
The pruned, column-narrow versionworked example
SQL
-- counts the same events, but reads two columns and three partitionsselectdate(event_time)asevent_day,count(*)aseventsfromeventswhereevent_time>='2026-01-03'andevent_time<'2026-01-06'groupbydate(event_time)orderbyevent_day;
Two columns, a half-open partition-key range, and a grouped result — the defensible shape of a warehouse query.
Anti-pattern to better habit
Anti-pattern
Why it hurts
Better habit
SELECT * on a wide table
Reads every column
Name the columns you need
No filter on the partition key
Full-table scan
Add a partition-key predicate
date(key) / cast(key) in WHERE
Defeats pruning
Compare the raw key to constants
Re-scanning raw history each run
Repeated full scans
Materialize / pre-aggregate
Judging cost by rows returned
Hides huge scans
Measure bytes scanned
Common mistake
Assuming a correct query is also an efficient one. Correctness and cost are independent. A right answer can still scan the whole table; always check what it read.
Better habit
Name columns and filter the raw partition key.
Confirm bytes scanned dropped, not just that the result is right.
Materialize repeated heavy aggregations.
Interview note
When a query is slow, narrate the usual suspects: “is it SELECT *? is the partition key filtered? did a function break pruning?” That checklist finds most warehouse waste fast.
Study tip
Re-derive each row of the anti-pattern table as a concrete query on query_runs or table_partitions. Spotting the fix is good; writing the cheaper query is the goal.
Remember this
Performance bugs are quiet: SELECT *, missing partition filters, functions that break pruning, and re-scanned history. Prune, filter the raw key, and verify the bytes dropped.
14 · Practice
Practice Lab
how much is pruned, what is most expensive, what scanned everything, who is driving cost, what is skewed, and what reads too much per partition.
⏱ 8 min · Topic 14 of 15
These six problems make warehouse tuning concrete on two metadata tables you would really query: table_partitions (per-partition rows and bytes) and query_runs (a query-history log of bytes scanned and partitions touched).
Treat each as a cost review: state what you are measuring, write the query, run it to read the numbers, then submit for scored feedback. Predict which queries or partitions will stand out before you run.
Core mental model
Each problem is one tuning question: how much is pruned, what is most expensive, what scanned everything, who is driving cost, what is skewed, and what reads too much per partition.
Why data engineers care
Reading about pruning and cost builds recognition; querying real metadata builds the instinct to find the expensive query and the skewed partition — the habit that keeps a warehouse fast and cheap under real budgets.
Starter shape to adaptworked example
SQL
Input data
query_runs4 rows
query_id
query_label
gb_scanned
2
orders_export
240
6
adhoc_select_star
180
4
event_funnel
85
1
daily_active_users
12
A slice of the query history; gb_scanned is the cost driver.
-- most cost queries rank or summarise the history / partition metadataselectquery_id,query_label,gb_scannedfromquery_runsorderbygb_scanneddesc,query_idlimit3;
Result · 3 rows
query_id
query_label
gb_scanned
2
orders_export
240
6
adhoc_select_star
180
4
event_funnel
85
The three heaviest queries. Adapt this shape to whichever tuning question each problem asks.
The template: choose the metadata table, pick rank / summary / ratio / flag, and order so the expensive or skewed rows surface first.
Common mistake
Copying the starter without stating which cost question it answers. The syntax feels familiar, but choosing the right metadata table and aggregation is the skill being practised.
Better habit
State what you are measuring (pruning, cost, skew) before writing.
Order so the expensive or skewed rows surface first.
Read the numbers before you submit.
Interview note
Narrate the cost question first: “I want bytes scanned per owner, so I’ll group the history by run_by and sum.” The framing shows you tune from evidence.
Study tip
After each problem, imagine the change that would make a query cheaper — a partition filter, fewer columns — and predict how the metadata would move.
Remember this
Apply one tuning question per problem on the metadata tables, order so the costly rows surface, and read the numbers before trusting them.
The final chapter turns everything into interview performance: clarifying assumptions, narrating query logic out loud, debugging live, and explaining senior-level tradeoffs the way an interviewer wants to hear them.
⏱ 3 min · Topic 15 of 15
Next chapter
SQL Interview Playbook
The final chapter turns everything into interview performance: clarifying assumptions, narrating query logic out loud, debugging live, and explaining senior-level tradeoffs the way an interviewer wants to hear them.