Day 3 of 5
⏱ ~55 minutes
PostgreSQL for Developers — Day 3

CTEs and Window Functions: Analytical SQL

Subqueries work. CTEs make them readable. Window functions let you compute running totals, rankings, and comparisons without collapsing rows. These are the queries that make analysts' jobs possible.

Common Table Expressions (CTEs)

A CTE is a named subquery defined at the top of a query with WITH. It makes complex queries readable by breaking them into named steps.

sql
-- Without CTE (hard to read)
SELECT name, total_spent
FROM (
    SELECT c.name, SUM(o.amount) AS total_spent
    FROM customers c
    JOIN orders o ON o.customer_id = c.id
    WHERE o.status = 'completed'
    GROUP BY c.id, c.name
) AS customer_totals
WHERE total_spent > 1000;

-- With CTE (same result, much clearer)
WITH customer_totals AS (
    SELECT c.name, SUM(o.amount) AS total_spent
    FROM customers c
    JOIN orders o ON o.customer_id = c.id
    WHERE o.status = 'completed'
    GROUP BY c.id, c.name
)
SELECT name, total_spent
FROM customer_totals
WHERE total_spent > 1000
ORDER BY total_spent DESC;

Chaining Multiple CTEs

sql
WITH
monthly_revenue AS (
    SELECT
        DATE_TRUNC('month', created_at) AS month,
        SUM(amount)                      AS revenue
    FROM orders
    WHERE status = 'completed'
    GROUP BY 1
),
prev_month AS (
    SELECT
        month,
        revenue,
        LAG(revenue) OVER (ORDER BY month) AS prev_revenue
    FROM monthly_revenue
)
SELECT
    month,
    revenue,
    prev_revenue,
    ROUND((revenue - prev_revenue) / prev_revenue * 100, 1) AS growth_pct
FROM prev_month
ORDER BY month;

Window Functions — Compute Over a Set of Rows

Window functions compute a value for each row based on a "window" of related rows — without collapsing those rows into a single aggregate. The OVER() clause defines the window.

sql
-- Running total of revenue over time
SELECT
    created_at::DATE                        AS date,
    amount,
    SUM(amount) OVER (ORDER BY created_at)  AS running_total
FROM orders
WHERE status = 'completed'
ORDER BY created_at;

-- Rank customers by total spending
SELECT
    c.name,
    SUM(o.amount)                           AS total_spent,
    RANK() OVER (ORDER BY SUM(o.amount) DESC) AS spending_rank
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;

-- Partition by — separate window per group
SELECT
    c.name,
    o.status,
    o.amount,
    SUM(o.amount) OVER (PARTITION BY o.status) AS status_total
FROM orders o
JOIN customers c ON c.id = o.customer_id;

Useful Window Functions

sql
-- ROW_NUMBER: unique number per row within partition
SELECT
    customer_id,
    created_at,
    ROW_NUMBER() OVER (
        PARTITION BY customer_id
        ORDER BY created_at
    ) AS order_number   -- 1st, 2nd, 3rd order per customer

FROM orders;

-- LEAD and LAG: access adjacent rows
SELECT
    created_at::DATE    AS date,
    amount,
    LAG(amount)  OVER (ORDER BY created_at) AS prev_day_amount,
    LEAD(amount) OVER (ORDER BY created_at) AS next_day_amount
FROM orders;

-- NTILE: divide rows into N buckets
SELECT
    name,
    total_spent,
    NTILE(4) OVER (ORDER BY total_spent) AS quartile
FROM customer_totals;
📝 Exercise
Analytical Queries With CTEs and Windows
  1. Using the orders table, write a CTE that calculates monthly revenue.
  2. Extend it with a second CTE that adds month-over-month growth percentage.
  3. Write a window function query that shows each order with a running total per customer.
  4. Find the first order (by date) for each customer using ROW_NUMBER() and a CTE.
  5. Rank all customers by total spending in a single query using RANK().

Lesson Summary

  • CTEs use WITH name AS (...) and make multi-step queries readable. Chain them with commas.
  • Window functions use OVER() to define the rows they compute across — they don't collapse rows.
  • PARTITION BY resets the window per group. ORDER BY inside OVER() controls row ordering.
  • LAG() and LEAD() access the previous or next row — perfect for period-over-period comparisons.
Challenge

Find the top 3 customers by spending in each calendar month. Use a CTE to aggregate monthly spending per customer, then use ROW_NUMBER() with PARTITION BY month to rank them, then filter to rank <= 3.

Finished this lesson?