sql explain analyze is the primitive every senior data engineer, analytics engineer, and DBA reaches for at 3 a.m. when a dashboard query that used to run in 200 milliseconds suddenly takes 40 seconds — and it is the primitive most engineers can type on day one but only a small minority can actually read on day one thousand. The gap between "I know the keyword" and "I can look at a plan artefact and name the fix out loud in one breath" is the gap between a mid-level candidate who gets nervous when the interviewer opens the plan tab and a senior candidate who takes the mouse, points at the hot node, and says "the estimate is thirty times the actual — run ANALYZE, then rewrite the join." This guide is the honest tour of what actually happens inside the planner when you prefix a query with EXPLAIN (ANALYZE, BUFFERS, VERBOSE) — how the four numbers per node compose into a plan tree, why cost= is a made-up unit while actual time= is wall clock, how Snowflake's Query Profile, BigQuery's execution graph, and SQL Server's actual execution plan translate the same ideas into different artefacts, and how the same five-step reading discipline turns any of them into a concrete fix.
The tour walks the four engines you have to keep straight in 2026 — Postgres EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT) with its cost / rows / loops / actual-time-per-node grid and the BUFFERS clause that surfaces the shared-hit / shared-read / temp-read triangle where spilling hides, the Snowflake Query Profile with its operator cards, micro-partition pruning percent, and local-vs-remote disk spill panel that decides warehouse sizing, the BigQuery execution graph with its stage-based DAG, slot-milliseconds accounting, --dry_run cost estimator, and INFORMATION_SCHEMA.JOBS audit trail, and finally the SQL Server actual execution plan XML with SET STATISTICS TIME/IO ON and the graphical SSMS view. Each section ships a teaching block followed by a Solution-Tail interview answer — code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works — so you leave with the mental model, the syntax, and the senior read-strategy your next interviewer is listening for.
When you want hands-on reps immediately after reading, drill the SQL optimization practice library → for plan-reading and rewrite drills, warm up the index reflex with the SQL indexing room →, and layer the broader SQL practice surface → covering 450+ DE-focused problems that pair every plan artefact with a concrete rewrite.
On this page
- Why execution plans matter in 2026
- Postgres EXPLAIN ANALYZE anatomy
- Snowflake QUERY_PROFILE
- BigQuery execution graph
- Dialect matrix + 5-step reading strategy
- Cheat sheet — plan-reading recipe list
- Frequently asked questions
- Practice on PipeCode
1. Why execution plans matter in 2026
The sql query optimizer mental model — cost-based optimisation, statistics, cardinality estimation, and why every senior read-strategy starts with a plan
The one-sentence invariant: an execution plan is the planner's compiled decision tree for how to physically execute a logical SQL statement — which access method for each table, which join algorithm for each join, which order to evaluate joins in, whether to sort, whether to hash, whether to spill, whether to parallelise — and reading it is the fastest way to translate "this query is slow" into "here is the exact one-line fix". Every engine ships an optimiser; every optimiser makes decisions based on table statistics and a cost model; every cost model is imperfect; and every imperfect cost model produces a bad plan on some query somewhere in your product every week. The senior discipline is not to memorise a hundred rewrites — it is to open the plan, find the one node where estimate diverges from actual, and let that number tell you what to fix.
The planner mental model in five layers.
- Parser. Turns the SQL string into an abstract syntax tree. This step never fails silently — a syntax error surfaces as a parse error. Nothing interesting to read at this layer.
-
Rewriter. Applies rule-based transformations — view expansion, subquery flattening, predicate pushdown into the CTE / subquery boundary, constant folding. On Postgres this is the
pg_rewritelayer; on Snowflake it is folded into the optimiser; on BigQuery it happens before stage assignment. - Cost-based optimiser (CBO). Considers many equivalent plan shapes, assigns a cost to each based on table statistics (row counts, distinct values, most common values, histograms, correlation coefficient), and picks the cheapest. This is where "the planner got it wrong" usually happens — the stats are stale, the histogram is coarse, the correlation between two columns is off, or the CBO does not know about a functional dependency your data has.
-
Executor. Walks the chosen plan tree and returns tuples up the pipeline. Modern executors are pipelined (results stream up the tree) rather than materialised (each node writes to a temp table). The
EXPLAIN ANALYZEoutput shows the actual row count and wall clock at each node — the executor's ground truth. -
Statistics collector. A background job on every engine that samples tables, updates histograms, and feeds the CBO on the next planning cycle. On Postgres —
ANALYZEorautovacuum. On Snowflake — automatic and invisible. On BigQuery — table metadata refreshed continuously.
Cost units vs actual time — the two axes of every plan node.
-
Cost is a made-up unit. In Postgres, cost is measured in "arbitrary planner units" — a
Seq Scanon a 1-row table costs about 0.01 units; aHash Joinon a million rows might cost 250,000 units. The unit does not correspond to milliseconds, CPU cycles, or bytes. It is only meaningful relative to other plan alternatives during optimisation. -
Actual time is wall clock.
actual time=0.017..0.019means "the first row from this node was ready at 0.017 ms after query start; the last row at 0.019 ms." This is real, measurable, comparable across queries. Every senior read starts here — cost is the optimiser's guess, actual time is what happened. -
The estimate-vs-actual gap is the smoking gun. If a node has
rows=100 loops=1in the plan header butactual rows=100000 loops=1, the optimiser was off by a thousand times — a bad plan is almost guaranteed. Fix — runANALYZE, check for stale stats, or add extended statistics on correlated columns. - Cost is only comparable within a single plan. You cannot compare cost 12,500 for query A against cost 8,700 for query B on the same table — the units are internal to the planner, not portable across queries.
What senior interviewers actually probe when they open the plan tab.
-
Do you know how to read the plan tree? Bottom-up, inner-most first. The
Limitat the top is the last node executed; theSeq Scan/Index Scanat the bottom is the first. Candidates who read top-down miss the hot leaf every time. -
Do you know the node types?
Seq Scan= full table scan.Index Scan= seek + heap fetch.Bitmap Heap Scan= index-driven heap fetch with bitmap OR.Hash Join= build hash table on smaller side, probe with larger.Merge Join= zip two pre-sorted streams.Nested Loop= outer × inner.Sort= sort operator (may spill).Hash Aggregate= hash-basedGROUP BY.Gather= parallel-worker collector. -
Do you know when the plan is bad? Estimate off by 10× or more.
Buffers: temp readnon-zero (spilling to disk).Nested Loopon two large tables.Seq Scanwhere an index would work.Sort → Hash Aggregatewhere theSortis doing all the work. -
Do you know the fix? Run
ANALYZE. Add a covering index. Rewrite a correlated subquery into aJOIN. Add aLATERALwhere a scalar subquery loops. Split aWITH RECURSIVEinto a materialised temp table. Increasework_memfor a specific session. Addpg_hint_planhints in exceptional cases. -
Do you know the trade-offs? Adding an index speeds reads but slows writes. Increasing
work_memspeeds sorts but risks OOM under concurrency. Materialising a CTE eats memory but avoids a recompute. Hints lock the plan — great for known cases, terrible when data shifts.
The reading discipline — bottom-up, node-by-node, cost-vs-actual ratio.
-
Step 1 — bottom-up. Find the leaf nodes (scans,
Valuesclauses, function-returning-set nodes). Read theiractual rowsandactual time. This is what the executor read from disk (or memory). -
Step 2 — cost-vs-actual ratio. For each node, compare
rows=(estimate) toactual rows=. A ratio within 2× is fine. 10× is worth investigating. 100× is almost always a bad plan. -
Step 3 — hot node. Find the node whose
actual timeis the largest fraction of total wall clock. This is the node to optimise. Speeding up a node that takes 0.1 ms of a 40 s query is pointless. -
Step 4 — spill / shuffle / parallelism. Check for
Buffers: temp read(spill),Sort Method: external merge(bigger spill),Workers Launched: 0(parallelism disabled),Rows Removed by Filter: 999999(post-filter waste). Each is a specific fix. - Step 5 — the one-line rewrite. Based on the hot node's failure mode, name the fix out loud — add index, rewrite join, increase memory, run ANALYZE, add hint. Senior candidates land on the fix within thirty seconds of opening the plan.
Why interviewers open plan tabs in senior loops.
-
It's a fluency check on the executor mental model. Anyone can type
EXPLAIN; the senior signal is being able to look at "Hash Join (cost=12500..37500 rows=1000 width=64) (actual time=45..190 rows=1000000 loops=1)" and say "estimate was a thousand rows, actual was a million — the join is spilling to disk and the plan is wrong" in one breath. -
It's a fluency check on the storage model. Postgres reads pages; Snowflake reads micro-partitions; BigQuery reads columnar files. The
Buffersline, thebytes scannedline, thePartitions scannedline — each surfaces the storage cost that differs per engine. - It's a fluency check on cost. On BigQuery, wrong plan = wrong bill ($5/TB scanned on-demand). On Snowflake, wrong plan = wrong warehouse size (2× per tier). On Postgres, wrong plan = wrong tail latency (100 ms → 40 s). Senior candidates can quantify the cost of a bad plan.
- It's a fluency check on system design. "Design a Postgres schema to support this dashboard" — great answers include the index plan (which composite indexes, in which order), the analyse cadence, and how the cost model reacts to skew. The plan surface is the system design surface.
-
It's a fluency check on debugging discipline. "The query got 100× slower after a data load — walk me through your diagnosis." Every senior answer starts with
EXPLAIN ANALYZEon the slow and fast versions and diffs the plan trees.
Worked example — the day-one EXPLAIN on a mystery-slow query
Detailed explanation. The archetype: a dashboard query that used to run in 200 ms now takes 40 seconds. The junior instinct is to add an index; the senior instinct is to open the plan and let the numbers name the fix. The single most useful EXPLAIN variant on Postgres is EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT).
Question. Given the query below on a Postgres 15 database, prefix it with the right EXPLAIN variant to surface plan shape, actual timings, buffer accounting, and node targeting. Name the four things you would read first.
Input.
-- The mystery-slow query
SELECT o.customer_id, SUM(o.total) AS lifetime_value
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.country = 'US'
AND o.created_at >= NOW() - INTERVAL '30 days'
GROUP BY o.customer_id
ORDER BY lifetime_value DESC
LIMIT 100;
Code.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)
SELECT o.customer_id, SUM(o.total) AS lifetime_value
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.country = 'US'
AND o.created_at >= NOW() - INTERVAL '30 days'
GROUP BY o.customer_id
ORDER BY lifetime_value DESC
LIMIT 100;
Step-by-step explanation.
-
EXPLAINalone prints the planned tree with estimates only — no query executes. Use this when the query is prohibitively expensive to actually run. -
ANALYZEruns the query and returns realactual timeandactual rowsper node. This is what you want 95% of the time — the plan the executor actually walked. -
BUFFERSadds theshared hit / shared read / temp readtriangle per node — cache hits, cold buffer reads from disk, and temp writes (spill). Non-zerotemp readis a bright-red flag. -
VERBOSEadds the column list and expression detail for each node — expensive to eyeball but crucial when you need to know exactly which columns are projected at each layer. -
FORMAT TEXT(the default) is the classic indented tree.FORMAT JSONis machine-readable for CI regression tests;FORMAT YAMLis a middle ground.
Output (elided).
Limit (cost=250.31..250.56 rows=100 width=16) (actual time=42.512..42.548 rows=100 loops=1)
Buffers: shared hit=1234, read=456, temp read=42 written=42
-> Sort (cost=250.31..252.81 rows=1000 width=16) (actual time=42.510..42.520 rows=100 loops=1)
Sort Key: (sum(o.total)) DESC
Sort Method: top-N heapsort Memory: 33kB
...
Rule of thumb. For 95% of interview-flow diagnosis and on-call debugging, EXPLAIN (ANALYZE, BUFFERS) is the right variant. Add VERBOSE when you suspect a projection issue; add FORMAT JSON when you want to diff plans across two queries programmatically.
Worked example — cost units are not milliseconds (the day-two lesson)
Detailed explanation. The most common junior misconception: reading cost=12500..37500 and treating those as milliseconds. They are not. They are internal planner units used only to rank plan alternatives. The single number that translates to real time is actual time=.
Question. Given the plan fragment below, which numbers are milliseconds and which are made-up units? What is the total wall-clock time of the query?
Input.
Limit (cost=12500.31..12500.56 rows=100 width=16) (actual time=42.512..42.548 rows=100 loops=1)
-> Sort (cost=12500.31..12502.81 rows=1000 width=16) (actual time=42.510..42.520 rows=100 loops=1)
-> HashAggregate (cost=12495.00..12497.50 rows=1000 width=16) (actual time=41.900..42.400 rows=850 loops=1)
-> Hash Join (cost=1250.00..12480.00 rows=100000 width=12) (actual time=25.100..40.500 rows=98000 loops=1)
...
Planning Time: 0.150 ms
Execution Time: 42.702 ms
Code. (No code — a plan-reading exercise.)
made-up units: cost=..
milliseconds: actual time=..
row counts: rows= (estimate) and actual rows= (real)
loops: actual loops=N — number of times this node was executed
totals: Planning Time (ms) + Execution Time (ms)
Step-by-step explanation.
-
cost=12500.31..12500.56— the two numbers are the estimated startup cost and total cost, in arbitrary planner units. Startup cost is what the node pays before emitting its first row; total cost is what it pays to emit all rows. -
actual time=42.512..42.548— the two numbers are the real wall-clock times for the first and last row emitted by this node, in milliseconds. Ifloops > 1, these are the per-loop averages, not the total. -
rows=100— the optimiser's estimate.actual rows=100— the real count. If these differ by 10×, the plan is likely suboptimal. -
loops=1— this node executed once. Nodes inside a nested loop, function scan, or subquery may execute many times; in that case wall-clock time =actual time (per loop) × loops. -
Execution Time: 42.702 ms— the true wall clock for the query.Planning Time— how long the optimiser took to plan (usually sub-millisecond; large for many-JOIN queries).
Output. Wall-clock total = 42.702 ms. The cost numbers (12500, 12480) are not milliseconds — do not multiply them.
Rule of thumb. Read actual time for wall clock. Read cost only to compare plan alternatives when explaining why the optimiser chose plan A over plan B. Never quote cost numbers to a business stakeholder.
Worked example — bottom-up reading discipline
Detailed explanation. The plan tree in FORMAT TEXT is nested — inner-most first. Execution walks bottom-up: leaf nodes (scans) produce rows, they feed intermediate nodes (joins, aggregates), which feed the top node (Limit, Sort). Reading top-down misses which leaf is doing the work.
Question. Given the plan below, which node is the bottleneck? Which node should you optimise? Give the reasoning.
Input.
Limit (actual time=1200.5..1200.6 rows=100 loops=1)
-> Sort (actual time=1200.4..1200.5 rows=100 loops=1)
-> HashAggregate (actual time=1198.3..1199.1 rows=850 loops=1)
-> Hash Join (actual time=100.5..1195.4 rows=98000 loops=1)
-> Seq Scan on orders o (actual time=99.0..1150.0 rows=10000000 loops=1)
Filter: (created_at >= now() - '30 days'::interval)
Rows Removed by Filter: 90000000
-> Hash (actual time=0.5..0.5 rows=1000 loops=1)
-> Seq Scan on customers c (actual time=0.1..0.4 rows=1000 loops=1)
Filter: (country = 'US'::text)
Code. (No code — a plan-reading exercise.)
Step-by-step explanation.
- Read bottom-up. The two
Seq Scanleaves execute first.Seq Scan on customers c— 0.4 ms — cheap.Seq Scan on orders o— 1150 ms — this is the hot node. -
Rows Removed by Filter: 90000000on the orders scan — the engine read 100 M rows and threw away 90 M of them via thecreated_at >= NOW() - INTERVAL '30 days'filter. That means no index oncreated_atexists, or the filter is not sargable. - The
Hash Joinat100.5..1195.4inherits the cost of the orders scan (~1095 ms of the join time is upstream from the scan). The join itself is cheap. -
HashAggregateandSortare microseconds — they operate on 100 K joined rows, not 100 M. - The fix is at the leaf — add an index on
orders(created_at). The join, aggregate, and sort are already optimal.
Output. The bottleneck is Seq Scan on orders o. Fix: CREATE INDEX ON orders(created_at). Estimated speed-up: 100 M reads → ~1 M reads = ~100× — from ~1200 ms to ~15 ms.
Rule of thumb. Bottom-up. Always. The leaf that dominates wall clock is the leaf to optimise. Top-level nodes rarely need attention unless they explicitly spill.
Common beginner mistakes
- Reading the plan top-down — missing the hot leaf.
- Treating
cost=as milliseconds — confusing internal units for wall clock. - Ignoring
Rows Removed by Filter— a filter that discards 90% of rows is telling you which index to add. - Skipping
BUFFERS— the temp-read line is the smoking gun for spilling. - Confusing
loops=1withloops=N— inside a nested loop, per-loop times must be multiplied byloops.
SQL interview question on when to reach for EXPLAIN ANALYZE
A senior interviewer often opens with: "You're on-call and a dashboard query for the CFO went from 200 ms to 40 seconds after last night's data load. Walk me through your first ten minutes of diagnosis. Which EXPLAIN variant do you run, which numbers do you read first, and what are the top three fixes you would try in order?"
Solution Using EXPLAIN (ANALYZE, BUFFERS) on the slow and fast versions, diffing plan trees
-- Step 1 — capture the slow plan
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)
SELECT customer_id, SUM(total)
FROM orders
WHERE created_at >= '2026-06-10'
GROUP BY customer_id
ORDER BY SUM(total) DESC
LIMIT 100;
-- Step 2 — capture the fast plan by rolling back one week
-- (via a read replica pinned to yesterday's snapshot, or a staging DB)
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)
SELECT customer_id, SUM(total)
FROM orders
WHERE created_at >= '2026-06-03'
GROUP BY customer_id
ORDER BY SUM(total) DESC
LIMIT 100;
-- Step 3 — refresh statistics if the slow plan estimate is off by 10× or more
ANALYZE orders;
-- Step 4 — re-run the slow plan
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, SUM(total)
FROM orders
WHERE created_at >= '2026-06-10'
GROUP BY customer_id
ORDER BY SUM(total) DESC
LIMIT 100;
-- Step 5 — if still slow, check for a missing index by hunting Rows Removed by Filter
-- Fix: CREATE INDEX ON orders(created_at) or a composite (customer_id, created_at DESC)
Step-by-step trace.
| Step | Action | Reads |
|---|---|---|
| 1 | Prefix slow query with EXPLAIN (ANALYZE, BUFFERS)
|
Plan tree with cost/actual/loops/buffers |
| 2 | Compare to fast-version plan on replica | Diff shows which node's cost or rows changed |
| 3 | Look for rows= vs actual rows= gap ≥ 10× |
Indicates stale statistics |
| 4 | Run ANALYZE orders to refresh statistics |
Optimiser now has fresh histograms |
| 5 | Re-EXPLAIN — if plan is now fast, root cause was stale stats |
Fixed |
| 6 | If still slow, look for Rows Removed by Filter: NNN
|
Missing index — add one |
| 7 | Verify final plan is Index Scan not Seq Scan at the hot leaf |
Ship |
Output:
| Diagnosis outcome | Root cause | One-line fix |
|---|---|---|
| Estimate rows 10× < actual | Stale statistics | ANALYZE orders; |
Rows Removed by Filter in the millions |
Missing index | CREATE INDEX ON orders(created_at); |
Buffers: temp read=NNN non-zero |
Sort or hash spill |
SET work_mem = '128MB'; (session) |
Nested Loop on two large tables |
Bad join estimate | Rewrite join, add composite index, or hint |
Seq Scan on a large table with cheap filter |
Selectivity < 1% but no index | Add partial index, or composite index |
Why this works — concept by concept:
-
Diff two plans — a slow-and-a-fast plan on the same query shape (yesterday vs today, prod vs replica) instantly localises the regression. If
Hash Joinwas cheap yesterday and expensive today, the stats on the join columns are stale or the data distribution shifted. -
ANALYZEfixes stale statistics — after a big data load, the planner's histograms and MCV lists are out of date. RunningANALYZE tablere-samples and refreshes them; the nextEXPLAINon the same query often shows a completely different, correctly-costed plan. -
Rows Removed by Filternames the missing index — the executor is telling you "I read N rows, threw away most of them via this filter." That filter should be an index seek instead of a scan. Add the index and the filter becomes anIndex Cond— zero rows discarded. -
BUFFERS: temp readis spilling — the planner allocated a sort or hash that outgrewwork_mem. Two fixes: rewrite the query to sort or aggregate less (LIMITearlier, smaller GROUP BY), or bumpwork_memfor this session. Never bump it globally — under concurrency, every session gets its own copy and you run out of RAM. -
Cost —
EXPLAINalone is free (planning cost only).EXPLAIN ANALYZEruns the query — expensive if the query is slow. On production, preferEXPLAINalone for exploratory diagnosis; useEXPLAIN ANALYZEwhen you can afford the extra wall clock or when the query returns quickly enough. The read discipline itself isO(depth of plan tree)— a five-node plan reads in five seconds.
SQL
Topic — optimization
SQL optimization drills
2. Postgres EXPLAIN ANALYZE anatomy
postgres explain analyze — the four numbers per node, the six major node types, and how BUFFERS surfaces the spill triangle
The mental model in one line: every Postgres plan node emits four numbers — cost (planner units), rows (estimate), actual time (wall clock), and loops (executions) — plus a variable-length BUFFERS triangle of shared hit / shared read / temp read that reveals whether the node lived in cache, hit cold buffers, or spilled to disk. Once you memorise the four numbers per node and the six major node types, you can read almost any Postgres plan in one pass.
Slot 1 — the four numbers per node.
-
cost=startup..total— planner units. Startup cost is what the node pays before emitting its first row (sort must complete before emit; hash must build before probe). Total cost is the price to emit all rows. Ratiototal / startupreveals whether the node is pipelined (near 1:1) or blocking (large gap). -
rows=N— the planner's estimate. Comes from table statistics (pg_stats) and the selectivity of the predicates above. Skewed columns without extended statistics produce bad estimates. -
actual time=first..last— wall clock (ms). First is when the first row was ready; last is when the last row was ready. If loops > 1, both are per-loop averages. -
actual rows=N— the real count. Ifrows=100butactual rows=100000, the optimiser was off by 1000× — almost certainly a bad plan. -
loops=N— how many times the executor ran this node.loops=1for most;loops=Nfor nodes inside a nested loop or a subquery per outer row. Wall-clock cost of the node =actual time × loops.
Slot 2 — the six major node types you must recognise on sight.
-
Seq Scan— full table scan. Reads every row in the table, applies filters row-by-row. Fast on small tables; catastrophic on big tables when a filter has good selectivity (should have been an index). -
Index Scan— btree seek + heap fetch. Uses an index to locate rows, then reads the heap page. Best when selectivity is high (< 1% of table) and the projected columns are mostly in the index or need heap fetch. -
Index Only Scan— btree seek without heap fetch. Available when all projected columns are covered by the index. Fastest read path — often 10× faster thanIndex Scanbecause it skips heap I/O. -
Bitmap Heap Scan— index-driven with heap fetch, but the executor first builds a bitmap of matching heap pages, then reads them in sorted order. Fast on medium-selectivity filters (1–10% of table) — better thanSeq Scanbecause it skips non-matching pages, better than plainIndex Scanbecause it batches heap I/O. -
Hash Join— build a hash table on the smaller side, probe with the larger.O(build_side_rows + probe_side_rows). Great for large tables with no useful sort order; may spill if the build side exceedswork_mem. -
Merge Join— zip two pre-sorted streams.O(N + M)after sort, orO(N + M)if both sides come pre-sorted from an index. Great when input is already sorted; expensive if you have to explicitly sort first. -
Nested Loop— outer × inner: for each outer row, execute the inner.O(outer × inner). Fine when outer is tiny; catastrophic when outer is millions. -
Sort— external sort operator. Cheap when input fitswork_mem; expensive when it spills totemp read.Sort Method: quicksort= in-memory;Sort Method: top-N heapsort= smart bounded sort forORDER BY ... LIMIT N;Sort Method: external merge= spilled. -
Hash Aggregate/Group Aggregate— hash-based or sort-basedGROUP BY.Hash Aggregatebuilds a hash on group keys;Group Aggregaterequires pre-sorted input.Hash Aggregatemay spill;Group Aggregatenever does but requires aSortupstream. -
Gather/Gather Merge— parallel worker collector.Gathercollects rows in arbitrary order;Gather Mergepreserves sort order from workers. Presence of these means the planner chose parallel execution —parallel_workersandmax_parallel_workers_per_gathercontrol this.
Slot 3 — the BUFFERS triangle: shared hit / shared read / temp read.
-
shared hit=N— pages served from the buffer cache (shared buffers). Fast — RAM read, no disk I/O. -
shared read=N— pages read from disk into the buffer cache. Slower than hit; each read is ~100 μs on SSD, ~5 ms on spinning disk. High shared read = cold cache; may warm up on subsequent runs. -
shared dirtied=N— pages modified by the query (usually only for DML). Ignore for read-only diagnosis. -
temp read=N/temp written=N— pages the executor spilled to disk because aSort,Hash Join, orHash Aggregateoverflowedwork_mem. This is the spill signal. Any non-zero temp read means the query is thrashing — fix is either query rewrite (limit earlier, smaller GROUP BY) orSET work_mem = '128MB'for the session. -
shared read / (shared hit + shared read)— the cache miss rate. Under 5% is fine; over 50% means the working set doesn't fit inshared_buffersor the buffer cache is being churned.
Slot 4 — reading a plan bottom-up in five moves.
-
Move 1 — identify the leaves. Every
Seq Scan,Index Scan,Bitmap Heap Scan,Values Scan,Function Scanis a leaf. Read theiractual time × loopsfor total per-leaf cost. -
Move 2 — find the hot leaf. The leaf with the largest
actual time × loopsis the bottleneck. IfSeq Scan on ordersis 1200 ms and everything else is 5 ms, the fix is at that scan. -
Move 3 — check
Rows Removed by Filter. If aSeq ScanhasFilter: created_at > NOW() - INTERVAL '30 days'andRows Removed by Filter: 99000000, the filter should be an index. Add one. -
Move 4 — check estimate-vs-actual. For each node, compare
rows=toactual rows=. A 10× gap is worth investigating; a 100× gap almost always means a bad plan. RunANALYZE. -
Move 5 — check
BUFFERS: temp read. Non-zero temp read = spill. Fix by rewriting the query or bumpingwork_mem.
Slot 5 — the estimate-vs-actual failure modes.
-
Stale statistics. After a data load, the histograms and MCV lists are out of date. Run
ANALYZE tableto refresh. -
Skewed columns without extended stats. A column with 90% of rows having value
'X'and 10% having other values will fool the optimiser unless you create extended statistics:CREATE STATISTICS s ON col1, col2 FROM t;. -
Correlated columns. If
countryandcityare highly correlated but the planner assumes independence, it can be off by 100× on multi-column predicates. Fix — extended statistics on(country, city). -
Function-wrapped columns.
WHERE EXTRACT(YEAR FROM created_at) = 2026is not sargable — the planner can't use the histogram. Rewrite:WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'. -
Prepared-statement plan reuse. With
plan_cache_mode = 'auto', Postgres may pick a generic plan that is bad for specific parameter values. Forceplan_cache_mode = 'force_custom_plan'if the shape hurts you.
Slot 6 — the eight most common Postgres plan pathologies.
-
Seq Scanon a large filtered table. Missing index. Fix —CREATE INDEX ON t (filter_col);or a composite index if multiple filters combine. -
Nested Loopwith millions on the outer side. Very slow — should beHash Join. Fix — check estimates; if the planner underestimated the outer, runANALYZE. If the estimate is right and the plan is still bad, raiseenable_nestloop = off(dangerous — use only after diagnosis). -
Sortwithexternal mergemethod. Spilling. Fix — rewrite to sort less (addLIMIT, smaller GROUP BY, pre-aggregate), or bumpwork_mem. -
Hash Joinwithtemp readnon-zero. Spilling. Fix — swap build side (planner may have picked the wrong side), or bumpwork_mem. -
Bitmap Heap Scanreading most of the table. The bitmap is close to full — the index isn't selective enough. Fix — either acceptSeq Scan(which is what the planner would pick with better stats), or add a partial index. -
Index Scanwith highRows Removed by Index Recheck. The index was used but many rows didn't match the full filter — the index only covered part of the predicate. Fix — extend the index to a coveringINCLUDEindex. -
Parallel Seq ScanwithWorkers Launched: 0. Parallelism was disabled — usually becausemax_parallel_workers_per_gather = 0or the table is too small (min_parallel_table_scan_size). Fix — configure appropriately. -
CTE Scanon a materialised CTE. Pre-PG12 CTEs were materialised (always). PG12+ inlines by default; if a CTE is materialised on new Postgres, it's an explicitWITH ... AS MATERIALIZED— verify that was intentional.
Slot 7 — EXPLAIN variants you should know.
-
EXPLAIN query— plan-only, no execution. Free but based on estimates only. Useful for expensive queries or when you can't afford to run. -
EXPLAIN ANALYZE query— runs the query and returns actual timings. The everyday variant. -
EXPLAIN (ANALYZE, BUFFERS)— adds theBUFFERStriangle. Use always for on-call diagnosis. -
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)— adds column projections. Useful for projection issues. -
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)— machine-readable. Great for CI regression tests, plan-diffing tooling, andpg_stat_statementscorrelation. -
EXPLAIN (ANALYZE, BUFFERS, WAL)— adds WAL (write-ahead log) accounting for DML. Useful when diagnosing slow INSERTs / UPDATEs. -
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)— adds non-default GUC settings. Useful when a query works differently on prod vs a replica.
Common beginner mistakes
- Running
EXPLAINwithoutANALYZEon a slow query and drawing conclusions from estimates only. - Bumping
work_memglobally after seeing a single spill — every backend gets its own copy and OOM under concurrency. - Adding an index without checking that the plan changes — sometimes the planner still prefers
Seq Scandue to bad stats or low selectivity. - Reading top-down — missing which leaf is the actual bottleneck.
- Ignoring
loops— aNested Loopwithactual time=0.1ms loops=1000000costs 100 seconds, not 0.1 ms. - Copying
EXPLAINoutput into Slack without formatting — a plan tree is nearly unreadable when line breaks are lost.
Worked example — the Seq Scan that should have been an Index Scan
Detailed explanation. The most common Postgres plan bug: a Seq Scan on a large table where a filter has good selectivity. The plan shows a Seq Scan with Rows Removed by Filter in the millions; the fix is a single-column index.
Question. Given the plan below on an orders table (100 M rows), identify the fix. Show the DDL that would flip the plan to Index Scan (or Bitmap Heap Scan) and reduce wall clock from ~1200 ms to under 20 ms.
Input.
QUERY PLAN
Limit (cost=... rows=100 width=16) (actual time=1200.5..1200.6 rows=100 loops=1)
Buffers: shared hit=1234 read=456
-> Sort (cost=... rows=100 width=16) (actual time=1200.4..1200.5 rows=100 loops=1)
-> Seq Scan on orders o (cost=... rows=1000000 width=12) (actual time=99.0..1150.0 rows=1000000 loops=1)
Filter: (created_at >= now() - '30 days'::interval)
Rows Removed by Filter: 99000000
Buffers: shared hit=1234 read=456
Code.
-- Fix: add index on the filter column
CREATE INDEX CONCURRENTLY idx_orders_created_at
ON orders (created_at DESC);
-- Verify the plan flip
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, SUM(total)
FROM orders
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY customer_id
ORDER BY SUM(total) DESC
LIMIT 100;
Step-by-step explanation.
- The current plan reads 100 M rows (
Seq Scan on orders) and discards 99 M via thecreated_atfilter — clear signal of a missing index oncreated_at. -
CREATE INDEX CONCURRENTLY— theCONCURRENTLYclause avoids taking anACCESS EXCLUSIVElock, so writes continue during index build. Takes longer (double-scan) but keeps prod live. -
DESCin the index matches the query'sORDER BY SUM(total) DESC— but the sort is on the aggregate, notcreated_at; soDESChere doesn't help ordering. It's still fine because btree indexes can be scanned in either direction. - After the index exists, run
ANALYZE orders(or wait for autovacuum). The optimiser now has fresh selectivity estimates forcreated_atand will pickIndex ScanorBitmap Heap Scanwhen the filter is selective enough. - Re-run
EXPLAIN (ANALYZE, BUFFERS)— the new plan should showBitmap Heap ScanorIndex Scanonorderswithactual rows=1000000(the filter-matching rows only) and wall clock under 20 ms.
Output.
| Before | After |
|---|---|
Seq Scan on orders |
Bitmap Heap Scan on orders |
actual rows=1000000 loops=1 |
actual rows=1000000 loops=1 |
Rows Removed by Filter: 99000000 |
Rows Removed by Filter: 0 |
| Buffers: shared read=456 | Buffers: shared read=12 |
| Wall clock ~1200 ms | Wall clock ~15 ms |
Rule of thumb. Any Seq Scan with Rows Removed by Filter in the millions is a missing-index diagnosis. The DDL is one line; the wall-clock win is 80–100× typical.
Worked example — the Sort that spills and how work_mem fixes it
Detailed explanation. A common failure mode: a Sort node with Sort Method: external merge and temp read in the buffers. The engine ran out of work_mem and spilled to a temp file. Two fixes — rewrite to sort less, or increase work_mem.
Question. Given the plan below with a spilled Sort, show two fixes and their trade-offs.
Input.
Sort (cost=... rows=10000000 width=64) (actual time=8500.0..12000.0 rows=10000000 loops=1)
Sort Key: total DESC
Sort Method: external merge Disk: 640000kB
Buffers: shared hit=5000, temp read=80000 written=80000
-> Seq Scan on orders (cost=... rows=10000000 width=64) (actual time=0.5..2200.0 rows=10000000 loops=1)
Code.
-- Fix A — bump work_mem for this session only (safe, cheap)
SET work_mem = '256MB';
SELECT * FROM orders ORDER BY total DESC;
RESET work_mem;
-- Fix B — rewrite to sort less (best when you only want top-N)
SELECT * FROM orders
ORDER BY total DESC
LIMIT 100;
-- Postgres picks Sort Method: top-N heapsort — bounded memory, no spill
-- Fix C — pre-aggregate before sorting (best when downstream only needs summaries)
SELECT customer_id, SUM(total) AS total
FROM orders
GROUP BY customer_id
ORDER BY total DESC
LIMIT 100;
Step-by-step explanation.
- The current plan sorts 10 M rows and spills 640 MB to disk (
Sort Method: external merge Disk: 640000kB). Wall clock is 12 s. - Fix A —
SET work_mem = '256MB'for the session. TheSortfits in memory, method changes toquicksort, wall clock drops to ~3 s. NeverALTER SYSTEM SET work_mem = '256MB'— under concurrency, 100 sessions × 256 MB = 25 GB of hash / sort memory. - Fix B — add
LIMIT 100. The planner picksSort Method: top-N heapsort— a bounded heap of size N. Memory usage isN × row_width, not full-table. Wall clock drops to ~2 s. - Fix C — pre-aggregate. Instead of sorting 10 M raw rows, sort 100 K aggregated rows.
Hash Aggregate → Sort → Limit. Wall clock ~500 ms. - Prefer C > B > A. Rewriting the query is usually cheaper than tuning memory; increasing memory is the last resort when the query truly needs a big sort.
Output.
| Fix | Sort Method | Disk spill | Wall clock |
|---|---|---|---|
| None | external merge | 640 MB | 12 s |
A: work_mem=256MB
|
quicksort | 0 | 3 s |
B: LIMIT 100
|
top-N heapsort | 0 | 2 s |
| C: pre-aggregate + LIMIT | quicksort | 0 | 500 ms |
Rule of thumb. Diagnose spill first — the Sort Method: external merge line and non-zero temp read are the smoking gun. Prefer to rewrite the query; bump work_mem only for the session, never globally.
Worked example — EXPLAIN (ANALYZE, BUFFERS) on a Hash Join bottleneck
Detailed explanation. A Hash Join between two large tables can dominate wall clock when the build side is bigger than expected. Reading BUFFERS reveals whether the hash is spilling.
Question. Given the plan below with a slow Hash Join, walk through diagnosis. Which side is the build side? What does temp read on the hash imply? What's the fix?
Input.
Hash Join (cost=... rows=1000000 width=32) (actual time=4500.0..8500.0 rows=980000 loops=1)
Hash Cond: (o.customer_id = c.id)
Buffers: shared hit=1000 read=4500 temp read=25000 written=25000
-> Seq Scan on orders o (cost=... rows=10000000 width=16) (actual time=0.5..3000.0 rows=10000000 loops=1)
-> Hash (cost=... rows=5000000 width=20) (actual time=4200.0..4200.0 rows=5000000 loops=1)
Buckets: 65536 Batches: 8 Memory Usage: 4096kB
-> Seq Scan on customers c (cost=... rows=5000000 width=20) (actual time=0.5..2000.0 rows=5000000 loops=1)
Code.
-- Fix A — bump work_mem for the session
SET work_mem = '256MB';
-- Fix B — swap the build side by rewriting (planner picked wrong)
-- The customers table is bigger than orders in this query, but customers should be filtered first
SELECT *
FROM orders o
JOIN (SELECT id FROM customers WHERE country = 'US') c
ON c.id = o.customer_id;
-- Now the hash is built on filtered customers (much smaller), not raw customers
-- Fix C — force the planner via a subquery / CTE materialisation
WITH us_customers AS MATERIALIZED (
SELECT id FROM customers WHERE country = 'US'
)
SELECT o.*
FROM orders o
JOIN us_customers c ON c.id = o.customer_id;
Step-by-step explanation.
-
Hashis the build side — Postgres always builds the hash on the bottom child ofHash Join. Here, that'scustomers c(5 M rows). -
Batches: 8— the hash was partitioned into 8 batches because it didn't fit inwork_mem. Batches > 1 means spilling. -
Buffers: shared hit=1000 read=4500 temp read=25000— thetemp read=25000is the smoking gun. 25 000 pages × 8 KB = 200 MB spilled. - Fix A — bump
work_mem. If bumped to 256 MB,Batchesdrops to 1 andtemp readgoes to 0; wall clock roughly halves. - Fix B — swap sides. If
customersis filtered by a selectivity predicate (country = 'US'), do the filter before the join. The filtered customers hash is 500 K rows (10% of 5 M), fits inwork_mem, no spill.
Output.
| Fix | Batches | temp read | Wall clock |
|---|---|---|---|
| None | 8 | 200 MB | 8.5 s |
| A: work_mem=256MB | 1 | 0 | 4.5 s |
| B: pre-filter build side | 1 | 0 | 1.5 s |
| C: MATERIALIZED CTE | 1 | 0 | 1.5 s |
Rule of thumb. Batches > 1 on a Hash node = spilling. Prefer to filter the build side down before the join; work_mem bump is the last resort.
postgres explain analyze interview question on a two-plan diff
A senior interviewer often asks: "Here are two EXPLAIN (ANALYZE, BUFFERS) outputs for the same query — one from prod and one from a staging DB with the same data. Prod is 30 seconds; staging is 300 ms. Walk me through the diff and name the root cause."
Solution Using bottom-up plan diffing to isolate the regressing node
-- Prod plan (slow — 30 s)
Limit (actual time=30000..30000 rows=100 loops=1)
Buffers: shared hit=5000 read=45000 temp read=8000
-> Sort (actual time=29995..30000 rows=100 loops=1)
Sort Method: external merge Disk: 64000kB
-> HashAggregate (actual time=28000..29500 rows=850000 loops=1)
-> Hash Join (actual time=100..27000 rows=1000000 loops=1)
Buffers: temp read=8000
-> Seq Scan on orders (actual time=0.5..12000 rows=100000000 loops=1)
-> Hash (actual time=500..500 rows=5000000 loops=1)
Batches: 8 Memory Usage: 4096kB
-> Seq Scan on customers (actual time=0.5..300 rows=5000000 loops=1)
-- Staging plan (fast — 300 ms)
Limit (actual time=290..300 rows=100 loops=1)
Buffers: shared hit=1500 read=200
-> Sort (actual time=289..300 rows=100 loops=1)
Sort Method: top-N heapsort Memory: 33kB
-> HashAggregate (actual time=250..280 rows=850000 loops=1)
-> Hash Join (actual time=50..220 rows=1000000 loops=1)
-> Bitmap Heap Scan on orders (actual time=1..80 rows=1000000 loops=1)
Recheck Cond: (created_at >= '2026-06-10')
-> Bitmap Index Scan on idx_orders_created_at
-> Hash (actual time=100..100 rows=5000000 loops=1)
Batches: 1 Memory Usage: 512000kB
-> Seq Scan on customers (actual time=0.5..80 rows=5000000 loops=1)
Step-by-step trace.
| Node | Prod actual time | Staging actual time | Diff |
|---|---|---|---|
| Seq/Bitmap Scan on orders | 12000 ms (Seq, 100M rows) | 80 ms (Bitmap, 1M rows) | Missing index on prod |
| Hash (customers) | 500 ms, Batches: 8 | 100 ms, Batches: 1 | work_mem too low on prod |
| Sort | external merge, 64 MB spill | top-N heapsort, 33 kB | Sort method differs — cascades from filter |
| Total wall clock | 30 s | 300 ms | 100× gap |
Prod is missing an index on orders(created_at) — the Seq Scan reads 100 M rows and the filter discards 99 M. Staging has the index; the plan flips to Bitmap Heap Scan reading only 1 M matched rows. Prod's work_mem is also too low — the customers hash spills to 8 batches; staging has bigger work_mem and fits in 1 batch. Two fixes, one root cause per — but the dominant fix is the index.
Output:
| Fix priority | Change | Est. wall clock after |
|---|---|---|
| 1 |
CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders(created_at); then ANALYZE orders;
|
~500 ms |
| 2 |
ALTER SYSTEM SET work_mem = '32MB'; (was 4 MB) then restart / reload |
~350 ms |
| 3 | Ensure both plans are on Postgres 15+ (top-N heapsort improvements) | ~300 ms |
Why this works — concept by concept:
-
Bottom-up diff — comparing plan trees leaf-first surfaces the storage-access divergence immediately. The prod
Seq Scanvs stagingBitmap Heap Scanis a one-line diff that pinpoints the missing index. -
Rows Removed by Filter— even without staging as a reference, prod's plan alone shows 99 M rows discarded by a filter. That is the missing-index tell every time. -
Batches: N > 1— theHashnode'sBatches: 8line means the hash spilled to 8 partitions on disk. Combined withMemory Usage: 4096kBandBuffers: temp read=8000, it names the spill and its size (~64 MB). -
Sort Methoddivergence — prod usesexternal merge(spilled); staging usestop-N heapsort(bounded). The method differs because the row count reaching the sort differs — the filter pathology upstream cascades all the way down. -
Cost — Diagnosis cost = one
EXPLAIN (ANALYZE, BUFFERS)× 2 plans = 30 s + 300 ms wall clock. Fix cost = oneCREATE INDEX CONCURRENTLY(typically minutes on 100 M rows, non-blocking) +ANALYZE. Downstream cost = zero — the index also benefits every otherorders(created_at)filter in the codebase.
SQL
Topic — optimization
SQL optimization drills
3. Snowflake QUERY_PROFILE
snowflake query profile — the operator stack, micro-partition pruning percent, and how local vs remote disk spill drives warehouse sizing
The mental model in one line: Snowflake's Query Profile presents the executed query as a stack of operator cards — TableScan, Filter, Aggregate, Join, Sort, WindowFunction — each annotated with bytes scanned, partitions total vs partitions scanned (pruning percent), row counts, and a spillage panel with local disk spill and remote disk spill in bytes; reading it top-down and hunting for low pruning percent plus non-zero remote spill is the entire diagnosis discipline. Once you can navigate the Snowsight Query Profile tab and its underlying QUERY_HISTORY view, you can size warehouses, spot bad table clustering, and rewrite joins in one pass.
Slot 1 — where to find the profile.
- Snowsight → Activity → Query History → click into a query → Profile tab. The graphical, interactive view. Every operator is a clickable card with bytes scanned, rows produced, and % of total execution time.
-
SELECT * FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY WHERE query_id = 'abc123';— the underlying table. Available roughly 45 minutes to 3 hours delayed. Columns includebytes_scanned,partitions_scanned,partitions_total,bytes_spilled_to_local_storage,bytes_spilled_to_remote_storage,warehouse_size. -
SELECT * FROM SNOWFLAKE.INFORMATION_SCHEMA.QUERY_HISTORY WHERE query_id = 'abc123';— near-real-time (7 days retention). Same columns as ACCOUNT_USAGE but faster availability, shorter retention. -
GET_QUERY_OPERATOR_STATS(query_id)— table function returning per-operator statistics. Machine-readable; use for programmatic profile analysis. -
The
SYSTEM$EXPLAIN_PLAN_JSON(query_text)function — returns the planned (pre-execution) plan as JSON. Useful when you can't afford to execute. -
WEB_UIshortcut — every query in Snowsight has a "Query Profile" link that opens the interactive view directly.
Slot 2 — the operator card anatomy.
-
TableScan — the leaf. Reads from micro-partitions. Card shows
Bytes scanned,Partitions total,Partitions scanned, andPruning %. Pruning percent is the single most important number in every profile. - Filter — applies a WHERE predicate. Card shows input row count and output row count. The pushdown-into-scan is invisible on Snowflake — good filters are absorbed into the TableScan card.
- JoinFilter — a Bloom filter used to skip micro-partitions on the probe side of a hash join. Presence means Snowflake is doing dynamic partition pruning — a huge win when it applies.
-
Join — hash join (default), sort-merge join (rare), broadcast join (for small side). Card shows
Build side rows,Probe side rows, and the join algorithm. -
Aggregate — hash-based
GROUP BY. Card showsinput rows,groups produced,bytes spilled(if any). -
Sort — order-by operator. Card shows
input rows,bytes spilled,sort keys. -
WindowFunction — analytic function (
ROW_NUMBER,RANK,SUM() OVER (...)). Card showspartitions,bytes spilled. -
Result — the final projection. Card shows
total rows returned,total bytes returned to client. - Broadcast / DistributeByHash — data-distribution nodes across the warehouse's cluster nodes. Presence of Broadcast usually means the planner detected a small side.
- Execution time % — every card has a per-operator percent of total execution time. Sort operators by this — the hot operator is the fix target.
Slot 3 — micro-partition pruning percent (the single most important number).
-
Partitions total— how many micro-partitions the table has in total. Micro-partitions are ~50–500 MB chunks Snowflake maintains internally; you don't create or manage them. -
Partitions scanned— how many were actually read. Ideally a tiny fraction of total. -
Pruning % = 1 - partitions_scanned / partitions_total— the % of micro-partitions skipped by min/max pruning on the WHERE clause. 99%+ is great. 50% means half your storage cost. 0% means the filter was not sargable or the table wasn't clustered on the filter column. -
How Snowflake prunes. For each micro-partition, it stores min and max of each column. A
WHERE created_at >= '2026-06-10'predicate can eliminate any micro-partition whose max < '2026-06-10'. Pruning happens before scan — no bytes read from pruned partitions. -
When pruning fails. Function-wrapped columns (
DATE(created_at)), non-clustered columns, complex predicates the optimiser can't push down. Fix — rewrite the predicate to be sargable, orCLUSTER BYthe table on the filter column.
Slot 4 — local vs remote disk spill and warehouse sizing.
-
Local disk spill = spill onto the warehouse node's SSD. Slower than RAM but manageable.
bytes_spilled_to_local_storagenon-zero means the warehouse ran out of memory but is still within its SSD budget. -
Remote disk spill = spill onto Snowflake's remote storage (S3-backed). Slow — 10–100× slower than local spill.
bytes_spilled_to_remote_storagenon-zero is the strongest signal that the warehouse is too small. - Warehouse sizing rule of thumb. If a query has non-zero remote spill, escalate one warehouse tier (X-Small → Small → Medium → Large → X-Large → 2XL → 3XL → 4XL). Each tier is roughly 2× the compute and 2× the memory. Remote spill going to zero usually happens within one or two tier bumps.
- Cost trade-off. Each tier bump doubles cost per minute. If a query is remote-spilling and you bump L → XL, you pay 2× per minute but the query may finish in 1/3 the time — net savings.
- Auto-suspend and multi-cluster. Auto-suspend is orthogonal; it kills the warehouse after idle. Multi-cluster is for concurrency (multiple queries at once), not for individual query size.
Slot 5 — bytes scanned vs partitions scanned.
-
bytes_scanned— the compressed bytes Snowflake read from storage. Columnar reads mean only projected columns are counted. -
partitions_scanned— a proxy for I/O. Each micro-partition is ~50–500 MB uncompressed. -
bytes_written_to_result/bytes_written— outbound data. -
Cost model on Snowflake — compute is time-billed (per second on the warehouse tier), not bytes-billed. So
bytes_scannedis a diagnostic, not a bill line. The bill is warehouse tier × query wall clock. -
Optimising bytes scanned — pick fewer columns (
SELECT a, b, cnotSELECT *), push predicates to prune, useCLUSTER BYon high-filter columns.
Slot 6 — reading a Snowflake profile top-down.
- Move 1 — sort operators by execution time %. The hot operator is the one with 40%+ of total time.
- Move 2 — check the hot operator's card. If it's a TableScan, check pruning %. If it's a Join, check build vs probe sizes. If it's an Aggregate, check spillage.
- Move 3 — check spillage panel. Non-zero remote spill = warehouse too small. Non-zero local spill = borderline.
- Move 4 — check bytes scanned across TableScans. If one TableScan reads 90% of total bytes, is it necessary? Can columns be projected away or partitions pruned?
- Move 5 — name the fix. Cluster the table. Rewrite the filter. Bump warehouse. Add a materialised view. Rewrite the join.
Slot 7 — the eight most common Snowflake profile pathologies.
-
Pruning % = 0. Full-table scan. Either the filter isn't sargable or the table isn't clustered on the filter column. Fix — rewrite the filter (DATE(created_at) = '2026-07-01'→created_at BETWEEN '2026-07-01' AND '2026-07-02') or addCLUSTER BY (created_at). - Non-zero remote disk spill. Warehouse too small. Bump one tier.
- JoinFilter not present. Snowflake didn't detect an opportunity for Bloom-filter dynamic pruning. Usually because one side is huge and the other is not filtered. Rewrite the query to filter the build side first.
-
Broadcast join with a large "small" side. The planner broadcast a table that isn't actually small. Verify with
SELECT COUNT(*) FROM small_side— if it's over ~1M rows, force a shuffle join by hinting. -
Cartesian join (accidental cross join). Card shows 10× the expected row count. Root cause is a missing join key. Search the query text for a missing
ONclause. -
Sort with high spill. Same as Postgres — either rewrite to sort less (add
LIMIT, pre-aggregate), or bump warehouse. -
Window function on a huge partition.
ROW_NUMBER() OVER (PARTITION BY user_id)on a table where one user has 100 M rows spills catastrophically. Fix — pre-filter to relevant users, or handle skew explicitly. - QUERY_HISTORY delay hurts on-call. ACCOUNT_USAGE.QUERY_HISTORY can be up to 3 hours delayed. For real-time on-call, use INFORMATION_SCHEMA.QUERY_HISTORY (near-real-time, 7 days).
Slot 8 — interview probes on Snowflake plan reading.
- "How do you read a Snowflake query profile?" — Open Snowsight Query History, click into the query, open the Profile tab. Sort operators by execution time %. Check hot operator's card (pruning %, spillage, join sides). Name fix.
-
"What is micro-partition pruning?" — Snowflake maintains min/max per column per micro-partition.
WHERE col < Xeliminates partitions with min > X. Pruning % tells you the effectiveness of the filter against the physical layout. - "Why is my query slow at page 1M on OFFSET pagination in Snowflake?" — Micro-partition pruning helps for the initial WHERE, but OFFSET's scan-and-discard is amplified by columnar reads. Switch to keyset (previous blog).
-
"When would you use
CLUSTER BY?" — When a table is very large (multiple TB), has a well-defined filter column (usually date), and pruning % is low. Cost — Snowflake maintains clustering in the background; cost per TB per month is documented. - "How do you size a warehouse?" — Start Medium. Watch for remote spill. If zero, drop to Small; if non-zero, bump to Large. Iterate. Multi-cluster is orthogonal (for concurrency).
Common beginner mistakes
- Reading the profile bottom-up (Postgres reflex) — Snowflake profiles read top-down.
- Ignoring pruning % — a full-table scan at 0% pruning is silent unless you look at that number.
- Confusing local and remote spill — remote is much more expensive.
- Only bumping warehouse without checking pruning — often the fix is to rewrite the filter, not scale up.
- Missing that
SELECT *costs more thanSELECT a, b, c— Snowflake is columnar; fewer projected columns = fewer bytes scanned.
Worked example — the TableScan with 0% pruning that a CLUSTER BY cures
Detailed explanation. The archetype: a fact table sliced by date_col with a WHERE date_col = X filter shows pruning % of 0. The table isn't clustered on date_col; the min/max of each micro-partition covers the whole date range, so no partition can be pruned. The fix is CLUSTER BY (date_col).
Question. Given the profile card below on a events table (200 TB, 800 M micro-partitions) with WHERE event_date = '2026-07-10', name the fix. Show the DDL and the expected pruning % after.
Input.
TableScan (events)
Partitions total: 800,000
Partitions scanned: 800,000
Pruning %: 0.0%
Bytes scanned: 180 GB
Rows produced: 4,200 M
Execution time %: 78%
Code.
-- Fix: cluster the table by event_date
ALTER TABLE events CLUSTER BY (event_date);
-- Snowflake reclusters in the background; monitor with:
SELECT SYSTEM$CLUSTERING_INFORMATION('events', '(event_date)');
-- After clustering completes (typically hours to days on 200 TB):
-- The same WHERE event_date = '2026-07-10' should show:
-- Pruning % ≈ 99.5%
-- Bytes scanned ≈ 900 MB
-- Wall clock ≈ 10× faster
Step-by-step explanation.
- Current state — 800 K partitions, 800 K scanned = 0% pruning. Each micro-partition's
event_datemin/max range covers so many days that no partition can be eliminated by the filter. -
ALTER TABLE ... CLUSTER BYstarts a background reclustering job. Snowflake rewrites micro-partitions soevent_datevalues are co-located. Cost — measured in credits per TB; documented in Snowflake pricing. -
SYSTEM$CLUSTERING_INFORMATIONreturns a JSON describing the current clustering depth.average_depthclose to 1 means fully clustered. - After clustering,
WHERE event_date = '2026-07-10'prunes down to the ~4 K micro-partitions covering that day — pruning % ≈ 99.5%. - Rule of thumb — cluster on the column your dashboard filters on. Almost always a date or timestamp. Don't cluster on high-cardinality unrelated columns.
Output.
| Before | After |
|---|---|
| Pruning % 0.0% | Pruning % 99.5% |
| Bytes scanned 180 GB | Bytes scanned 900 MB |
| Partitions scanned 800,000 | Partitions scanned 4,000 |
| Execution time 78% of total | Execution time 8% of total |
| Wall clock ~4 min | Wall clock ~25 s |
Rule of thumb. Any TableScan with pruning % under 50% on a filter column is a clustering diagnosis. Cluster once, benefit every dashboard that filters on that column.
Worked example — the remote spill that a warehouse bump cures
Detailed explanation. A Sort or Aggregate operator with non-zero bytes_spilled_to_remote_storage is the strongest single signal that the warehouse is undersized. One tier bump usually zeroes it out.
Question. Given the profile card below on a Sort operator with 220 GB remote spill on a Medium warehouse, name the fix. Show the ALTER WAREHOUSE that would cure it.
Input.
Sort
Sort keys: event_date, user_id
Input rows: 4,200,000,000
Output rows: 4,200,000,000
Bytes spilled to local storage: 80 GB
Bytes spilled to remote storage: 220 GB
Execution time %: 61%
Warehouse: MEDIUM
Code.
-- Fix A — bump the warehouse for this run
ALTER WAREHOUSE analytics_wh SET WAREHOUSE_SIZE = 'LARGE';
-- Re-run query
-- Expected: local spill ~40 GB, remote spill 0
-- Fix B — rewrite the query to sort less (LIMIT, pre-aggregate)
SELECT user_id, event_date, MAX(event_ts) AS last_event
FROM events
GROUP BY user_id, event_date
ORDER BY event_date DESC, user_id
LIMIT 1000;
-- Sort now operates on ~2M aggregated rows, not 4.2B raw rows
-- Fix C — if this is a one-off, use a larger warehouse only for this session
USE WAREHOUSE big_wh; -- pre-created 2XL
-- run query
USE WAREHOUSE analytics_wh;
Step-by-step explanation.
- Current state — Medium warehouse, 220 GB remote spill on the
Sort. Remote spill dominates wall clock; each byte spilled to remote storage is 10–100× slower than in-memory. - Fix A — bump to Large. Large has 2× memory of Medium (~256 GB vs 128 GB for Sort spill headroom). Remote spill should drop to zero; local spill may remain but is manageable.
- Fix B — rewrite. If the query only needs top-N or pre-aggregated data, do that first. Sorting 4.2 B rows to keep 1000 is wasteful; pre-aggregate to 2 M rows, then sort.
- Fix C — separate warehouse. If this is a nightly job that hits Large only once, keep the daytime warehouse Medium and switch to a bigger warehouse only for the nightly step.
- Cost trade-off — Large is 2× Medium's per-second cost, but if wall clock drops from 30 min to 5 min, net cost drops.
Output.
| Fix | Remote spill | Local spill | Wall clock | Cost |
|---|---|---|---|---|
| None | 220 GB | 80 GB | 30 min | 30 × 1 = 30 units |
| A: Large warehouse | 0 | 40 GB | 8 min | 8 × 2 = 16 units |
| B: pre-aggregate | 0 | 0 | 3 min | 3 × 1 = 3 units |
| C: dedicated 2XL for the job | 0 | 0 | 4 min | 4 × 4 = 16 units |
Rule of thumb. Prefer B (rewrite) > A (bump warehouse) > C (dedicated warehouse). Rewriting is usually the cheapest and most durable fix.
Worked example — the Broadcast join that broke on data growth
Detailed explanation. Snowflake's planner picks broadcast join when one side is estimated small enough to distribute to all warehouse nodes. If the small side grows (say from 100 K rows to 20 M rows) but stats haven't refreshed, the broadcast may fail or become catastrophic.
Question. Given the profile card below with a Broadcast join that's dominating wall clock, name the diagnosis and the fix.
Input.
Join (INNER)
Type: BROADCAST
Build side: dim_customer (20 M rows, 1.2 GB in memory)
Probe side: fact_orders (4.2 B rows)
Execution time %: 55%
Bytes spilled to remote: 8 GB
Code.
-- Diagnosis: dim_customer isn't small anymore; broadcasting 20M rows to every node is expensive.
-- Fix A — force a hash join instead of broadcast (use hint)
SELECT /*+ NO_BROADCAST(dim_customer) */
o.id, o.total, c.name
FROM fact_orders o
JOIN dim_customer c ON c.id = o.customer_id
WHERE o.event_date = '2026-07-10';
-- Fix B — filter dim_customer down before the join
SELECT o.id, o.total, c.name
FROM fact_orders o
JOIN (
SELECT id, name FROM dim_customer WHERE active_flag = TRUE
) c ON c.id = o.customer_id
WHERE o.event_date = '2026-07-10';
-- Fix C — cluster fact_orders by event_date so the probe side prunes first
ALTER TABLE fact_orders CLUSTER BY (event_date);
Step-by-step explanation.
- Broadcast join built a hash on
dim_customer(20 M rows, 1.2 GB) and shipped it to every warehouse node. Memory pressure caused spill. - Fix A — hint
NO_BROADCASTondim_customerforces a shuffle join; both sides are hash-partitioned by the join key. No broadcast means no per-node memory footprint. - Fix B — filter the dim table to only the active customers (say 4 M rows). The broadcast becomes cheap again.
- Fix C — cluster the fact table so the
WHERE event_dateprunes probe-side partitions first. Combined with (A) or (B), the query is near-optimal. - Interview signal — knowing when Snowflake picks broadcast vs shuffle join and how to force each is a senior-level probe.
Output.
| Fix | Join type | Remote spill | Wall clock |
|---|---|---|---|
| None | Broadcast | 8 GB | 4 min |
| A: NO_BROADCAST | Hash / Shuffle | 0 | 2 min |
| B: filter dim | Broadcast (smaller) | 0 | 90 s |
| B + C | Broadcast + probe pruning | 0 | 45 s |
Rule of thumb. Broadcast join is optimal for genuinely small dim tables (< 10 K rows). When the "small" side grows past ~1 M rows, force shuffle join or filter the dim first.
snowflake query profile interview question on plan reading and warehouse sizing
A senior interviewer often asks: "A finance dashboard on Snowflake takes 8 minutes to load. You're the on-call DE. Walk me through the Query Profile — what do you look at first, what's the ordered checklist of fixes, and how do you decide when to bump the warehouse vs when to rewrite the query?"
Solution Using the top-down profile checklist with pruning, spill, and operator time %
-- Step 1 — find the query and its query_id
SELECT query_id, query_text, execution_time / 1000 AS seconds
FROM SNOWFLAKE.INFORMATION_SCHEMA.QUERY_HISTORY
WHERE start_time > DATEADD(hour, -1, CURRENT_TIMESTAMP)
ORDER BY execution_time DESC
LIMIT 20;
-- Step 2 — pull the per-operator stats
SELECT *
FROM TABLE(GET_QUERY_OPERATOR_STATS('abc123-def456'))
ORDER BY execution_time_breakdown_overall_percentage DESC;
-- Step 3 — check pruning on TableScans
SELECT
operator_type,
operator_attributes:table_name::string AS table_name,
operator_statistics:pruning:partitions_scanned::int AS scanned,
operator_statistics:pruning:partitions_total::int AS total_partitions,
1 - (operator_statistics:pruning:partitions_scanned::float
/ operator_statistics:pruning:partitions_total::float) AS pruning_pct
FROM TABLE(GET_QUERY_OPERATOR_STATS('abc123-def456'))
WHERE operator_type = 'TableScan';
-- Step 4 — check spillage
SELECT
bytes_spilled_to_local_storage,
bytes_spilled_to_remote_storage,
warehouse_size
FROM SNOWFLAKE.INFORMATION_SCHEMA.QUERY_HISTORY
WHERE query_id = 'abc123-def456';
Step-by-step trace.
| Step | Signal | Action |
|---|---|---|
| 1 | Find query_id from QUERY_HISTORY | Isolate the slow query |
| 2 | List operators by execution time % | Identify the hot operator (usually TableScan or Sort/Join) |
| 3 | Check pruning % on all TableScans | If any < 50%, cluster or rewrite filter |
| 4 | Check remote spill in QUERY_HISTORY | If non-zero, bump warehouse OR rewrite |
| 5 | Check Broadcast joins on non-small tables | Force NO_BROADCAST hint or filter first |
| 6 | Verify SELECT * isn't the culprit |
Project only needed columns |
| 7 | Re-run and diff | Confirm expected drop in wall clock |
Output:
| Signal | Root cause | One-line fix |
|---|---|---|
| Pruning % < 50% on filter column | Table not clustered | ALTER TABLE t CLUSTER BY (col) |
| Remote spill > 0 | Warehouse too small OR bad plan | Bump 1 tier OR rewrite |
| Broadcast join with build > 1M rows | Stale stats or growing dim |
/*+ NO_BROADCAST(t) */ hint |
SELECT * on 100-column table |
Excess columnar reads | Project only needed columns |
| Sort spilling on top-N | No LIMIT push-down | Add explicit LIMIT + pre-aggregate |
Why this works — concept by concept:
- Top-down operator time % — Snowflake profiles are naturally ordered by execution time %. The hot operator jumps to the top; you don't have to hunt for it. Read from there.
- Pruning % is the pre-scan lever — every byte you don't scan is free. Clustering shifts the pruning % from 0 to 99% for the filter column. That single move often 10×'s dashboard queries.
- Remote spill = memory pressure — remote is 10–100× slower than local spill. Zero remote spill is the sizing target. When bumping a warehouse takes remote spill to zero, you've found the right tier.
- Rewrite > scale, when possible — rewriting (adding LIMIT, pre-aggregating, filtering dims first) is durable and doesn't inflate cost per query. Bumping warehouse is fast but pays 2× per tier.
- Cost — profile diagnosis is $0 (a few metadata queries). Fix costs vary — clustering a 200 TB table takes credits to reclusters; bumping a warehouse for one query is fractional cost; rewriting the SQL is free but takes engineer-time. Net-cost usually favours rewrite > cluster > scale.
SQL
Topic — optimization
SQL optimization drills
4. BigQuery execution graph
bigquery execution plan — the stage-based DAG, slot-milliseconds, shuffle bytes, --dry_run cost estimation, and INFORMATION_SCHEMA.JOBS
The mental model in one line: BigQuery's execution model is a directed acyclic graph of stages, where each stage is a set of parallel workers reading input, applying a piece of the query, and writing output that gets shuffled into the next stage; reading the graph is a matter of finding the hot stage (largest slot_ms), checking shuffle bytes (network cost), and reading the INFORMATION_SCHEMA.JOBS row for the query to correlate slot time against wait time. Once you can read the stage graph and the JOBS row, you can size reservations, spot skew, and translate cost per query into cost per week.
Slot 1 — the stage-based model.
- Stages are the unit of parallel work. Each stage has an input (from disk or a previous stage's shuffle output), a compute step (filter, aggregate, join), and a shuffle output. Stages run in parallel wherever their inputs are ready.
- Slots are the compute unit. Each stage runs on some number of slots (BigQuery's word for "worker threads"). On-demand pricing gives 2000 slots per project by default; flat-rate reservations pin exact slot counts.
-
Slot-milliseconds (
slot_ms) — the fundamental unit of BigQuery compute cost. If a stage uses 100 slots for 4.2 seconds, that's100 × 4200 = 420,000 slot_ms. The whole-querytotal_slot_msis the sum across all stages. - Wall clock ≠ slot time. Wall clock is what the user waits. Slot time is total compute. A stage using 100 slots for 4.2 s has 420,000 slot_ms but only 4.2 s of wall clock. High parallelism means wall clock much less than slot time.
Slot 2 — reading the stage graph.
- Console view — BigQuery UI → Query History → click query → Execution details tab. Shows the DAG with each stage's slot time, wait time, and shuffle bytes.
-
Stage columns.
slot_ms,wait_ms,read_ms,compute_ms,write_ms,shuffle_output_bytes,records_read,records_written. -
Skew signal. If a stage has
slot_mssignificantly larger than expected givenrecords_read, the workers are unbalanced — some workers read 100× more than others. Skew is expensive. -
Wait vs compute.
wait_ms > compute_msmeans the stage was queued waiting for slots (concurrency pressure).compute_ms > wait_msmeans the query was compute-bound (worked flat out). -
Hot stage. The stage with the largest
slot_msis the bottleneck. Optimising a stage with 200,000 slot_ms of a 220,000-total is worthwhile; optimising a 200-slot_ms stage isn't.
Slot 3 — --dry_run for cost estimation without execution.
-
bq query --dry_run --use_legacy_sql=false 'SELECT ...'— returns the estimated bytes processed without running the query. -
--dry_runis free. No slots used, no bytes billed. - Estimate accuracy. Usually within 5% of actual on partition-pruned queries. Larger error on complex joins where the planner can't estimate output.
-
Use case — cost gates in CI. Wrap queries in a dry-run + assertion: fail if estimated bytes exceed a threshold. Catches accidental
SELECT *on a 100 TB table before deployment. - Web console equivalent. The query editor shows "This query will process X TB when run" — same estimator.
Slot 4 — INFORMATION_SCHEMA.JOBS audit trail.
-
SELECT * FROM \region-us.INFORMATION_SCHEMA.JOBS WHERE job_id = 'abc';— full metadata row per query. -
Key columns.
total_bytes_processed,total_slot_ms,total_bytes_billed,start_time,end_time,cache_hit,statement_type,query. -
job_stages— nested array with per-stage details. -
referenced_tables— array of tables the query touched. Useful for lineage. -
INFORMATION_SCHEMA.JOBS_BY_ORGANIZATION— organisation-wide view (requires admin role). - Retention. ~180 days. Sufficient for weekly cost reviews.
Slot 5 — slot utilisation vs wait time (the CPU vs queue distinction).
- Compute time = time slots actually worked. Green area in the console utilisation chart.
- Wait time = time slots were assigned but blocked (usually on I/O or shuffle). Amber area.
- Read time = time reading from disk / column storage. Grey area.
- Write time = time writing shuffle output. Blue area.
- Diagnosis reading. If wait time > compute time, the query is I/O-bound or shuffle-bound; a bigger reservation won't help. If compute time > wait time, more slots would speed it up (assuming you have them).
Slot 6 — on-demand vs flat-rate slot billing.
-
On-demand. $5 per TB scanned (US region). Bills by
total_bytes_billed. Slots are elastic — up to 2000 per project. Best for spiky workloads. - Flat-rate reservations. Commit to X slots per month. Predictable bill. Queries wait in queue when they exceed the reservation. Best for consistent workloads.
- BI Engine. In-memory cache for dashboarding queries. Reduces slot cost when the same query runs repeatedly.
- Storage cost is separate. ~$20/TB/month for active storage; ~$10/TB/month for long-term (unchanged for 90 days).
- Egress cost is separate. Charged per byte leaving GCP.
Slot 7 — reading the graph in five moves.
-
Move 1 — find the hot stage. Sort by
slot_msdesc. The top stage is the bottleneck. - Move 2 — check hot stage's compute vs wait. Compute > wait = more slots would help. Wait > compute = I/O bound.
- Move 3 — check shuffle bytes. Large shuffle between two stages means the intermediate result is big. Aggregate earlier if possible.
-
Move 4 — check
records_readvs partition prune. If a stage read 1 T records but the table is 100 T records, pruning worked. If it read 100 T, pruning failed — check partition and cluster keys. -
Move 5 — name the fix. Partition the table on the filter column, cluster on high-cardinality keys, rewrite
SELECT *, use approximate aggregations for cardinality estimates.
Slot 8 — the eight most common BigQuery execution-graph pathologies.
-
Full-table scan on a partitioned table. Filter isn't partition-pruning-friendly.
WHERE DATE(created_at) = '2026-07-10'on a table partitioned byDATE(created_at)prunes.WHERE EXTRACT(YEAR FROM created_at) = 2026doesn't. -
SELECT *on a wide table. Every column is read even if only three are used. Fix — project only what you need. - Skewed join. One key value has 100× more rows than others; one worker gets stuck. Fix — pre-aggregate, sample, or handle skew explicitly.
-
Cross join without predicate. The classic
FROM a, b WHERE a.x = b.xtypo. BigQuery aborts on huge cross joins. - Large shuffle between stages. The intermediate is bigger than either input. Sometimes fine (aggregation reduces later); usually a bad plan.
- Cache miss on repeated dashboarding queries. BI Engine not configured. Fix — add a reservation with BI Engine.
- On-demand querying a 100 TB table daily. $500/query. Fix — partition, cluster, materialised view, or move to flat-rate.
- Slot wait time > compute time. Queue pressure. Fix — larger reservation, or run job in a less contended time window.
Slot 9 — interview probes on BigQuery plan reading.
- "How do you read a BigQuery execution plan?" — Open Query History, click into query, open Execution details. Find hot stage by slot_ms. Check compute vs wait. Diagnose shuffle. Name fix.
-
"What's slot_ms?" — Slot × milliseconds. The fundamental compute unit. Total
slot_ms= sum across all stages. -
"When would you use
--dry_run?" — For cost estimation before running expensive queries. Also in CI to gate PRs that would scan too much. -
"How do you use
INFORMATION_SCHEMA.JOBS?" — Query it for slow-query audits, cost attribution, or plan history. Correlates well with team dashboards. - "On-demand or flat-rate?" — On-demand for spiky ad-hoc analytics ($5/TB is fine when volume is modest). Flat-rate for consistent workloads where you can commit to steady slot usage.
Common beginner mistakes
- Confusing slot_ms with wall clock — 100 slots × 4 s = 400 slot_s, but wall clock was 4 s.
- Forgetting to filter on the partition column — full-table scan on a partitioned table costs the same as an unpartitioned table.
- Using
SELECT *in dashboards — every extra column is a byte scanned = a cent billed. - Ignoring shuffle bytes — the intermediate stages may be bigger than the input if the plan is bad.
- Running expensive queries without
--dry_runfirst.
Worked example — the full-table scan on a partitioned table
Detailed explanation. The most common BigQuery bill surprise: a table partitioned by DATE(created_at) but the query filter is WHERE EXTRACT(YEAR FROM created_at) = 2026 — the filter can't prune partitions because the planner can't inverse-map the function.
Question. Given the query below on a 100 TB events table partitioned by DATE(created_at), why does --dry_run estimate 100 TB? What's the fix?
Input.
SELECT user_id, COUNT(*) AS n
FROM `project.dataset.events`
WHERE EXTRACT(YEAR FROM created_at) = 2026
AND EXTRACT(MONTH FROM created_at) = 7
GROUP BY user_id
LIMIT 100;
Code.
-- Diagnose
bq query --dry_run --use_legacy_sql=false '
SELECT user_id, COUNT(*) AS n
FROM `project.dataset.events`
WHERE EXTRACT(YEAR FROM created_at) = 2026
AND EXTRACT(MONTH FROM created_at) = 7
GROUP BY user_id
LIMIT 100'
-- Output: This query will process 100 TB when run.
-- Fix: rewrite to a sargable partition filter
SELECT user_id, COUNT(*) AS n
FROM `project.dataset.events`
WHERE created_at >= '2026-07-01'
AND created_at < '2026-08-01'
GROUP BY user_id
LIMIT 100;
-- Dry run: This query will process 3 TB when run. (One month of 100 TB × 12.)
Step-by-step explanation.
- Original query wraps
created_atinEXTRACT(...). BigQuery's partition pruner can't invert the function to identify which partitions match — it falls back to full-table scan. -
--dry_runreveals the damage: 100 TB estimated. At $5/TB on-demand, that's $500 per run. - Fix — rewrite the filter as a range on the raw column:
WHERE created_at >= '2026-07-01' AND created_at < '2026-08-01'. The partition pruner can now identify exactly the July 2026 partitions. - Re-run
--dry_run: 3 TB estimated. At $5/TB, that's $15 per run — 30× cheaper. - Rule of thumb — always filter partitioned tables on the raw partition column with
>=/</BETWEEN. Never wrap in a function.
Output.
| Query | Dry-run bytes | On-demand cost |
|---|---|---|
WHERE EXTRACT(YEAR FROM ...) |
100 TB | $500 |
WHERE created_at BETWEEN ... |
3 TB | $15 |
WHERE created_at BETWEEN ... AND cluster_col = X |
300 GB | $1.50 |
Rule of thumb. Always --dry_run before running big-table queries. Always filter on the partition column with sargable predicates. Cluster the table on the next-most-common filter column for even more pruning.
Worked example — the skewed join that dominates one stage
Detailed explanation. A join where one key value has 100× more rows than others creates a skewed stage — one worker gets stuck processing the hot key while others finish quickly. Symptom: one stage's slot_ms is much larger than expected.
Question. Given the execution graph fragment below showing a skewed join stage, name the diagnosis and the fix.
Input.
Stage 3 — HashJoin (fact_events × dim_user)
Records read: 4,200,000,000
Records written: 4,200,000,000
slot_ms: 82,000,000
wait_ms: 400
compute_ms: 410,000
Shuffle output: 660 GB
Skew warning: True — user_id=SYSTEM has 800M rows
Code.
-- Fix A — filter the hot key out (if valid)
WITH filtered_events AS (
SELECT *
FROM fact_events
WHERE user_id != 'SYSTEM' -- exclude the system pseudo-user
AND created_at BETWEEN '2026-07-01' AND '2026-07-31'
),
system_events AS (
SELECT *
FROM fact_events
WHERE user_id = 'SYSTEM'
AND created_at BETWEEN '2026-07-01' AND '2026-07-31'
)
SELECT ... FROM filtered_events JOIN dim_user USING (user_id)
UNION ALL
SELECT ... FROM system_events -- handle SYSTEM separately
-- Fix B — hash-partition the join key with a salt
SELECT ...
FROM fact_events e
JOIN dim_user u ON u.user_id = e.user_id
AND MOD(e.hash_salt, 10) = MOD(u.hash_salt, 10)
Step-by-step explanation.
- Stage 3 shows 82 M slot_ms — the dominant stage. Records read is 4.2 B, but the skew warning notes one user_id (
SYSTEM) accounts for 800 M rows. - In a hash join, all rows with the same key hash to the same worker.
SYSTEMsends 800 M rows to one worker while 100+ others finish quickly. - Fix A — split the hot key. Query the SYSTEM subset separately, then UNION ALL with the non-SYSTEM subset. Each subset's join is balanced.
- Fix B — salt the join. Add a hash-salt column that varies within the hot key, so the 800 M rows spread across 10 workers instead of 1. Requires schema change to the dim table.
- Interview signal — recognising skew from the execution graph and naming a mitigation is a senior-level probe.
Output.
| Fix | slot_ms | Wall clock |
|---|---|---|
| None | 82,000,000 | 8 min |
| A: filter hot key | 42,000,000 | 4 min |
| B: salted key | 44,000,000 | 3 min |
| A + partition prune | 8,000,000 | 45 s |
Rule of thumb. Any stage with slot_ms >> average is a skew candidate. Fix by isolating the hot key or salting it.
Worked example — moving from on-demand to a reservation for cost savings
Detailed explanation. A team running 50 similar dashboards on-demand at $5/TB every hour discovers they're paying $30,000/month in BigQuery. A flat-rate reservation can cap the bill while providing the same throughput — often 40% cheaper.
Question. Given the monthly usage below, decide whether to stay on-demand or switch to flat-rate. What size reservation would break even?
Input.
Monthly total bytes billed: 6 PB
Monthly on-demand cost: $30,000 ($5/TB * 6,000 TB)
Average slot_ms/day: 2.1 * 10^10 (typical 400 slots utilized on avg)
Peak slots concurrent: ~1200 (short bursts)
Off-peak: ~200
Query cadence: 15,000 queries/day
Code.
-- Estimate flat-rate cost at various commitments
-- Assumes $2000/mo per 100 slots (approximate US pricing; verify current)
-- 500 slots = $10,000/mo — throttled at peaks
-- 700 slots = $14,000/mo — few queue stalls
-- 1000 slots = $20,000/mo — covers 95% peaks
-- 1200 slots = $24,000/mo — covers 99% peaks
-- Autoscale up to 500 flex slots = ~$1000/mo extra
Step-by-step explanation.
- On-demand at $30 K/month; queries run at up to 1200 slots peak but average 400 slots.
- A 700-slot flat-rate reservation ($14 K/month) covers the average with headroom. Queries can queue during peaks — usually OK for dashboards.
- Add 500 flex-slot autoscaling ($1 K/month typical) to cover the top 5% of peak-slot-demand queries without queueing.
- Total ≈ $15 K/month vs $30 K on-demand — 50% cheaper for the same throughput.
- Trade-off — reservations require a monthly commitment; on-demand is elastic. If usage drops 50% next quarter, on-demand shrinks with it; flat-rate doesn't.
Output.
| Model | Slots | Cost / month | Notes |
|---|---|---|---|
| On-demand | 2000 (elastic) | $30,000 | Elastic; pays per TB scanned |
| Flat 500 slots | 500 | $10,000 | Queues at peaks |
| Flat 700 + 500 flex | 700+500 | $15,000 | Sweet spot |
| Flat 1200 slots | 1200 | $24,000 | Almost no queueing |
Rule of thumb. Flat-rate wins when average slot utilisation is above ~30% of peak. On-demand wins for spiky ad-hoc analytics. INFORMATION_SCHEMA.JOBS gives you the raw material to model this.
bigquery execution plan interview question on cost + performance tuning
A senior interviewer often asks: "A dashboard on a 200 TB partitioned + clustered events table is slow and expensive — 4 minutes per load, $8 per query, running every hour. Walk me through your diagnosis using the execution graph and INFORMATION_SCHEMA.JOBS, and name the top three fixes ranked by ROI."
Solution Using stage-graph diagnosis + partition pruning + materialised view
-- Step 1 — pull job metadata
SELECT
job_id,
total_bytes_processed / POW(1024, 4) AS tb_scanned,
total_slot_ms,
end_time - start_time AS wall_clock
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE job_id = 'abc123';
-- Step 2 — read the stage graph via query_plan (JSON)
SELECT
step.name AS stage_name,
step.slot_ms AS stage_slot_ms,
step.records_read AS records_read,
step.shuffle_output_bytes AS shuffle_bytes
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT,
UNNEST(job_stages) AS step
WHERE job_id = 'abc123'
ORDER BY step.slot_ms DESC;
-- Fix A — narrow the partition filter
-- Was: WHERE created_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
-- Now: WHERE created_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
-- Effect: 200TB → 15TB scanned per query
-- Fix B — pre-aggregate into a materialised view
CREATE MATERIALIZED VIEW `project.dataset.mv_daily_events` AS
SELECT
DATE(created_at) AS event_date,
user_id,
COUNT(*) AS event_count,
SUM(revenue) AS revenue
FROM `project.dataset.events`
GROUP BY event_date, user_id;
-- Dashboard now queries the MV, scanning ~10 MB per day per user instead of raw events
-- Fix C — add BI Engine to the reservation
-- (Console → BigQuery → Reservations → Add BI Engine capacity — e.g. 20 GB)
-- Cached repeat queries served in milliseconds
Step-by-step trace.
| Fix | Bytes scanned | Slot_ms | Wall clock | Cost / query |
|---|---|---|---|---|
| Baseline (90-day filter, raw events) | 200 TB | 15 * 10^10 | 4 min | $1,000 |
| A — narrow to 7 days | 15 TB | 1.3 * 10^10 | 45 s | $75 |
| A + B — MV pre-aggregation | 300 MB | 1 * 10^8 | 3 s | $0.02 |
| A + B + C — BI Engine cache | 300 MB (first) / 0 (cache) | ~0 | 200 ms | $0 (cache hit) |
Output:
| Metric | Baseline | Optimised |
|---|---|---|
| Wall clock | 4 min | 200 ms (cache) / 3 s (miss) |
| Bytes scanned | 200 TB | 300 MB |
| Cost / query | $1,000 | $0.02 |
| Hourly cost | $1,000/hr | $0.02/hr |
| Weekly cost | $168,000/wk | $3.36/wk |
Why this works — concept by concept:
-
Partition pruning by narrowing the filter window — most dashboards actually only need the last 7 or 30 days. Narrowing from 90 days to 7 days is a mechanical 13× reduction in bytes scanned. Verify with
--dry_run. - Materialised view for pre-aggregation — dashboards summarise the raw data. Pre-summarise once, query many times. Automatic incremental refresh keeps the MV current. Bytes scanned per dashboard load drops from TB to MB.
- BI Engine cache — an in-memory cache for repeat queries. Sub-second latency; zero bytes scanned on cache hit. Perfect for dashboards where the same query runs every hour.
-
INFORMATION_SCHEMA.JOBSfor cost attribution — the metadata table lets you attribute per-team, per-project, and per-user costs. Weekly review of the top 20 queries bytotal_bytes_processedfinds the biggest optimisation targets. - Cost — Diagnosis: $0 (metadata queries). Fixes: MV creation is a one-off compute cost (~$100 for 200 TB); BI Engine adds a fixed monthly cost (~$100 for 20 GB). Ongoing: 50,000× cheaper. ROI in hours.
SQL
Topic — optimization
SQL optimization drills
5. Dialect matrix + 5-step reading strategy
read execution plan across four engines — Postgres, Snowflake, BigQuery, SQL Server — one query, four artefacts, one senior reading strategy
The mental model in one line: the same conceptual query (join, filter, aggregate, sort, limit) compiles to a nested plan tree on Postgres, an operator stack on Snowflake, a stage DAG on BigQuery, and an XML plan graph on SQL Server; the artefacts look different but the reading discipline is the same — find the hot node, check the estimate-vs-actual, check for spill or shuffle, check parallelism, name the fix. Learn the five-step discipline once; apply it everywhere.
Slot 1 — the dialect matrix.
| Engine | EXPLAIN keyword | Profile artefact | Cost surface |
|---|---|---|---|
| Postgres | EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT/JSON) |
Nested plan tree with per-node cost / rows / actual / loops / buffers | pg_stat_statements, log_min_duration_statement |
| Snowflake |
EXPLAIN (plan only) + Query Profile UI (actual) |
Operator stack with pruning % + spill panel | QUERY_HISTORY, ACCOUNT_USAGE, GET_QUERY_OPERATOR_STATS |
| BigQuery |
bq query --dry_run + Execution details tab |
Stage DAG with slot_ms + shuffle_bytes | INFORMATION_SCHEMA.JOBS, dry_run cost estimator |
| SQL Server |
SET STATISTICS TIME/IO ON + Actual Execution Plan (XML) |
Graphical plan in SSMS + XML | Query Store, DMVs (sys.dm_exec_query_stats) |
Slot 2 — SQL Server-specific plan reading.
-
SET STATISTICS TIME ON— reports CPU and elapsed time per statement. -
SET STATISTICS IO ON— reports logical reads (from cache) and physical reads (from disk) per table. - Actual Execution Plan (Ctrl+M in SSMS) — the graphical plan tree. Nodes are labelled with cost %, actual row count, and estimated row count.
- Estimated vs Actual Plan — Estimated is planning-only (no execution); Actual is post-execution. Prefer Actual for diagnosis.
- Plan XML — the underlying representation. Machine-readable; can be diffed programmatically.
-
Query Store — SQL Server's built-in query performance history. Similar to
pg_stat_statementsbut with plan diffs and forced-plan capability. -
DMVs —
sys.dm_exec_query_stats,sys.dm_exec_query_plan,sys.dm_os_wait_stats. Real-time perf data. -
Missing Index DMVs —
sys.dm_db_missing_index_details— SQL Server's suggestion engine. Use as a starting point, not a truth. -
Common operators.
Index Seek,Clustered Index Scan,Hash Match,Nested Loops,Sort,Stream Aggregate,Table Spool. - Cost estimate. Each operator has a "cost of subtree" %. The percentages sum to 100%; the operator with the highest % is the bottleneck.
Slot 3 — the same query across four engines.
-- Same conceptual query on all four engines
SELECT
customer_id,
SUM(total) AS lifetime_value
FROM orders
WHERE created_at >= '2026-06-10'
GROUP BY customer_id
ORDER BY lifetime_value DESC
LIMIT 100;
Each engine shows this differently:
-
Postgres — nested tree:
Limit → Sort → HashAggregate → Bitmap Heap Scan on orders. -
Snowflake — operator stack:
Result → Sort → Aggregate → Filter → TableScan(orders). -
BigQuery — 3-stage DAG:
Stage 1 (read + filter) → Shuffle → Stage 2 (aggregate + sort) → Stage 3 (limit + output). -
SQL Server — XML plan tree with
Limit → Sort → Hash Match (Aggregate) → Index Seek on orders(created_at).
Slot 4 — the 5-step senior read strategy (applies to all four engines).
- Step 1 — find the hot node. The one with the largest fraction of total wall clock (or slot_ms on BigQuery). Every profile UI sorts by this.
- Step 2 — check estimate vs actual. Every plan has an estimate and an actual row count per node. If they diverge by 10× or more, the optimiser was wrong — refresh stats or add extended stats.
-
Step 3 — check spill or shuffle. Postgres
Buffers: temp read. Snowflakebytes_spilled_to_remote_storage. BigQueryshuffle_bytes(large intermediate). SQL ServerSort Warningin the XML plan. Any of these is a specific fix. -
Step 4 — check parallelism. Postgres
Workers Launched. Snowflake warehouse size. BigQuery slot utilisation vs wait time. SQL ServerMAXDOPsetting. If parallelism is disabled or unused, decide whether it should be. - Step 5 — fix the plan. Add index, run ANALYZE, rewrite join, add hint, bump memory, cluster the table, pre-aggregate. The hot-node diagnosis names the one-line fix.
Slot 5 — hints and last-resort tools.
-
Postgres —
pg_hint_plan. An extension for query hints./*+ SeqScan(t) */forces seq scan. Use rarely — hints lock the plan against future data shifts. -
Snowflake —
NO_BROADCAST,BROADCAST,MATERIALIZED_VIEW_HINT. Fewer hints than other engines; the optimiser is intentionally opaque. - BigQuery — no hints per se. Rewrite the SQL to force a plan shape. Use materialised views or authorised views for repeated shapes.
-
SQL Server — many hints.
WITH (NOLOCK),OPTION (HASH JOIN),OPTION (MAXDOP 4). Powerful but easily abused. - Rule. Hints are a last resort. First — refresh stats. Second — rewrite. Third — index. Fourth — hint (and add a comment explaining the hint's justification for the next maintainer).
Slot 6 — cost per query (bill-line thinking).
- Postgres self-hosted. Cost = servers × hours. Query cost = fraction of server-time. Diagnose to keep utilisation reasonable; scale up when needed.
-
Postgres cloud (RDS, Aurora). Cost = instance-hours + IO. Bad queries can inflate IO — check
pg_stat_statementsand diff top-N by total time. - Snowflake. Cost = warehouse-tier × query-wall-clock. Bad plans = long wall clock = big bill. Optimise for shorter wall clock at a given tier.
- BigQuery on-demand. Cost = TB scanned × $5. Bad plans = full-table scans = big bill. Optimise for pruned partitions and smart clustering.
- BigQuery flat-rate. Cost = slot commitment × month. Bad plans = queue waits or overrun. Optimise for slot efficiency (compute > wait).
- SQL Server. Cost = server + license. Query cost = share of server; long queries block others via lock contention. Diagnose lock waits with DMVs.
Slot 7 — reading discipline vs tuning discipline.
-
Reading. Every plan artefact you can read is a chance to catch a regression before it ships. Add plan diffs to code review. Compare prod vs staging plans for the same query. Set alerts on
pg_stat_statementsfor queries whose mean-time doubles. - Tuning. Once the read names the fix, apply the fix. Prefer stats refresh (cheap) → query rewrite (durable) → index (durable, mild write cost) → materialised view (durable, storage cost) → warehouse bump (fast, expensive). Hints are last.
Slot 8 — the SQL Server actual execution plan XML example.
-- SQL Server: turn on both flags
SET STATISTICS TIME ON;
SET STATISTICS IO ON;
-- Include Actual Execution Plan (Ctrl+M) then run:
SELECT customer_id, SUM(total) AS lifetime_value
FROM orders
WHERE created_at >= '2026-06-10'
GROUP BY customer_id
ORDER BY lifetime_value DESC
OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY;
-- Right-click the plan → Show Execution Plan XML → paste into a diff tool
-- Turn off after diagnosis
SET STATISTICS TIME OFF;
SET STATISTICS IO OFF;
Common beginner mistakes
- Reading only one engine's plan without recognising the other three shapes.
- Confusing Snowflake's operator stack (top-down) with Postgres's plan tree (bottom-up).
- Forgetting that BigQuery slot_ms ≠ wall clock — a 100-slot query is fast relative to slot time.
- Skipping the estimate-vs-actual check — the single most reliable signal of a bad plan.
- Bumping resources first — always diagnose the plan first; scale is the last resort.
Worked example — reading the same query on all four engines
Detailed explanation. The clearest way to lock in the mental model: read the same query's plan on Postgres, Snowflake, BigQuery, and SQL Server, and identify the equivalent nodes.
Question. Given the query and the plan artefacts from each engine, map each engine's nodes to the conceptual operators (Filter, GroupBy, Sort, Limit).
Input.
-- The query
SELECT customer_id, SUM(total) AS lifetime_value
FROM orders
WHERE created_at >= '2026-06-10'
GROUP BY customer_id
ORDER BY lifetime_value DESC
LIMIT 100;
Code. (No code — a mapping exercise.)
Conceptual operator | Postgres node | Snowflake operator | BigQuery stage | SQL Server node
Read | Bitmap/Index Scan | TableScan | Stage 1 input | Index Seek
Filter | Recheck Cond / Filter | Filter / Scan | Stage 1 filter | (subsumed)
GroupBy | HashAggregate | Aggregate | Stage 2 agg | Hash Match (Aggregate)
Sort | Sort | Sort | Stage 2 sort | Sort
Limit | Limit | Result | Stage 3 output | Top
Step-by-step explanation.
- Read — every engine has a scan. Postgres shows the physical access method (Bitmap / Index / Seq); Snowflake shows partitions scanned; BigQuery shows records_read per stage; SQL Server shows the seek predicate.
- Filter — subsumed into scan on most engines when the predicate is sargable (index-lookup key on Postgres / SQL Server; partition prune on BigQuery; pruning % on Snowflake).
- GroupBy — always a hash aggregate for high-cardinality group keys. Postgres and SQL Server can also pick sort-based aggregation when input is already sorted.
- Sort — the
ORDER BY SUM(total) DESCrequires sorting after aggregation. Postgres and SQL Server may pick top-N heapsort when combined with LIMIT. - Limit — the top node. Postgres
Limit, SnowflakeResult(subsumes limit), BigQuery output stage, SQL ServerTop.
Output. The mapping in the table above. Learn one column deeply; the others come naturally once you can read one.
Rule of thumb. Read one engine deeply first (usually Postgres because the syntax is most explicit). Then translate to Snowflake, BigQuery, SQL Server as you encounter them. The five-step reading discipline is invariant across all four.
Worked example — the same query optimised differently on each engine
Detailed explanation. Optimising the same conceptual query on each engine involves engine-specific levers — Postgres wants indexes, Snowflake wants clustering, BigQuery wants partition + cluster keys, SQL Server wants an index seek plus a covering include column.
Question. For the query above, give the one-line optimisation for each engine.
Input.
-- The query
SELECT customer_id, SUM(total) AS lifetime_value
FROM orders
WHERE created_at >= '2026-06-10'
GROUP BY customer_id
ORDER BY lifetime_value DESC
LIMIT 100;
Code.
-- Postgres
CREATE INDEX CONCURRENTLY idx_orders_created_customer_total
ON orders (created_at, customer_id) INCLUDE (total);
-- Snowflake
ALTER TABLE orders CLUSTER BY (created_at);
-- BigQuery
CREATE OR REPLACE TABLE `project.dataset.orders`
PARTITION BY DATE(created_at)
CLUSTER BY customer_id
AS SELECT * FROM `project.dataset.orders`;
-- SQL Server
CREATE NONCLUSTERED INDEX idx_orders_created_at
ON orders (created_at)
INCLUDE (customer_id, total);
Step-by-step explanation.
- Postgres — composite index on
(created_at, customer_id)withINCLUDE (total)covers the filter, the group key, and the aggregate — index-only scan possible. - Snowflake — cluster by
created_atso theWHEREfilter prunes micro-partitions.SELECTing only three columns already benefits from columnar storage. - BigQuery — partition by
DATE(created_at)so the filter prunes date partitions. Cluster bycustomer_idso the group-by benefits from block-level locality. - SQL Server — nonclustered index on
created_atwithINCLUDE (customer_id, total)— covers the filter and projects the two needed columns. - All four fixes achieve the same shape: a small scan + hash aggregate + sort + limit. Wall clock drops from tens of seconds to sub-second.
Output.
| Engine | Before | After | Speed-up |
|---|---|---|---|
| Postgres | Seq Scan / 1.2 s | Index Only Scan / 15 ms | 80× |
| Snowflake | 0% prune / 45 s | 99% prune / 4 s | 11× |
| BigQuery | Full-scan / 200 TB | Partition + cluster / 2 GB | 100,000× cost |
| SQL Server | Clustered Index Scan / 2.5 s | Index Seek + Include / 30 ms | 83× |
Rule of thumb. Every engine has an equivalent "cover the query with the index" pattern. Learn the syntax for each; the concept is the same.
execution plan analysis interview question on cross-engine plan reading
A senior interviewer often asks: "Design a runbook a mid-level engineer can follow to read execution plans across Postgres, Snowflake, BigQuery, and SQL Server. What are the five steps, and what's the one-line signal on each engine that tells you 'this plan is broken'?"
Solution Using the invariant 5-step read strategy plus a per-engine cheat card
STEP 1 — find the hot node/stage.
Postgres: sort plan by actual_time; deepest actual_time × loops = hot node.
Snowflake: sort by execution_time_breakdown_overall_percentage; top card = hot.
BigQuery: sort by slot_ms; largest stage = hot.
SQL Server: sort by cost of subtree %; highest = hot.
STEP 2 — check estimate vs actual.
Postgres: rows= vs actual rows= per node. Gap ≥ 10× is bad.
Snowflake: input rows vs output rows on operators. Compare to statistics.
BigQuery: records_read vs estimated records. INFO_SCHEMA.JOBS has both.
SQL Server: Estimated Number of Rows vs Actual Number of Rows on each node.
STEP 3 — check spill or shuffle.
Postgres: BUFFERS: temp read non-zero; Sort Method: external merge.
Snowflake: bytes_spilled_to_remote_storage non-zero.
BigQuery: large shuffle_output_bytes between stages.
SQL Server: Sort Warning; Hash Warning; TempDB usage.
STEP 4 — check parallelism.
Postgres: Workers Launched. Should be > 0 on big scans.
Snowflake: warehouse size and MULTI_STATEMENT_COUNT.
BigQuery: slot count and wait_ms.
SQL Server: MAXDOP and Parallelism operator in the plan.
STEP 5 — fix the plan.
Postgres: ANALYZE; add index; rewrite join; SET work_mem session.
Snowflake: CLUSTER BY; rewrite filter; bump warehouse tier.
BigQuery: partition + cluster; materialised view; rewrite SELECT.
SQL Server: rebuild statistics; CREATE INDEX; Query Store forced plan.
BROKEN-PLAN one-line signals (per engine):
Postgres: "Rows Removed by Filter: 99000000" — missing index.
Snowflake: "Partitions scanned = Partitions total" — no clustering / bad filter.
BigQuery: "Full scan on partitioned table" — filter not sargable.
SQL Server: "Clustered Index Scan on 100M-row table with cost 98%" — missing index.
Step-by-step trace.
| Step | Postgres | Snowflake | BigQuery | SQL Server |
|---|---|---|---|---|
| 1 hot | actual_time × loops | exec time % | slot_ms per stage | cost of subtree % |
| 2 est vs act | rows vs actual rows | input vs output rows | records_read | Est vs Actual rows |
| 3 spill | temp read | remote spill bytes | large shuffle | Sort/Hash warning |
| 4 parallel | Workers Launched | WH size | slot count vs wait | MAXDOP + parallel op |
| 5 fix | ANALYZE + INDEX | CLUSTER + rewrite | PARTITION + MV | REBUILD + INDEX |
The runbook is invariant across all four engines. The signals differ; the discipline is the same.
Output:
| Engine | Fastest one-signal diagnosis |
|---|---|
| Postgres | Any Seq Scan with Rows Removed by Filter > 1M |
| Snowflake | Any TableScan with pruning < 50% on a filter column |
| BigQuery | Any query where dry_run bytes > 10 TB on a partitioned table |
| SQL Server | Any plan node with cost of subtree > 80% and no index seek |
Why this works — concept by concept:
- Invariant discipline — every plan diagnosis reduces to five steps: hot node, estimate vs actual, spill/shuffle, parallelism, fix. The signals differ per engine but the mental model is one.
- One-signal diagnosis — for every engine, there's a single reading that catches the most common plan pathology. Teaching a mid-level engineer that signal gives them 80% of senior plan-reading skill for that engine.
- Runbook portability — an engineer who learns the runbook on Postgres can port it to Snowflake or BigQuery in a day. The mental model is the durable asset; syntax is trivia.
- Escalation path — cheap fixes first (refresh stats), durable fixes second (index, cluster, partition), expensive fixes last (scale up, hint, materialise). Applies universally.
- Cost — the runbook itself is a one-page cheat sheet; teaching it takes an hour. Ongoing cost is engineer-time saved on diagnosis (typically 30–60 min per incident reduced to 5).
SQL
Topic — optimization
SQL optimization drills
SQL
Topic — joins
SQL join optimisation drills
Cheat sheet — plan-reading recipe list
-
Bottom-up reading rule. On Postgres and SQL Server, always read the plan tree bottom-up — leaf scans first,
Limitlast. On Snowflake and BigQuery, sort operators or stages by execution time % / slot_ms and read the top. -
Cost vs actual time.
cost=is planner units (Postgres, SQL Server), not milliseconds.actual time=is wall clock (ms). Never quote cost to non-DBA humans. -
The four numbers per Postgres node. cost (startup..total), rows (estimate), actual time (first..last), loops. Wall-clock cost =
actual time × loops. -
Estimate vs actual gap. Any node with
rowsoff by 10×+ fromactual rowsis a bad-stats or bad-plan signal. Fix —ANALYZE, add extended statistics, or force the plan. -
Rows Removed by Filter. Any PostgresSeq ScanwithRows Removed by Filterin the millions is a missing-index diagnosis. Add the index. -
BUFFERS: temp read. Non-zero on Postgres = spilling. Fix — rewrite (LIMIT, pre-aggregate) orSET work_memper session. -
Sort Method. Postgres —quicksort(in-mem),top-N heapsort(bounded),external merge(spilled).external merge= spill = fix. -
Batches: N > 1. PostgresHashnode with N > 1 = spilling. Fix — bumpwork_memor filter build side down. -
Workers Launched: 0. Postgres parallelism disabled. Checkmax_parallel_workers_per_gatherandmin_parallel_table_scan_size. -
Nested Loopon two large sides. Bad plan almost always. Fix — refresh stats or forceHash Join(last resort). -
Snowflake pruning %. The single most important number. 99% = great. Under 50% = fix the filter or
CLUSTER BY. -
Snowflake remote spill. Non-zero
bytes_spilled_to_remote_storage= warehouse too small OR bad plan. Bump one tier or rewrite. -
Snowflake local spill. Non-zero
bytes_spilled_to_local_storagebut zero remote = borderline. Not urgent. -
Snowflake
SELECT *. Costs more thanSELECT a, b, c— columnar storage. Project only needed columns. -
Snowflake broadcast join. Fine for < 10 K rows on the build side. Over ~1 M, force
NO_BROADCASTor filter first. - BigQuery slot_ms. Fundamental compute cost unit. Sort stages by slot_ms desc to find the hot stage.
-
BigQuery
--dry_run. Free bytes-processed estimate. Always run before expensive queries. Gate PRs in CI on estimated bytes. -
BigQuery partition pruning. Filter on the raw partition column with
>=/BETWEEN. Never wrap in a function (EXTRACT,DATE). - BigQuery clustering. Cluster on high-cardinality filter columns after partition. Block-level pruning inside each partition.
- BigQuery materialised view. Pre-aggregate for dashboards. Automatic incremental refresh. Bytes scanned drops 1000×+.
- BigQuery on-demand vs flat-rate. On-demand ($5/TB) for spiky ad-hoc. Flat-rate (per-slot commitment) for consistent workloads.
-
SQL Server
SET STATISTICS TIME/IO ON. Reports CPU time, elapsed time, logical reads, physical reads. Turn on for diagnosis, off after. - SQL Server actual execution plan. Ctrl+M in SSMS. Cost of subtree % on every node sums to 100%; hottest node is the fix target.
- SQL Server Query Store. Historical query perf + plan diffs. Forced plans available.
- The 5-step senior read strategy. Hot node → estimate vs actual → spill/shuffle → parallelism → fix. Invariant across all four engines.
-
Refresh stats first, always.
ANALYZEon Postgres. Snowflake refreshes automatically. BigQuery viaINFORMATION_SCHEMA. SQL ServerUPDATE STATISTICS. Fresh stats fix ~30% of bad plans immediately. - Rewrite before scaling. Query rewrite is free forever. Scaling is a monthly cost. Prefer rewrite when feasible.
- Hints are last resort. Every hint locks a plan against future data shifts. Prefer index / cluster / partition changes to hints when possible.
- Cost per query. Postgres self-hosted = server share. Snowflake = warehouse × wall clock. BigQuery on-demand = TB × $5. Flat-rate = monthly commit. Know your cost model when reading plans.
-
Plan diffs in code review. Add
EXPLAIN (ANALYZE, BUFFERS)outputs to PR descriptions for schema or query changes. Catches regressions before ship. -
CI cost gates. Wrap expensive queries with
--dry_runin CI. Fail PR if estimated bytes exceed a threshold. -
Monitoring stack. Postgres
pg_stat_statements. SnowflakeQUERY_HISTORY. BigQueryINFORMATION_SCHEMA.JOBS. SQL ServerQuery Store. Weekly review of top-N by mean time or total bytes. - Rule of thumb. 80% of bad plans come from 20% of queries. Diagnose the top 20 by cost weekly; that's usually where the leverage is.
Frequently asked questions
What is SQL EXPLAIN ANALYZE and how do I read the output?
sql explain analyze is the primitive every engine ships for surfacing the physical plan the executor actually walked — on Postgres it's EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT) and it returns a nested plan tree annotated with cost=startup..total (planner units), rows=N (estimate), actual time=first..last (wall clock ms), loops=N (executions), and optional BUFFERS: shared hit / shared read / temp read (memory vs disk vs spill). Read it bottom-up — the leaf scans execute first and feed intermediate nodes (joins, aggregates), which feed the top node (Limit, Sort); the leaf whose actual time × loops dominates total wall clock is the bottleneck. The reading discipline is five steps — find the hot node, compare estimate to actual (10×+ gap = stale stats or bad plan), check for spill (Buffers: temp read non-zero, Sort Method: external merge), check parallelism (Workers Launched > 0), and name the fix (add index, run ANALYZE, rewrite join, bump work_mem). On Snowflake, the equivalent is the Snowsight Query Profile with operator cards and pruning %; on BigQuery, it's the execution graph with per-stage slot_ms; on SQL Server, it's the actual execution plan XML with cost-of-subtree percentages. The read is invariant across engines; only the artefact differs.
What's the difference between Postgres cost and actual time?
Cost is a made-up planner unit used only to rank plan alternatives during optimisation — a Seq Scan might cost 0.00..25000.00, but that does not mean 25 seconds; it means "the CBO's internal price for this scan compared to alternatives." Actual time is wall clock in milliseconds — actual time=42.512..42.548 means the first row from this node was ready 42.512 ms after query start; the last was ready at 42.548 ms. The two are not comparable — cost is arbitrary and only meaningful within a single plan; actual time is real and directly interpretable. On top of that, if a node has loops=1000 it executed a thousand times, so the wall-clock cost of the node is actual time × loops, not just actual time. When you're diagnosing a slow query, ignore cost entirely and read actual time × loops per node; when you're explaining why the optimiser chose plan A over plan B, cost is the axis that matters. Never quote cost to a business stakeholder — "this query cost 250,000" means nothing to them; "this query took 42 seconds" means everything.
How do I read a Snowflake query profile?
Open Snowsight → Activity → Query History → click into the query → Profile tab — the graphical view shows a stack of operator cards (TableScan, Filter, Aggregate, Join, Sort, WindowFunction), each with Execution time %, Bytes scanned, Rows produced, and per-operator statistics. Read top-down, sorted by execution time %, and the hot operator surfaces first. On the hot card — check pruning % for TableScans (99% is great, under 50% is a clustering or filter problem), check local vs remote disk spill for aggregates and sorts (bytes_spilled_to_remote_storage non-zero is the strongest single signal that the warehouse is too small), and check join algorithm for joins (Broadcast for genuinely small dim tables, hash / shuffle for large tables). The underlying data lives in SNOWFLAKE.INFORMATION_SCHEMA.QUERY_HISTORY (near-real-time, 7-day retention) and SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY (delayed but 1-year retention), with per-operator stats via GET_QUERY_OPERATOR_STATS(query_id). The five-step reading discipline is identical to other engines — hot operator, estimate vs actual (input vs output rows), spill (remote > local > none), parallelism (warehouse size), name the fix (CLUSTER BY, rewrite filter, bump warehouse, force NO_BROADCAST hint).
How do I read a BigQuery execution plan?
Open BigQuery Console → Query History → click the query → Execution details tab — the graphical view shows a stage-based DAG where each stage is a set of parallel workers reading input, applying part of the query, and shuffling output to the next stage. The fundamental compute unit is slot_ms (slot-milliseconds); a stage using 100 slots for 4.2 s costs 420 000 slot_ms with only 4.2 s of wall clock. Read stages sorted by slot_ms desc — the top stage is the bottleneck — and inspect records_read (partition pruning worked if this is a small fraction of the table), shuffle_output_bytes (large shuffle = big intermediate = optimise upstream), wait_ms vs compute_ms (wait > compute = slots queued; compute > wait = flat out), and skew warnings (one hot key monopolising a worker). The underlying data is INFORMATION_SCHEMA.JOBS with total_slot_ms, total_bytes_processed, and job_stages (nested per-stage details). Use bq query --dry_run to estimate bytes_processed before running — free, matches actual within ~5%, and pairs with CI gates to reject expensive PRs. The senior fix set is partition on the filter column (PARTITION BY DATE(created_at)), cluster on the next-most-common filter (CLUSTER BY user_id), create materialised views for dashboards (1000× cost drop typical), and choose on-demand vs flat-rate based on utilisation patterns.
What does "rows removed by filter" mean in Postgres?
Rows Removed by Filter: N on a Postgres plan node means the executor read N rows and threw them away — the filter predicate matched some fraction of the input, and the discarded rows are the loss. On a Seq Scan with Rows Removed by Filter: 99000000, the executor read 100 M rows and kept only 1 M — a scan that should have been an index seek. On an Index Scan with non-zero Rows Removed by Index Recheck, the index covered part of the predicate (say, the leading column) but a remaining condition (say, a trailing column not in the index) had to be re-checked at the heap; extending the index to a covering INCLUDE index eliminates the recheck. On a Bitmap Heap Scan with Rows Removed by Filter: N, the index bitmap was over-inclusive — the index isn't selective enough on its own; a partial index or a composite index that covers more of the predicate reduces the recheck. The general fix pattern is: any node with Rows Removed by Filter in the millions is a missing index or weak index diagnosis — add or extend an index so the filter becomes an Index Cond (part of the seek) instead of a Filter (applied after the read).
How do I fix a plan where the estimate is 10× off the actual?
An estimate that's 10×+ off the actual usually means one of five things — stale statistics (last ANALYZE was days ago and data has shifted; run ANALYZE table to refresh), skewed columns without extended stats (a column with a highly-skewed distribution fools the histogram; create CREATE STATISTICS s ON col FROM t; and run ANALYZE), correlated columns treated as independent (say, city and country are correlated but the planner multiplies their selectivities as if independent; CREATE STATISTICS s (dependencies, ndistinct) ON city, country FROM t; fixes it), function-wrapped predicates (WHERE EXTRACT(YEAR FROM created_at) = 2026 is not sargable; rewrite as a range WHERE created_at BETWEEN '2026-01-01' AND '2027-01-01'), or plan cache mode mismatch (a prepared statement was planned with generic parameters that don't match this call's actual values; force plan_cache_mode = 'force_custom_plan' for this session). The fastest triage — always try ANALYZE first — it's free, fast, and fixes ~30% of estimate divergences on its own. If ANALYZE doesn't help, look at the specific columns and predicates; extended statistics and predicate rewrites cover the remaining ~50%. As a last resort, force the plan via pg_hint_plan or its equivalents on other engines — but hints lock the plan against future data shifts, so always add a comment explaining why and revisit periodically.
Practice on PipeCode
- Drill the SQL optimization practice library → for
EXPLAIN ANALYZEreading, plan-tree diffs,Rows Removed by Filterdiagnostics, and one-line index or rewrite fixes. - Sharpen the composite-index reflex with SQL indexing drills → — covering indexes,
INCLUDEcolumns, partial indexes, index-only scans, and cluster keys. - Layer the join-optimisation surface with SQL join drills → — hash vs merge vs nested loop, broadcast vs shuffle joins on warehouses, and skew mitigation.
- Push the SQL join-language depth with join drills on SQL → — semi-joins, anti-joins, lateral joins, and self-joins that show up in plan-reading interview questions.
- Warm up with aggregation practice → — hash aggregate vs group aggregate, pre-aggregation patterns, and materialised-view design.
- Layer window function drills → —
ROW_NUMBER()/RANK()/LAG()plans, partition-key alignment with clustering, and window-frame reading. - Sharpen the general SQL surface with the SQL practice library → — 450+ DE-focused questions covering plan reading, index design,
EXPLAIN ANALYZE, and every adjacent pattern. - For the broader SQL interview surface, take the SQL for Data Engineering course →.
Pipecode.ai is Leetcode for Data Engineering — every `sql explain analyze` recipe above ships with hands-on practice rooms where you type `EXPLAIN (ANALYZE, BUFFERS, VERBOSE)` on a live Postgres, read the plan tree bottom-up, spot the missing index from `Rows Removed by Filter`, migrate the same query to a Snowflake profile with pruning % and remote spill diagnosis, translate it into a BigQuery stage graph with `slot_ms` and `--dry_run` cost analysis, and finally read the SQL Server actual execution plan with `SET STATISTICS TIME/IO ON` — the exact four-engine `read execution plan` fluency that senior DE interviews probe. PipeCode pairs every plan-reading concept with 450+ DE-focused problems and a real-time scoring engine, so you never have to wonder whether your plan-diagnosis answer holds up under a senior interviewer's depth probes.





Top comments (0)