EduFlow
SQL for Analysts
0%

Section 4 · SQL in the Real World

Capstone: The Q4 Business Review

17 min3 min readSQL exercise

Lesson at a glance

Combine the whole course in one analysis, comparing Q4 2025 with Q4 2024 by country through named CTE steps, reconciling the totals, stating caveats and writing the answer a CEO can act on.

  1. 1Step 1: fix the definitions
  2. 2Step 2: build it in named steps
  3. 3Step 3: check before you believe
  4. 4Step 4: write the answer
  5. 5What you can do now

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

The last request of the year comes from Rania herself: “I’m presenting the Q4 review to the board. How did Q4 2025 compare with Q4 2024, country by country, and what’s the story?” This is the whole course in one question. It needs date ranges, joins without fan-out, conditional aggregation, CTEs, sanity checks and, at the end, a few sentences a board can act on. Work through it the way you would at a real desk: definitions first, then steps, then checks, then words.

Step 1: fix the definitions

Before any SQL, write down what each word in the question means, and put it where the query can see it:

  • Q4: 1 October to 31 December, filtered as half-open ranges, from the first day of the quarter up to but not including 1 January, for both 2024 and 2025.
  • Revenue: sum of quantity * unit_price * (1 - discount) from order_items, shipping excluded.
  • Which orders: every status except ‘cancelled’, the definition used throughout this course.
  • Country: the customer’s country, from customers.
  • Grain of the answer: one row per country.

The periods go into a tiny CTE of their own. Every later step joins to it, so changing the quarter means editing one place, not six.

Step 2: build it in named steps

periods (one row per quarter) and order_revenue (one row per order) feed q4_orders (one row per Q4 order), which feeds by_country (one row per country), which feeds the final SELECT that adds growth and average order value. periods 1 row / quarter order_revenue 1 row / order q4_orders 1 row / Q4 order by_country 1 row / country SELECT growth
The capstone query as a pipeline of CTEs, each with its own grain
SQL
WITH periods AS (
  SELECT 'Q4 2024' AS period, '2024-10-01' AS start_date, '2025-01-01' AS end_date
  UNION ALL
  SELECT 'Q4 2025', '2025-10-01', '2026-01-01'
),
order_revenue AS (
  -- grain: one row per order
  SELECT order_id, SUM(quantity * unit_price * (1 - discount)) AS revenue
  FROM order_items
  GROUP BY order_id
),
q4_orders AS (
  -- grain: one row per non-cancelled Q4 order
  SELECT p.period, c.country, o.order_id, r.revenue
  FROM orders AS o
  JOIN periods AS p
    ON o.order_date >= p.start_date AND o.order_date < p.end_date
  JOIN customers AS c ON c.customer_id = o.customer_id
  JOIN order_revenue AS r ON r.order_id = o.order_id
  WHERE o.status <> 'cancelled'
),
by_country AS (
  -- grain: one row per country
  SELECT
    country,
    SUM(revenue) FILTER (WHERE period = 'Q4 2024') AS revenue_2024,
    SUM(revenue) FILTER (WHERE period = 'Q4 2025') AS revenue_2025,
    COUNT(*)     FILTER (WHERE period = 'Q4 2025') AS orders_2025
  FROM q4_orders
  GROUP BY country
)
SELECT
  country,
  ROUND(revenue_2024, 2) AS revenue_q4_2024,
  ROUND(revenue_2025, 2) AS revenue_q4_2025,
  ROUND(100.0 * (revenue_2025 - revenue_2024) / NULLIF(revenue_2024, 0), 1) AS growth_pct,
  ROUND(revenue_2025 / orders_2025, 2) AS avg_order_value_2025
FROM by_country
ORDER BY revenue_q4_2025 DESC;

Every technique here has a lesson behind it. The join to periods uses a range condition in ON, which labels each order with its quarter and drops everything else. order_revenue collapses items first, so COUNT(*) later counts orders. FILTER puts both years side by side in one pass. NULLIF protects the growth calculation from a country with no sales last year.

