Layered query design, debuggable intermediate results, and SQL that is easy to narrate in interviews.
⏱ 32 min readTopics chapter readerLevel · Medium
01 · Orientation
What You'll Master Here
source rows, cleaned rows, shaped rows, final answer.
⏱ 5 min · Topic 1 of 13
Chapter 4 turns SQL from a pile of clauses into a readable plan. You will use CTEs, subqueries, and derived tables — and keep the last SELECT simple — so that even complex work stays easy to check and fix.
The key shift is to think in named row sets. A row set is just a batch of rows — the result of a query — that you give a name so you can use it later. Each CTE or subquery then answers one small question: which rows do I want, what does one row stand for, what am I measuring?
By the end you should be able to break a hard query into layers, choose between a CTE and a subquery on purpose, and walk someone through your answer step by step instead of just reciting syntax.
Core mental model
A complex query is a small pipeline written inside one statement: source rows, cleaned rows, shaped rows, final answer.
Why data engineers care
Interview SQL and real-world SQL both reward clear thinking. An answer that is right but impossible to check is fragile, and a query nobody can explain causes trouble later.
CTE
A batch of rows you name with WITH and then use in the main query — like a temporary table that exists only for this one query.
subquery
A SELECT written inside another query — used to filter rows, to work out a value to compare against, or to build a batch of rows for the outer query.
derived table
A subquery written in the FROM clause. The outer query reads it just like a table.
correlation
A subquery that looks back at the row the outer query is currently checking, so it runs again for each of those rows.
1. raw_orders
filter and standardize source rows
2. paid_orders
keep the business population
3. country_revenue
aggregate at the reporting grain
4. final select
name columns for the reader
A CTE stack should read like a debug transcript: each layer has one job and can be selected independently while you inspect the query.
Name the working set in a CTEworked example
SQL
Input data
orders5 rows
order_id
buyer_id
status
total_amount
1001
1
paid
80
1002
3
paid
120
1003
4
pending
40
1004
1
shipped
220
1005
2
paid
540
The pending order 1003 is excluded; 9 of the 12 orders are paid or shipped.
Showing the first 4 of the 9 paid/shipped rows the CTE defines.
The CTE names the rows you care about; the final select just reads from it. One idea, one name.
Common mistake
Cramming every transformation into one large SELECT. The query may run, but it becomes hard to review, test, and fix when a row count changes.
Better habit
Name intermediate row sets after the job they perform.
Run each layer before adding the next.
Keep the final SELECT boring and obvious.
Interview note
A strong layered answer sounds like a calm walkthrough: “first I isolate the rows, then I aggregate, then I expose the result.”
Study tip
Use the topic menu as a checklist. Each topic is a structuring habit you should be able to demonstrate, not just recognise.
Remember this
Think in named row sets. The best complex SQL reads like a short pipeline you can check one step at a time.
02 · Layering
CTEs As Named Query Layers
filter, then aggregate, then show the result. Read the stack top to bottom, like sentences.
⏱ 5 min · Topic 2 of 13
A WITH block can chain several CTEs, and each one can build on the previous. This is how a multi-step calculation stays readable: every step has a name and a single job.
Here the first layer isolates paid and shipped orders, and the second aggregates revenue per buyer from that clean set. The final select just orders the answer.
Because each layer is independent, you can run any one of them on its own to check it before moving on.
Core mental model
Each CTE does one job: filter, then aggregate, then show the result. Read the stack top to bottom, like sentences.
Why data engineers care
Most real metrics are several steps deep: filter, aggregate, join, rank. Naming each step is what lets the next engineer follow — and debug — your logic.
Two layers: filter, then aggregateworked example
SQL
Input data
orders5 rows
order_id
buyer_id
status
total_amount
1001
1
paid
80
1004
1
shipped
220
1009
1
pending
30
1005
2
paid
540
1011
2
paid
110
Buyer 1's pending order 1009 is excluded. Paid/shipped totals: 1→300, 2→650, 3→290, 4→150, 6→640.
Revenue is summed only over paid/shipped orders, so buyer 1 is 300 (not 330) — the pending order was filtered out in layer one.
paid_orders defines the rows; buyer_revenue defines the metric. The final select only orders the result.
One big SELECT vs a CTE stack
Aspect
One SELECT
CTE stack
Readability
Degrades fast
Each step is named
Debugging
All or nothing
Run one layer at a time
Reuse
Copy/paste logic
Reference a CTE again
Common mistake
Aggregating before the row set is clean. A stray status or NULL leaks into the metric, and the bug is buried inside one big statement.
Better habit
Give each layer a name that states its job.
Filter to the right rows before you aggregate.
Let the final select show the answer, not work it out.
Interview note
Talk through the layers out loud: “paid_orders is the set of rows I care about, buyer_revenue is the number I am measuring.” It shows structured thinking.
Production note
A named layer is also a natural place to add a data-quality check or a comment that the next on-call engineer will thank you for.
Remember this
Chain CTEs so each step has one job. Filter, then aggregate, then show the result — top to bottom.
03 · Debugging
Debugging One Layer At A Time
Point a quick check at one layer — how many rows, does the key repeat, are there NULLs — before you build the next layer on top.
⏱ 6 min · Topic 3 of 13
The hidden superpower of CTEs is debugging. Before you trust the final answer, temporarily swap the final SELECT for a quick check against any layer.
Counting the rows in a layer, or checking that a key has no duplicates, turns a vague “the number looks off” into a precise “the filter kept 9 rows, as expected”.
The example below expects 9 paid or shipped orders out of 12. Ask what would happen if it came back as 12 instead: that would mean the status filter kept everything and is simply broken — and you would find that out now, while the query is five lines long, rather than after four more layers are stacked on top of it.
That timing is the whole point. A bug caught at the first layer costs you seconds. The same bug spotted in the final number, once the aggregates and joins are built on top of it, costs you an afternoon. This is the same habit of checking row counts from earlier chapters, now applied one layer at a time.
Core mental model
Point a quick check at one layer — how many rows, does the key repeat, are there NULLs — before you build the next layer on top.
Why data engineers care
When a layered query is wrong, the bug lives in exactly one layer. Inspecting layers is how you find it in minutes instead of staring at the whole query.
Inspect a layer before building on itworked example
SQL
Input data
orders5 rows
order_id
status
1001
paid
1003
pending
1004
shipped
1006
refunded
1010
paid
5 of 12 rows shown. 9 orders are paid or shipped; 3 are pending or refunded.
The filter kept exactly the 9 paid and shipped rows, and left the 3 pending and refunded ones out. This layer is the size you expected, so it is safe to build the next one on top of it.
Swap the real final select for a COUNT to confirm the layer is the size you expect, then build the rest.
Common mistake
Building the whole query, then debugging only the final number. You cannot tell which layer introduced the error, so you re-read everything instead of one step.
Better habit
Check row counts after each filter.
Check that the key does not repeat before you join.
Look for a NULL group before you aggregate.
Interview note
Saying “let me count the rows in that CTE before I aggregate” signals exactly the validation instinct interviewers look for.
Study tip
Keep a scratch “select count(*) from <layer>” handy. It is the fastest way to pin down which layer holds the bug.
Remember this
A layered query traps each bug inside one layer. Point a COUNT or a duplicate check at that layer before you trust it.
04 · Subqueries
Scalar Subqueries For Global Thresholds
Work out one number with the subquery, then filter every row against that single number.
⏱ 5 min · Topic 4 of 13
Scalar just means a single value. A scalar subquery returns exactly one row and one column — one value — so you can use it anywhere SQL expects a single value, most often in a WHERE comparison.
It is the natural way to compare every row against one number worked out from the whole table: above the overall average, after the latest timestamp, bigger than the company-wide total.
The subquery runs once to work out that one number, then every row from the main query is compared against it.
Core mental model
Work out one number with the subquery, then filter every row against that single number.
Why data engineers care
“Above average”, “later than the most recent load”, and “over the limit” are everyday filters. A scalar subquery says all of that in one step, instead of you running one query to get the number and a second query to use it.
scalar subquery
A subquery that returns a single value — one row, one column. “Scalar” is just the technical word for “a single value”.
Orders above the overall averageworked example
SQL
Input data
orders5 rows
order_id
total_amount
1001
80
1004
220
1005
540
1009
30
1010
640
The overall AVG(total_amount) across all 12 orders is 180.
Only three orders exceed the 180 average; everything at or below it is filtered out.
The subquery computes the one average; the outer query keeps only rows that beat it.
Common mistake
Using a subquery that can return more than one row where a single value is expected. The engine errors (or, worse, silently misbehaves). A scalar comparison needs exactly one row, one column.
Better habit
Use scalar subqueries for one-value benchmarks.
Confirm the subquery truly returns a single value.
Name the benchmark in a comment if it is not obvious.
Study tip
If you need that same number several times, work it out once in a CTE instead of repeating the scalar subquery.
Interview note
Say what the subquery returns: “this scalar subquery is the average across all orders, so the filter keeps the above-average ones.”
Remember this
A scalar subquery is one value. Use it to compare every row against one number worked out from the whole table — above average, latest, over the limit.
05 · Subqueries
Correlated Subqueries For Row-by-Row Questions
For each row in the main query, ask whether the inner query can find at least one matching row. Stop at the first match.
⏱ 6 min · Topic 5 of 13
A correlated subquery looks at a column from the row the main query is currently checking. Because that row keeps changing, the subquery runs again for every one of those rows. EXISTS is the most common form: “is there at least one matching row?”
In the first example below, one line does all of this: `where o.buyer_id = c.customer_id`. The `c` belongs to the outer query, so that line reaches out to the customer being checked right now. That reach is the correlation, and it is why the inner query cannot run just once — it has to run again for each customer. Compare that with the scalar subquery from the previous section, which runs once and hands the same number to every row.
Unlike a join, EXISTS returns each row from the main query at most once, so it never duplicates rows — the same fan-out-safe pattern from Chapter 2. (Fan-out is when a join accidentally makes copies of your rows.)
Use it for eligibility and lifecycle questions: customers who have ordered, accounts with a failed payment, users with any activity this week.
Core mental model
For each row in the main query, ask whether the inner query can find at least one matching row. Stop at the first match.
Why data engineers care
Existence questions drive eligibility, activation, and fraud logic. EXISTS expresses them clearly and avoids the duplicate rows a naive join would create.
Follow the walk, one customer at a time: buyer 1 → order 1004 found → keep. Buyer 2 → nothing → drop. Buyer 3 → order 1012 → keep. Buyer 4 → order 1008 → keep. Buyer 6 → nothing → drop. The inner query ran five times, once per customer, each time with a different customer_id plugged in.
For each customer, EXISTS checks whether at least one shipped order exists, then stops looking. `select 1` is just a convention — EXISTS ignores what you select and only cares whether a row came back. So each customer appears once.
Why EXISTS cannot duplicate a rowworked example
SQL
Input data
customers6 rows
customer_id
country
1
US
2
NULL
3
GB
4
IN
5
US
6
GB
Customer 5 has never placed an order.
orders (all statuses)7 rows
order_id
buyer_id
1001
1
1004
1
1009
1
1005
2
1002
3
1003
4
1010
6
Buyer 1 alone has three orders: 1001, 1004, and 1009.
-- drop the status filter: now buyer 1 matches three timesselectc.customer_id,c.countryfromcustomersascwhereexists(select1fromordersasowhereo.buyer_id=c.customer_id)orderbyc.customer_id;
Result · 5 rows
customer_id
country
1
US
2
NULL
3
GB
4
IN
6
GB
Customer 1 appears exactly once, even though three orders matched — that is the whole point. A join would have returned customer 1 three times and left you reaching for DISTINCT. Customer 5 has no orders at all, so they drop out.
This is the case that separates EXISTS from a join. Buyer 1 has three orders, so the inner query finds three matches — but EXISTS stops at the first one and answers a plain “yes”. A join asks a different question (“give me the matching rows”), so it would return customer 1 once per order.
Scalar subquery vs correlated subquery
Type
How often it runs
Knows about the outer row?
Scalar (previous section)
Once, for the whole query
No — one number is handed to every row
Correlated (this section)
Once for every row of the main query
Yes — that is what makes it correlated
EXISTS vs a join for existence
Approach
Rows returned
Risk
WHERE EXISTS (...)
Each main-query row once
Safe, reads as a sentence
JOIN + DISTINCT
After deduping a fan-out
Fan-out happens first, DISTINCT hides it
Common mistake
Joining to the related table just to test existence. A one-to-many join makes copies of your rows, so you need DISTINCT to undo damage EXISTS would have avoided in the first place.
Better habit
Use EXISTS / NOT EXISTS for existence questions.
Correlate on the key (o.buyer_id = c.customer_id).
Prefer NOT EXISTS over NOT IN when the column can be NULL.
Interview note
Reaching for EXISTS on an existence question — instead of a join you then have to DISTINCT — signals you think about fan-out before it bites.
Production note
Correlated subqueries can be slower on large tables. Get it correct and readable first, then check how the database plans to run it and switch to a join if you need the speed.
Remember this
A correlated EXISTS asks “is there at least one match?” for each row of the main query, and returns each row once — existence without duplicates.
06 · Subqueries
Derived Tables In FROM
Aggregate inside the FROM subquery to get one row per thing you are measuring, then filter those rows in the outer WHERE.
⏱ 5 min · Topic 6 of 13
A derived table is a subquery in the FROM clause that the outer query treats like a table. It is handy when you need to aggregate first and then filter on that aggregate.
It is closely related to a CTE: both give a name to a middle step. A derived table is short and stays where it is used; a CTE is named up top, can be reused, and is easier to check on its own.
A common use is the “aggregate then filter” shape: compute buyer revenue, then keep only the high-value buyers.
Core mental model
Aggregate inside the FROM subquery to get one row per thing you are measuring, then filter those rows in the outer WHERE.
Why data engineers care
Filtering on an aggregate is a daily need. A derived table (or a CTE) gives the aggregate a place to live so the outer query can filter it.
Aggregate first, then filter the totalsworked example
Buyer 4 totals 190, below 200, so it drops out. This is the HAVING idea expressed as a derived table.
The derived table buyer_totals is one row per buyer; the outer query keeps only buyers at or above 200.
CTE vs derived table
Pattern
Best when
Tradeoff
CTE
Several named steps make the story clearer
More vertical space, easier to debug
Derived table
One local aggregate feeds one outer query
Compact, but easy to bury logic
Common mistake
Leaving a derived table unnamed or with an unclear alias. The outer query pulls columns out of something the reader cannot identify, which is hard to read and fix.
Better habit
Name the derived table after the rows it produces.
Move it into a CTE once it needs a real name or a second use.
Filter aggregates in the outer query (or use HAVING).
Study tip
A derived table that feeds only one outer query and stays small is fine. The moment you want to reference it twice, make it a CTE.
Interview note
Point out that they are the same thing: “this derived table does what HAVING does — it just gives me a batch of rows I can filter.”
Remember this
A derived table names an aggregate inside FROM so you can filter it. Move it into a CTE once it earns a name.
07 · Structure
Reusable Cleanup CTEs
Clean once at the top, then let every later layer trust the cleaned columns.
⏱ 6 min · Topic 7 of 13
Open a messy query and you will often spot the same small fix written again and again — a COALESCE here, a CASE there, all doing the exact same job.
Every copy is a chance to get it wrong. If the rule ever changes, you have to hunt down and update all of them. Miss one, and that copy keeps giving a different answer with nothing to warn you.
The fix is to do the cleaning once, in a CTE near the top, and give the cleaned column a name. Every layer below it just uses that name and never repeats the fix.
Core mental model
Clean once at the top, then let every later layer trust the cleaned columns.
Why data engineers care
Repeated cleanup is where bugs hide. Fix four of the five copies and the fifth keeps producing a wrong number, quietly, for months. One cleanup CTE removes that whole class of error.
The problem: the same fix written three timesworked example
SQL
Input data
customers6 rows
customer_id
country
1
US
2
NULL
3
GB
4
IN
5
US
6
GB
Customer 2 has no country recorded — it is NULL. That is the row COALESCE exists for.
orders7 rows
order_id
buyer_id
total_amount
1001
1
80
1004
1
220
1009
1
30
1005
2
540
1011
2
110
1002
3
120
1010
6
640
A sample of the 12 orders. Totals per buyer: 1→330, 2→650, 3→290, 4→190, 6→700. Buyer 5 never ordered.
-- coalesce(c.country, 'unknown') appears three timesselectcoalesce(c.country,'unknown')ascountry,count(*)asorders,sum(o.total_amount)asrevenuefromordersasojoincustomersasconc.customer_id=o.buyer_idwherecoalesce(c.country,'unknown')<>'IN'groupbycoalesce(c.country,'unknown')orderbyrevenuedesc;
Result · 3 rows
country
orders
revenue
GB
5
990
unknown
2
650
US
3
330
Customer 2 has no country, so their 2 orders and 650 in revenue land in an “unknown” group instead of disappearing. GB combines customers 3 and 6. IN was filtered out.
The answer is right, but read the query: the same COALESCE is written in the SELECT, in the WHERE, and in the GROUP BY. Three copies of one rule. Decide tomorrow to call it “not set” instead of “unknown” and you must change all three — and if you change only two, the query still runs and quietly gives you the wrong groups.
The fix: clean once, then use the clean nameworked example
SQL
Input data
customers6 rows
customer_id
country
1
US
2
NULL
3
GB
4
IN
5
US
6
GB
The same input. Customer 2 still has a NULL country.
Exactly the same numbers as before. That is the point: the query did not get smarter, it got safer. One place to read, one place to change.
Same answer, one rule. cleaned_customers decides once what a missing country is called. After that, the SELECT, the WHERE, and the GROUP BY all just say c.country — they trust it and never repeat the fix. Change the rule now and you change one line.
Common mistake
Writing the same COALESCE or CASE in several places. The copies drift apart over time, and the one you forgot to update produces a wrong number.
Better habit
Move a repeated expression into a cleanup CTE.
Put the cleanup near the top, so later layers can trust it.
Name the cleanup CTE after the table it cleans.
Study tip
If the same CASE or COALESCE appears three times, that is your signal to move it into a cleanup CTE.
Production note
On real teams this cleanup CTE usually grows up into its own saved view or table — often called a staging model. The idea is the same, just bigger: one agreed place where a column is cleaned, so every team reads the same version instead of each inventing their own.
Remember this
Write a cleanup rule once, in its own CTE, and let the rest of the query use it by name. Repeating the same fix in three places is how one of them ends up wrong.
08 · Recursion
Recursive CTEs & Hierarchies
where the walk starts. Recursive term: how it takes one step. The engine repeats the step until a step finds nothing.
⏱ 6 min · Topic 8 of 13
Some questions cannot be answered with a fixed number of joins, because the answer depends on how deep the data goes. "Everyone beneath this manager" is the classic: one join gets the direct reports, two gets their reports, and the tree keeps going.
A recursive CTE has two halves joined by UNION ALL. The anchor produces the starting rows. The recursive term joins the table back to the rows the CTE has produced so far, and runs again on whatever it produced — until an iteration produces nothing.
The word RECURSIVE goes after WITH, once, even when several CTEs follow: `with recursive x as (...), y as (...)`. In SQL Server it is omitted entirely, which is the only dialect difference worth memorising here.
Core mental model
Anchor: where the walk starts. Recursive term: how it takes one step. The engine repeats the step until a step finds nothing.
Why data engineers care
Org charts, category trees, bill-of-materials, threaded comments, and any "chain of" question — supply chain, referral, account hierarchy — are all this shape. Writing it with three self joins works until somebody adds a level, and then it silently returns fewer rows.
Everyone beneath one manager, to any depthworked example
SQL
withrecursivesubtreeas(-- anchor: the direct reportsselectemployee_id,full_name,1asdepthfromemployeeswheremanager_id=2unionall-- recursive term: the reports of everyone we have already foundselecte.employee_id,e.full_name,s.depth+1fromemployeesasejoinsubtreeassone.manager_id=s.employee_id)selectemployee_id,full_name,depthfromsubtreeorderbydepth,employee_id;
Result · 7 rows
employee_id
full_name
depth
5
Eve Marsh
1
6
Farid Haddad
1
9
Ivy Chen
2
10
Jonas Weber
2
15
Omar Diaz
3
20
Tara Singh
4
22
Vik Sharma
5
Abridged — eleven rows in total. Three self joins would have returned the first eight and stopped, which looks like an answer rather than a truncation.
The recursive term references the CTE by name. That self-reference is what makes it recursive — and it is only allowed once, in one branch.
Walking the other way, and building a pathworked example
SQL
withrecursivechainas(selectemployee_id,full_name,full_nameaspath,1asdepthfromemployeeswheremanager_idisnull-- the root: IS NULL, never = NULLunionallselecte.employee_id,e.full_name,c.path||' > '||e.full_name,c.depth+1fromemployeesasejoinchainascone.manager_id=c.employee_id)selectfull_name,depth,pathfromchainwheredepth>=4orderbyemployee_id;
Result · 3 rows
full_name
depth
path
Ivy Chen
4
Ada Okafor > Bo Lindqvist > Eve Marsh > Ivy Chen
Omar Diaz
5
Ada Okafor > Bo Lindqvist > Eve Marsh > Jonas Weber > Omar Diaz
Vik Sharma
7
Ada Okafor > Bo Lindqvist > Eve Marsh > Jonas Weber > Omar Diaz > Tara Singh > Vik Sharma
Abridged. The WHERE lives in the FINAL select — putting depth >= 4 inside the recursive term would stop the walk before it ever reached depth 4 and return nothing.
Anchoring on the root builds every path in one traversal. Anchoring on each employee and walking up would re-walk the shared upper levels once per person.
Stopping deliberatelyworked example
SQL
withrecursivesubtreeas(selectemployee_id,full_name,0aslevels_downfromemployeeswhereemployee_id=2unionallselecte.employee_id,e.full_name,s.levels_down+1fromemployeesasejoinsubtreeassone.manager_id=s.employee_idwheres.levels_down<2-- the bound belongs HERE)selectfull_name,levels_downfromsubtreewherelevels_down>0orderbylevels_down,employee_id;
Result · 5 rows
full_name
levels_down
Eve Marsh
1
Farid Haddad
1
Ivy Chen
2
Jonas Weber
2
Kira Novak
2
Five rows instead of eleven. On a deep hierarchy the difference between bounding the walk and filtering the result is the entire query cost.
A bound in the recursive term stops the engine descending. The same predicate in the final SELECT returns the same rows, having walked the whole tree first.
Where to put things in a recursive CTE
You want to
Put it
Because
Start somewhere specific
The anchor
The anchor is the only part that references a constant.
Limit how deep the walk goes
The recursive term
It stops the engine descending, rather than discarding rows it already produced.
Show only part of the result
The final SELECT
The walk must pass through the rows you are hiding to reach the ones you want.
Aggregate the result
The final SELECT
Aggregates are not permitted in the recursive term on most engines.
Common mistake
Anchoring with `manager_id = null` instead of `is null`. The comparison is never true, the anchor returns no rows, and the entire CTE comes back empty — which reads as "there is no hierarchy" rather than as a bug.
Putting the depth filter in the recursive term when you meant to filter output. The walk stops before it reaches the rows you asked for, so the query returns nothing at all.
Using UNION instead of UNION ALL out of caution. A deduplication pass runs on every iteration. It is occasionally the fix for a graph that revisits nodes, and it is never free.
Assuming the data really is a tree. One bad `manager_id` creates a cycle and an unbounded recursion runs until the engine kills it. A depth bound is the portable guard; some engines also offer a recursion limit.
Better habit
Write the anchor and run it alone before adding the recursive term.
Carry a depth column always — it is free, and it is how you spot a runaway walk.
Bound the recursion on anything that might not be a true tree.
Dialect note
PostgreSQL, SQLite, MySQL 8+, Snowflake and BigQuery all require the RECURSIVE keyword; SQL Server and Oracle omit it. Oracle also keeps the older CONNECT BY syntax, which does the same job and is not portable to anything else.
Interview note
The org-chart question is asked because the naive answer — a few self joins — runs and returns plausible rows. Saying "the depth is a property of the data, so the number of joins cannot be fixed" is the sentence being listened for.
Remember this
Anchor, step, repeat. The engine stops when a step finds nothing — unless the data is not really a tree, in which case you stop it yourself.
09 · Readability
When A Query Is Too Nested
If you cannot say what one row of every layer stands for at a glance, the query is too nested.
⏱ 5 min · Topic 9 of 13
Unnamed subqueries tucked inside other unnamed subqueries are the classic “write-only” SQL — easy to write, painful to read. It runs, but nobody, including you next month, can quickly say what each layer means.
The query below works, but the inner aggregate has no name, so you cannot easily say what one of its rows stands for, or run it on its own to check it.
The fix is almost always the same: pull the nested subquery out into a named CTE, so each layer can be read and run by itself.
Core mental model
If you cannot say what one row of every layer stands for at a glance, the query is too nested.
Why data engineers care
Deeply nested SQL is slow to debug and risky to change. Naming the layers is the cheapest readability win available.
Works, but hard to readworked example
SQL
Input data
orders (paid only)6 rows
order_id
buyer_id
total_amount
1001
1
80
1005
2
540
1011
2
110
1002
3
120
1007
3
95
1010
6
640
Paid revenue per buyer: 1→80, 2→650, 3→215, 6→640.
-- the inner aggregate is anonymous: what is its grain?selectbuyer_id,revenuefrom(selectbuyer_id,sum(total_amount)asrevenuefromorderswherestatus='paid'groupbybuyer_id)asxwhererevenue>100orderbyrevenuedesc,buyer_id;
Result · 3 rows
buyer_id
revenue
2
650
6
640
3
215
Buyer 1 (80) is below the 100 cut-off, so it drops out. Pull the subquery out into a named `paid_buyer_revenue` CTE and it becomes obvious that each row is one buyer.
The result is correct, but the name x tells you nothing. It hides the fact that this is one row per buyer, holding their paid revenue.
Common mistake
Nesting several unnamed subqueries inside each other. You cannot quickly say what a row stands for, check row counts, or explain why a filter sits where it does.
Better habit
Use derived tables sparingly.
Reach for a CTE once a batch of rows deserves a name.
Do not try to write fewer lines while you are still learning.
Watch out
Every extra level of unnamed nesting roughly doubles the time it takes the next person to understand the query. Names are cheaper than comments.
Interview note
If asked to improve a nested query, say it out loud: “I would pull this subquery out into a named CTE, so it is clear what one row means and I can test it.”
Remember this
If you cannot say what one row of every layer stands for at a glance, pull the nested subquery out into a CTE.
10 · Final shape
The Final SELECT Should Be Boring
Do the work in named layers, and let the final SELECT be a clean window onto the answer.
⏱ 5 min · Topic 10 of 13
A layered query has CTEs doing the work at the top, and then one last SELECT at the bottom that produces the answer. That last one is the final SELECT.
It should do almost nothing. No SUM, no CASE, no filters, no math. It just picks the columns, puts them in order, and hands them over. All the real thinking already happened in the layers above it.
Think of maths homework. You show your working line by line down the page, and the last line is simply “= 42”. You would never cram the whole calculation into that last line. Your final SELECT is the “= 42” line.
So “boring” is a compliment here. A boring final SELECT is a sign that the layers above it did their jobs.
Core mental model
Do the work in named layers, and let the final SELECT be a clean window onto the answer.
Why data engineers care
The final SELECT is the first thing anyone reads. If it is calm and obvious, they trust the query straight away. If it is dense, they have to work backwards through your code just to work out what question it even answers.
All the work is upstream; the answer is exposedworked example
One row per buyer, biggest revenue first. The final SELECT holds no logic at all — it only names the two columns it wants and the order it wants them in.
Split it in two. The CTE does the thinking: it adds up the amounts and groups them per buyer. The final SELECT picks two columns and sorts them — there is not a single calculation in it. Read that last part out loud and it is just the question itself: “give me each buyer and their revenue, biggest first.”
Common mistake
Packing CASE logic, math, and filters into the final SELECT. The answer and the working-out end up tangled together, so the reader cannot tell what the query is actually asking.
Better habit
Do the work in CTEs; let the final SELECT just show the result.
Keep the final SELECT to naming columns and sorting them.
If the final SELECT starts growing logic, add a layer.
Interview note
If your final SELECT is stuffed with logic, the interviewer cannot see how you thought about the problem. Keep it boring on purpose.
Study tip
Here is the test. Read your final SELECT out loud. Can you say it as the answer to the question, with no “and also works out …” in the middle? If you catch yourself saying “it gives me revenue per buyer, and also drops refunds, and also rounds it”, each of those “and also”s wants to be its own layer.
Remember this
Keep the final SELECT boring — name the columns, sort them, show the answer. If it starts growing logic, that logic wants its own layer.
11 · Dialect
CTEs Are Structure, Not Magic
CTEs organise your thinking. Speed is a separate question, answered later by the query plan — not by the WITH keyword.
⏱ 5 min · Topic 11 of 13
PostgreSQL, BigQuery, Snowflake, and SQLite all support WITH queries. What differs is how each one runs a CTE behind the scenes: some work out its rows once and store them for the rest of the query (the technical word is materialising), while others fold the CTE into the main query and run it as one piece.
A clean CTE stack makes a query easier to read and check. It does not automatically make it faster. Think of CTEs as a way to organise, not to speed things up.
Once the query is correct and easy to explain, and only if the table is large, look at the query plan — the database’s own description of how it intends to run your query — and adjust if a CTE is being handled in a way that hurts.
Core mental model
CTEs organise your thinking. Speed is a separate question, answered later by the query plan — not by the WITH keyword.
Why data engineers care
Believing CTEs are always free, or always faster, leads to surprises on big tables. Knowing they are about structure, not speed, keeps your expectations honest.
A readable layer is still just a row setworked example
SQL
Input data
orders4 rows
order_id
created_at
1001
2026-01-12 08:40:00
1010
2026-01-30 21:10:00
1011
2026-02-02 09:00:00
1012
2026-02-05 10:00:00
All 12 orders fall in January–February 2026, so all are on or after 2026-01-01.
Every order is recent here. On a real table this same shape filters to a meaningful window.
The CTE makes the intent obvious. Whether the database stores its rows or folds it into the main query is a detail of how the engine chooses to run it.
Common mistake
Assuming a CTE stack is automatically faster than the same query written flat. Some engines store a CTE’s rows, which can help or hurt. Speed is decided by how the database plans to run it, not by the WITH keyword.
Better habit
Write for readability first.
Look at the query plan before trying to speed up a large query.
Leave a comment next to any trick that only works on one database.
Dialect note
Postgres used to always work out a CTE separately and store the result — a wall the optimiser would not look through — until v12 let it fold simple CTEs into the main query. BigQuery and Snowflake fold them in freely. SQLite supports WITH and RECURSIVE. Keep CTEs readable first, then check the query plan on big tables.
Production note
A clean CTE stack is not automatically faster. Performance work starts after correctness and explainability are settled.
Remember this
CTEs are for structure and debuggability. Treat performance as a separate question answered by the query plan.
12 · Practice
Practice Lab
source → cleaned → shaped → exposed.
⏱ 12 min · Topic 12 of 13
These six problems turn vague business questions into named, layered queries, and each opens in the live SQL workspace on the same marketplace dataset — customers, orders, and order_items.
Treat each as a small interview: state the row-set story (which rows, which grain, which metric), write the layers, run it to inspect the output, then submit for scored feedback.
Core mental model
Every lab should have a row-set story before it has a final answer: source → cleaned → shaped → exposed.
Why data engineers care
Structure becomes a skill only when you practice turning questions into intermediate row sets you can name, run, and defend under interview pressure.
Most problems start by naming a working set like this, then add an aggregate or join layer on top.
Common mistake
Writing the final answer before naming the intermediate row sets. The query becomes one dense SELECT that is hard to debug — the opposite of what this chapter practices.
Better habit
State the row-set story before writing SQL.
Run each layer, then add the next.
Keep the final SELECT boring.
Interview note
Narrate the layers before typing: “paid_orders is the population, buyer_revenue is the metric, the final select exposes it.”
Study tip
After solving each lab, try collapsing it into one SELECT and back into CTEs — feeling the readability difference is the lesson.
Remember this
Give every query a row-set story: source, cleaned, shaped, exposed. Structure is what makes complex SQL defensible.
Once your queries are structured clearly, the next step is analytics over ordered row sets: ranking, deduplication, offsets, running totals, and cohort-style analysis.
⏱ 3 min · Topic 13 of 13
Next chapter
Window Functions & Analytics Patterns
Once your queries are structured clearly, the next step is analytics over ordered row sets: ranking, deduplication, offsets, running totals, and cohort-style analysis.
Chapter 5 moves from layering logic to comparing rows without collapsing detail.