Advanced SQL: window functions, CTEs and query plans
Learn the SQL that data engineering interviews test: ranking, running totals and LAG with window functions, readable CTEs, and how to read a query plan to find out why a query is slow. Runs on your laptop with Python and SQLite.
You will learn the SQL that separates a data engineer from someone who only knows SELECT ... WHERE: window functions (ranking, running totals, comparing with the previous row), CTEs (named steps that make long queries readable) and query plans (how to see why a query is slow).
Prerequisites: basic SQL (SELECT, WHERE, GROUP BY, JOIN) and Python 3 installed. We use SQLite because it comes with Python and needs no setup. Everything here also works in PostgreSQL, MySQL 8+, BigQuery and Snowflake, with small differences in date functions that we point out.
Set up a practice database
Create a new folder with two files. setup.sql builds a tiny orders table (all prices in ₹). run_sql.py loads it into a fresh in-memory database and runs whichever query file you give it.
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer TEXT NOT NULL,
order_date TEXT NOT NULL,
total INTEGER NOT NULL
);
INSERT INTO orders (order_id, customer, order_date, total) VALUES
(1, 'Asha', '2026-10-01', 300),
(2, 'Ravi', '2026-10-01', 120),
(3, 'Asha', '2026-10-03', 180),
(4, 'Meena', '2026-10-03', 460),
(5, 'Ravi', '2026-10-04', 90),
(6, 'Asha', '2026-10-06', 220),
(7, 'Meena', '2026-10-07', 150),
(8, 'Ravi', '2026-10-07', 310);Creates one table with 8 orders from 3 customers over 7 days.
import sqlite3
import sys
conn = sqlite3.connect(":memory:")
with open("setup.sql", encoding="utf-8") as f:
conn.executescript(f.read())
with open(sys.argv[1], encoding="utf-8") as f:
cursor = conn.execute(f.read())
print(" | ".join(col[0] for col in cursor.description))
for row in cursor:
print(" | ".join(str(value) for value in row))Builds the database from setup.sql, runs the query in the file you pass, and prints the column names and rows.
How to run the examples
- 1Create a folder
sql-practiceand savesetup.sqlandrun_sql.pyin it. - 2Save each query from this lesson in its own file (
q1.sql,q2.sqland so on) in the same folder. - 3Open a terminal in the folder and run
python run_sql.py q1.sql. - 4Compare your output with the output shown in the lesson, then change the query and run it again.
To run a query, save it in a file such as q1.sql and run python run_sql.py q1.sql. Window functions need SQLite 3.25 or newer; any recent Python has this. Check with python -c "import sqlite3; print(sqlite3.sqlite_version)".
Window functions: calculations across related rows
GROUP BY collapses many rows into one. A window function calculates something across a group of related rows but keeps every row. That is why they are so useful: you can add a rank, a running total or the previous value as a new column next to the original data.
The shape is always: function() OVER (PARTITION BY ... ORDER BY ...). PARTITION BY splits rows into groups (like GROUP BY, but without collapsing). ORDER BY sets the order inside each group.
Ranking: ROW_NUMBER and RANK
SELECT order_id, customer, total,
ROW_NUMBER() OVER (ORDER BY total DESC) AS row_num,
RANK() OVER (ORDER BY total DESC) AS rnk
FROM orders
ORDER BY total DESCNumbers every order from the biggest to the smallest total.
order_id | customer | total | row_num | rnk
4 | Meena | 460 | 1 | 1
8 | Ravi | 310 | 2 | 2
1 | Asha | 300 | 3 | 3
6 | Asha | 220 | 4 | 4
3 | Asha | 180 | 5 | 5
7 | Meena | 150 | 6 | 6
2 | Ravi | 120 | 7 | 7
5 | Ravi | 90 | 8 | 8Here both columns agree because no two totals are equal. They differ when there are ties. Suppose two orders both have a total of 300:
How the three ranking functions treat ties
| total | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 460 | 1 | 1 | 1 |
| 300 | 2 | 2 | 2 |
| 300 | 3 | 2 | 2 |
| 220 | 4 | 4 | 3 |
ROW_NUMBER always gives unique numbers (ties are broken arbitrarily unless you add more columns to ORDER BY). RANK gives tied rows the same rank and then skips numbers. DENSE_RANK gives tied rows the same rank and does not skip.
The classic interview question: top N per group
"Find each customer's biggest order." You cannot use WHERE rn = 1 in the same SELECT that creates rn, because WHERE runs before window functions. So you compute the rank in a CTE (a WITH clause, a named temporary result) and filter in the outer query.
WITH ranked AS (
SELECT customer, order_id, total,
ROW_NUMBER() OVER (PARTITION BY customer ORDER BY total DESC) AS rn
FROM orders
)
SELECT customer, order_id, total
FROM ranked
WHERE rn = 1
ORDER BY customerPARTITION BY customer restarts the numbering for each customer; rn = 1 keeps their biggest order.
customer | order_id | total
Asha | 1 | 300
Meena | 4 | 460
Ravi | 8 | 310For "top 2 per group" change the filter to rn <= 2. If you want ties to all be included, use RANK() instead of ROW_NUMBER(). Ask the interviewer which they want: it shows you have thought about ties.
Running totals
SELECT customer, order_date, total,
SUM(total) OVER (PARTITION BY customer ORDER BY order_date, order_id) AS running_total
FROM orders
ORDER BY customer, order_date, order_idAdds up each customer's spending order by order.
customer | order_date | total | running_total
Asha | 2026-10-01 | 300 | 300
Asha | 2026-10-03 | 180 | 480
Asha | 2026-10-06 | 220 | 700
Meena | 2026-10-03 | 460 | 460
Meena | 2026-10-07 | 150 | 610
Ravi | 2026-10-01 | 120 | 120
Ravi | 2026-10-04 | 90 | 210
Ravi | 2026-10-07 | 310 | 520When you add ORDER BY to an aggregate inside OVER, the default is "from the first row of the partition up to the current row", which is exactly a running total. We added order_id to the ORDER BY so that two orders on the same date have a fixed order.
Looking at neighbouring rows: LAG and LEAD
LAG(column) gives the value from the previous row (in the window's order). LEAD(column) gives the next row. They are the tool for "compared with yesterday" questions. First step: total revenue per day, in a CTE. Second step: compare each day with the previous one.
WITH daily AS (
SELECT order_date, SUM(total) AS revenue
FROM orders
GROUP BY order_date
)
SELECT order_date, revenue,
LAG(revenue) OVER (ORDER BY order_date) AS prev_revenue,
revenue - LAG(revenue) OVER (ORDER BY order_date) AS change
FROM daily
ORDER BY order_dateDay-over-day change in revenue. The first day has no previous row, so LAG gives NULL.
order_date | revenue | prev_revenue | change
2026-10-01 | 420 | None | None
2026-10-03 | 640 | 420 | 220
2026-10-04 | 90 | 640 | -550
2026-10-06 | 220 | 90 | 130
2026-10-07 | 460 | 220 | 240Missing days
"Previous row" means the previous row that exists, not the previous calendar day. Our shop had no orders on 5 October, so 6 October is compared with 4 October. If you need strict day-by-day comparison, first build a table of all dates (a calendar table) and left join your data to it.
(Python prints SQL NULL as None; in a SQL client you will see NULL.)
Moving averages and window frames
A frame says which rows around the current row the function can see. ROWS BETWEEN 2 PRECEDING AND CURRENT ROW means the current row and the two before it: a 3-row moving average.
WITH daily AS (
SELECT order_date, SUM(total) AS revenue
FROM orders
GROUP BY order_date
)
SELECT order_date, revenue,
ROUND(AVG(revenue) OVER (
ORDER BY order_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 1) AS avg_last_3_days
FROM daily
ORDER BY order_dateSmooths out daily ups and downs. The first rows average over fewer than 3 rows because there is no earlier data.
order_date | revenue | avg_last_3_days
2026-10-01 | 420 | 420.0
2026-10-03 | 640 | 530.0
2026-10-04 | 90 | 383.3
2026-10-06 | 220 | 316.7
2026-10-07 | 460 | 256.7CTEs: write queries as readable steps
You have already used CTEs. A CTE names a sub-query so the main query reads top to bottom, like a recipe: first compute daily totals, then compare days. Compare this with nesting the same logic inside FROM ( ... ) three levels deep. Both give the same result, but the CTE version is much easier to read, test and fix.
You can chain several CTEs by separating them with commas, and each can use the ones above it:
WITH ordered AS (
SELECT customer, order_date,
LAG(order_date) OVER (PARTITION BY customer ORDER BY order_date, order_id) AS prev_date
FROM orders
)
SELECT customer, order_date, prev_date,
CAST(julianday(order_date) - julianday(prev_date) AS INTEGER) AS days_between
FROM ordered
WHERE prev_date IS NOT NULL
ORDER BY customer, order_dateHow many days passed between each customer's orders. julianday() is SQLite's date arithmetic; in PostgreSQL use order_date - prev_date, in BigQuery DATE_DIFF(order_date, prev_date, DAY).
customer | order_date | prev_date | days_between
Asha | 2026-10-03 | 2026-10-01 | 2
Asha | 2026-10-06 | 2026-10-03 | 3
Meena | 2026-10-07 | 2026-10-03 | 4
Ravi | 2026-10-04 | 2026-10-01 | 3
Ravi | 2026-10-07 | 2026-10-04 | 3Debug a long query by running the CTEs one at a time. Replace the final SELECT with SELECT * FROM ordered and check that step before moving on.
Query plans: why is this query slow?
When SQL runs slowly, guessing is a waste of time. Ask the database how it plans to run the query. In SQLite put EXPLAIN QUERY PLAN in front. In PostgreSQL and MySQL use EXPLAIN (and EXPLAIN ANALYZE in PostgreSQL, which also runs the query and shows real timings). Warehouses such as BigQuery and Snowflake show a query profile in their web console.
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE customer = 'Asha'Asks SQLite how it would find Asha's orders.
id | parent | notused | detail
2 | 0 | 216 | SCAN ordersSCAN orders means a full table scan: the database reads every row to find the matching ones. For 8 rows it is nothing. For 80 million rows it is slow. Now add an index. Put this line at the end of setup.sql, then run q7.sql again.
CREATE INDEX idx_orders_customer ON orders(customer);An index is a sorted lookup structure on one column, like the index at the back of a book.
id | parent | notused | detail
3 | 0 | 62 | SEARCH orders USING INDEX idx_orders_customer (customer=?)SEARCH ... USING INDEX means the database jumps straight to the matching rows. The same idea applies in every database: a plan that scans a huge table to return a few rows is the first thing to look for.
Reading plans: what to look for
| In the plan, you see | Usually means | What to try |
|---|---|---|
| Full scan / Seq Scan on a big table, few rows returned | No usable index | Add an index on the filter or join column |
| Index scan / Index search | The index is used | Usually fine |
| Sort on a huge result | ORDER BY over many rows | Filter earlier, add LIMIT, or index the sort column |
| Estimated rows far from actual rows (PostgreSQL EXPLAIN ANALYZE) | The planner has stale statistics | Run ANALYZE on the table |
| Warehouse profile shows a very large amount of data scanned | Query reads columns or partitions it does not need | Select only needed columns; filter on the partition column |
Indexes are not free
Indexes speed up reads but slow down writes and take space, so do not index every column. And in cloud warehouses like BigQuery and Snowflake you usually do not create indexes; you rely on partitioning, clustering and selecting fewer columns. The lesson *OLTP vs OLAP* explains why.
Common mistakes
Mistakes to avoid
- Filtering on a window function in WHERE. It fails or gives wrong results. Compute it in a CTE, then filter outside.
- Forgetting PARTITION BY, so the ranking runs over the whole table instead of per group.
- ORDER BY inside OVER that is not unique, so ties give unstable results. Add a tie-breaker such as the id.
- Using
SELECT *in a CTE chain on wide tables. It makes queries slow and brittle. - Joining before aggregating and then summing: a join that duplicates rows inflates the totals. Check row counts before and after each join.
You are a senior data engineer. Explain this SQL query step by step in plain English. Then list any bugs or risks (duplicated rows from joins, NULL handling, ties, missing days). Finally suggest a version that is easier to read using CTEs.
Database: [SQLite / PostgreSQL / BigQuery / Snowflake]
Table definitions:
[paste CREATE TABLE statements, no real data]
Query:
[paste your query]What to expect: A good answer walks through each clause, flags real risks such as ties or a missing PARTITION BY, and gives the rewrite. Run both versions on your sample data and compare results.
How this comes up in interviews
- "Find the second highest salary / each department's top 3 earners." (DENSE_RANK or ROW_NUMBER in a CTE.)
- "Compute a running total or a 7-day moving average."
- "Find users who ordered on 3 consecutive days." (LAG, or ROW_NUMBER trick.)
- "What is the difference between RANK and DENSE_RANK?"
- "This query is slow. How do you investigate?" (Read the plan, check indexes, check data scanned.)
Try it: Top 2 orders per customer, then fix a tie
1. Write a query that returns each customer's two biggest orders, with the rank number.
2. Insert an extra order for Asha with total = 220 (same as order 6), for example INSERT INTO orders VALUES (9, 'Asha', '2026-10-07', 220); at the end of setup.sql. Re-run with ROW_NUMBER and then with RANK. Which orders appear for Asha, and why do the two functions differ?
Try it: Day-over-day percentage change
Extend q4.sql to add a pct_change column: the change divided by the previous day's revenue, as a percentage with one decimal place. The first day should show NULL, not an error.
Key takeaways
- Window functions add a calculation across related rows without collapsing them. The shape is
function() OVER (PARTITION BY ... ORDER BY ...). - ROW_NUMBER is unique, RANK skips after ties, DENSE_RANK does not skip.
- Top N per group: rank in a CTE, filter in the outer query.
- LAG and LEAD compare a row with its neighbours; they use the previous existing row, not the previous calendar day.
- CTEs turn a long query into readable, testable steps.
- Use EXPLAIN to see whether the database scans the whole table or uses an index before you guess at fixes.
Check yourself
Finishing the quiz marks the lesson as done.
1. You want each customer's single biggest order, keeping the full order row. Which approach is correct?
2. Three rows have totals 500, 400, 400. What does RANK() OVER (ORDER BY total DESC) return?
3. What is the difference between GROUP BY and a window function such as SUM() OVER (...)?
4. Your orders table has no orders on 5 October. You use LAG(revenue) OVER (ORDER BY order_date). What does the 6 October row compare against?
5. EXPLAIN shows a full table scan on a 50-million-row table for a query that returns 20 rows with WHERE customer_id = 42. What is the most likely fix?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.