Step 3: check before you believe

Two checks take a minute and catch most mistakes. First, reconcile: the eight revenue_q4_2025 values should add up to the total from a simpler query over orders and order_items alone, 146,825.44, and the 2024 column to 52,537.10. They do, to within a cent of rounding, so no fan-out or lost rows. Second, revisit the data-quality suite. The misfiled products don’t matter at country level. Two returned orders without return records and 63 orders still ‘processing’ or ‘shipped’ do: some Q4 2025 revenue may yet be cancelled or refunded.

Step 4: write the answer

The query is not the deliverable; the answer is. Here’s what goes to Rania:

Q4 2025 revenue was 146.8k, up from 52.5k in Q4 2024 (+179%), with every country at least doubling. The growth is volume, not basket size: orders rose from 228 to 642 while average order value held at about 230. The UK is now our largest market (26.5k, up from 5.4k). Jordan’s +627% comes from a small base. Caveat: 63 Q4 orders, most of them from December, were still processing or in transit at year end, so cancellations and returns could trim Q4 slightly.

Three to five sentences, the numbers that matter, one honest caveat. Notice what it doesn’t say: why volume grew. The cohort lesson suggests where to look next, at how many of those orders came from new customers acquired in the Black Friday weeks. A good answer usually ends by pointing at the next good question.

What you can do now

Look back at the first lesson’s request about thin margins. You’ve gone from SELECT and LIMIT to an analysis with five named steps, built-in checks and a written conclusion. More importantly, you’ve built habits: write the question and grain before the query, use half-open date ranges, put right-table filters in ON, aggregate before joining, use NOT EXISTS for “never”, reconcile every total, and treat NULLs and failed checks as questions. Those habits transfer to any database, any dialect and any business. The exercise below is your last one, written from a blank page.

Key takeaways

  • Start a real analysis by fixing definitions (periods, revenue, statuses) in one place, so every later step uses the same ones.
  • Build the query as CTE steps at explicit grains, aggregating to one row per order before joining anything that repeats.
  • Put comparison periods side by side with FILTER, and compute growth with NULLIF so a zero base gives NULL instead of an error.
  • Reconcile the final table against an independent total and list the data caveats before you write the conclusion.
  • Report the finding in plain sentences with the numbers that support it, including what the data cannot tell you yet.

Further reading

Check your understanding

4 questions

Question 1 of 4

Q4 revenue grew from 52,537 to 146,825 while average order value stayed around 230 in both years. What does that tell Rania?

Question 2 of 4

Jordan’s Q4 revenue grew 627%, the highest of any country. How should the review present it?

Question 3 of 4

Why does the capstone query aggregate order_items into order_revenue before joining it to orders and customers?

Question 4 of 4

Sixty-three Q4 2025 orders are still ‘processing’ or ‘shipped’ on 31 December. How should they affect the report?

Practice

SQL exercise

Q4 growth by channel, from a blank page

Rania’s follow-up question from the board: “Which channel drove the growth?” Write the query from scratch.

Return one row per channel with revenue_q4_2024, revenue_q4_2025 (both rounded to 2 decimals) and growth_pct (percentage change from Q4 2024 to Q4 2025, rounded to 1 decimal). Use the capstone’s definitions: Q4 is 1 October to 31 December, revenue is item revenue (quantity * unit_price * (1 - discount)), and every status except ‘cancelled’ counts. Row order doesn’t matter.

Tables
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- Q4 2025 vs Q4 2024 revenue by channel. Write it from scratch.
-- Definitions: Q4 = 1 Oct to 31 Dec (half-open ranges), revenue = item revenue,
-- every status except 'cancelled'.
-- Output: channel, revenue_q4_2024, revenue_q4_2025, growth_pct
SELECT channel
FROM orders
GROUP BY channel;Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.