1. CTE — Common Table Expression
A CTE is a temporary named result set that exists only for the duration of a query.
Syntax
WITH my_cte AS (
SELECT ...
)
SELECT *
FROM my_cte;Backend-dev mental model:
CTE = named intermediate query resultSimilar to:
const result = someQuery();and then using result later.
Example
WITH high_value_orders AS (
SELECT *
FROM orders
WHERE order_value > 1000
)
SELECT *
FROM high_value_orders;The CTE does not normally create a permanent table.
2. Why Use CTEs?
Main reasons:
-
Make large SQL queries easier to read
-
Break complex queries into steps
-
Avoid deeply nested subqueries
-
Reuse intermediate query results
-
Commonly used together with window functions
Without CTE
SELECT *
FROM (
SELECT *
FROM orders
WHERE order_value > 1000
) x
WHERE customer_id = 10;With CTE
WITH high_value_orders AS (
SELECT *
FROM orders
WHERE order_value > 1000
)
SELECT *
FROM high_value_orders
WHERE customer_id = 10;3. Multiple CTEs
You can create multiple CTEs in sequence.
WITH
high_value_orders AS (
SELECT *
FROM orders
WHERE order_value > 1000
),
recent_orders AS (
SELECT *
FROM high_value_orders
WHERE created_at >= '2026-01-01'
)
SELECT *
FROM recent_orders;Think of it as a pipeline:
orders
↓
high_value_orders
↓
recent_orders
↓
final queryWindow Functions
4. What Is a Window Function?
A window function performs a calculation using other related rows without removing the current row.
This is the most important concept.
GROUP BY
→ groups rows
→ collapses rows
WINDOW FUNCTION
→ logically groups rows
→ calculates something
→ keeps every original rowGeneral Syntax
FUNCTION() OVER (
PARTITION BY ...
ORDER BY ...
)Think of it as:
FUNCTION() OVER (
which rows should I consider?
in what order?
)5. PARTITION BY
PARTITION BY divides rows into logical groups for the window function.
It is similar to GROUP BY, but it does not collapse rows.
Example:
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
)This means:
For each customer:
take their orders
sort newest → oldest
assign row numbersExample data:
customer | order | date
---------|-------|------
A | 1 | Jan 1
A | 2 | Jan 5
B | 3 | Jan 3
B | 4 | Jan 8Partitions:
Customer A:
order 2
order 1
Customer B:
order 4
order 3Result:
A | order 2 | 1
A | order 1 | 2
B | order 4 | 1
B | order 3 | 2Important mental model:
GROUP BY = group + reduce
PARTITION BY = group + annotate6. ROW_NUMBER()
ROW_NUMBER() gives every row a unique sequential integer.
It always starts from 1 within each partition.
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
)Example:
value | row_number
------|-----------
300 | 1
300 | 2
200 | 3
100 | 4Even when values are equal, every row gets a unique number.
Common Use Case: Latest Record Per Customer
WITH ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) AS rn
FROM orders
)
SELECT *
FROM ranked
WHERE rn = 1;Meaning:
For every customer:
newest order → rn = 1
Then:
return only rn = 17. RANK()
RANK() gives equal values the same rank.
Example:
Values:
300
300
200
100Result:
300 → 1
300 → 1
200 → 3
100 → 4Rank 2 is skipped because two rows occupied rank 1.
8. DENSE_RANK()
DENSE_RANK() also gives equal values the same rank, but does not skip numbers.
300 → 1
300 → 1
200 → 2
100 → 3Comparison
Values: 300 300 200 100
ROW_NUMBER: 1 2 3 4
RANK: 1 1 3 4
DENSE_RANK: 1 1 2 3Use:
ROW_NUMBER()
→ when every row needs a unique number
RANK()
→ when ties should have the same rank and gaps are okay
DENSE_RANK()
→ when ties should have the same rank without gaps9. SUM() as a Window Function
Normal aggregation:
SELECT
customer_id,
SUM(amount)
FROM orders
GROUP BY customer_id;Input:
A | 100
A | 200
A | 300Output:
A | 600The individual rows disappear.
Using a window function:
SELECT
order_id,
customer_id,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
) AS customer_total
FROM orders;Result:
order | customer | amount | customer_total
------|----------|--------|---------------
1 | A | 100 | 600
2 | A | 200 | 600
3 | A | 300 | 600Every row remains.
10. COUNT() as a Window Function
COUNT(*) OVER (
PARTITION BY customer_id
)Example:
order | customer | order_count
------|----------|------------
1 | A | 3
2 | A | 3
3 | A | 3
4 | B | 2
5 | B | 2Useful for:
-
Orders per customer
-
Events per session
-
Transactions per account
-
Items per order
without losing individual rows.
11. LAG()
LAG() lets you access a value from a previous row.
LAG(amount) OVER (
ORDER BY created_at
)Example:
amount | previous_amount
-------|----------------
100 | NULL
150 | 100
200 | 150Useful for:
-
Previous status
-
Previous transaction amount
-
Previous login
-
Previous sensor reading
-
Comparing current vs previous values
12. LAG() Offset
You can specify how many rows backward to look.
Syntax:
LAG(column, offset)Example:
LAG(amount, 2) OVER (
ORDER BY created_at
)means:
Give me the amount from 2 rows before.Example:
amount | LAG(amount, 2)
-------|---------------
100 | NULL
200 | NULL
300 | 100
400 | 200If you don't specify an offset:
LAG(amount)it defaults to:
LAG(amount, 1)13. LEAD()
LEAD() does the opposite of LAG().
It accesses a value from a future row.
LEAD(amount) OVER (
ORDER BY created_at
)Example:
amount | next_amount
-------|------------
100 | 150
150 | 200
200 | NULL14. LEAD() Offset
You can specify how many rows forward to look.
LEAD(amount, 3) OVER (
ORDER BY created_at
)means:
Give me the amount from 3 rows ahead.General syntax:
LAG(value, offset)
LEAD(value, offset)15. Default Value for LAG() and LEAD()
You can also specify what should be returned when the requested row doesn't exist.
Syntax:
LAG(value, offset, default_value)
LEAD(value, offset, default_value)Example:
LAG(amount, 2, 0) OVER (
ORDER BY created_at
)Instead of returning NULL:
100 → 0
200 → 0
300 → 100
400 → 200So the arguments are:
LAG(column, how_far_back, fallback_value)
LEAD(column, how_far_forward, fallback_value)Important:
The meaning of "previous" and "next" is determined by:
ORDER BYinside the OVER(...).
It is not based on physical table order.
16. Running Total
Window functions are useful for cumulative calculations.
SUM(amount) OVER (
ORDER BY created_at
)Given:
100
200
50Result:
amount | running_total
-------|--------------
100 | 100
200 | 300
50 | 350You can combine this with partitions:
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at
)Now each customer gets their own running total.
17. GROUP BY vs PARTITION BY
GROUP BY
SELECT
customer_id,
SUM(amount)
FROM orders
GROUP BY customer_id;Input:
A | 100
A | 200
A | 300Output:
A | 600Three rows became one.
PARTITION BY
SELECT
customer_id,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
) AS total
FROM orders;Output:
A | 100 | 600
A | 200 | 600
A | 300 | 600All three rows remain.
Memorize
GROUP BY
= group + collapse
PARTITION BY
= group + calculate + preserve rows18. CTE + Window Function Together
This is one of the most common patterns.
Problem:
Get the highest-value order from every customer.
WITH ranked_orders AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_value DESC
) AS rn
FROM orders
)
SELECT *
FROM ranked_orders
WHERE rn = 1;Conceptually:
Step 1:
orders
↓
window function calculates rn
Step 2:
CTE stores that intermediate result
Step 3:
outer query filters WHERE rn = 119. Why CTE Is Often Needed with ROW_NUMBER()
You generally cannot do:
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) AS rn
FROM orders
WHERE rn = 1;because WHERE is evaluated before the SELECT alias/window result is available.
Instead:
WITH ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) AS rn
FROM orders
)
SELECT *
FROM ranked
WHERE rn = 1;Think:
CTE
↓
calculate window function
↓
outer query
↓
filter calculated result20. Useful Backend Patterns
Latest Row Per User
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY created_at DESC
)Then:
WHERE rn = 1Latest Status Per Order
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY created_at DESC
)Deduplicate Records
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC
)Keep:
rn = 1Top 3 Orders Per Customer
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_value DESC
)Then:
WHERE rn <= 3Previous Status
LAG(status) OVER (
PARTITION BY order_id
ORDER BY created_at
)Status Two Events Ago
LAG(status, 2) OVER (
PARTITION BY order_id
ORDER BY created_at
)Next Scheduled Event
LEAD(created_at) OVER (
PARTITION BY customer_id
ORDER BY created_at
)Total Without Losing Rows
SUM(amount) OVER (
PARTITION BY customer_id
)21. Window Function Structure
When you see:
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
)read it from inside out:
PARTITION BY customer_id
→ divide data by customer
ORDER BY created_at DESC
→ newest first within each customer
ROW_NUMBER()
→ assign 1, 2, 3... to those rowsGeneral mental model:
FUNCTION() OVER (
PARTITION BY grouping
ORDER BY ordering
)22. Cheat Sheet
CTE
WITH x AS (...)
→ Named intermediate query resultOVER(...)
→ Defines the window for a window functionPARTITION BY
→ Which rows belong togetherORDER BY inside OVER()
→ Order of rows inside the partitionROW_NUMBER()
→ 1, 2, 3, 4
→ always uniqueRANK()
→ 1, 1, 3, 4
→ ties share rank
→ gaps possibleDENSE_RANK()
→ 1, 1, 2, 3
→ ties share rank
→ no gapsLAG(column)
→ previous rowLAG(column, 3)
→ 3 rows beforeLEAD(column)
→ next rowLEAD(column, 3)
→ 3 rows aheadLAG(column, 2, 0)
→ 2 rows before
→ return 0 if it doesn't existSUM() OVER(...)
→ sum while keeping rowsCOUNT() OVER(...)
→ count while keeping rows23. Final Mental Models
The most important things to remember:
CTE
= name an intermediate query resultGROUP BY
= group rows and reduce themPARTITION BY
= logically group rows without removing themWindow Function
= calculate something using related rows
while preserving the current rowROW_NUMBER
= unique position of a rowLAG / LEAD
= navigate backward / forward within ordered rowsFor backend development, the most useful window-function concepts to master first are:
-
ROW_NUMBER() -
PARTITION BY -
SUM() OVER(...) -
COUNT() OVER(...) -
LAG() -
LEAD() -
CTE +
ROW_NUMBER()together
These cover a large percentage of real-world backend SQL use cases.