EduFlow
SQL for Analysts
0%

Section 3 · Analytical SQL

Running Totals, LAG and Moving Averages

18 min3 min readSQL exercise

Lesson at a glance

Turn a monthly or daily table into a trend with running totals, year-to-date resets, LAG and LEAD comparisons, and moving averages over explicit ROWS frames, including filling missing days first.

  1. 1Running totals and year-to-date
  2. 2Comparing with the previous row
  3. 3Moving averages and the frame

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

Rania’s monthly business review needs three things on one slide: revenue by month, how much of the year’s total has accumulated so far, and whether each month grew on the last. You could compute the year-to-date figure in a spreadsheet afterwards. But the same window functions that ranked products last lesson can also accumulate and compare, so the whole table comes straight out of SQL, reproducibly.

Running totals and year-to-date

A window aggregate with an ORDER BY inside OVER becomes a running calculation: each row sees the rows up to itself. Add PARTITION BY and it restarts per group. Here are Cartwheel’s monthly orders with a year-to-date count that resets each January:

SQL
WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month, COUNT(*) AS orders
  FROM orders
  WHERE status <> 'cancelled'
  GROUP BY month
)
SELECT
  month,
  orders,
  SUM(orders) OVER (
    PARTITION BY substr(month, 1, 4)
    ORDER BY month
  ) AS orders_ytd
FROM monthly
ORDER BY month
LIMIT 14;

The partition key is the year, taken from the first four characters of the month label. December 2024 closes the year at 552 orders, and January 2025 starts again at 84.

Two windows on the same row combine nicely. Dividing the running total by the total of the whole partition, 100.0 * SUM(orders) OVER (PARTITION BY substr(month, 1, 4) ORDER BY month) / SUM(orders) OVER (PARTITION BY substr(month, 1, 4)), gives the cumulative share of the year reached by each month. For a seasonal business like Cartwheel, that’s the number finance actually tracks: by the end of October 2025, the year was just under two-thirds done.

Comparing with the previous row

LAG(x) returns the value of x from the previous row in the window order, and LEAD(x) from the next. Period-over-period change is then simple arithmetic:

SQL
WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month, COUNT(*) AS orders
  FROM orders
  WHERE status <> 'cancelled'
  GROUP BY month
)
SELECT
  month,
  orders,
  LAG(orders) OVER (ORDER BY month) AS prev_month,
  orders - LAG(orders) OVER (ORDER BY month) AS change,
  ROUND(100.0 * (orders - LAG(orders) OVER (ORDER BY month))
        / NULLIF(LAG(orders) OVER (ORDER BY month), 0), 1) AS change_pct,
  LAG(orders, 12) OVER (ORDER BY month) AS same_month_last_year
FROM monthly
ORDER BY month DESC
LIMIT 4;

November 2025 jumped by 193 orders, then December fell by 140: Black Friday, then the hangover. Month-over-month numbers are noisy for a seasonal business, which is why the last column uses LAG(orders, 12), the value twelve rows back, for a year-over-year view: 325 orders this November against 120 last November.

LAG(orders, 12) is only “the same month last year” if every month has a row. A month with no orders would have no row, and every comparison after it would silently shift by one month. That assumption is about to matter.

Moving averages and the frame

A moving average smooths daily noise. The window needs an explicit frame, the range of rows around the current one that the aggregate sees: ROWS BETWEEN 6 PRECEDING AND CURRENT ROW is the current row plus the six before it.

Ten daily rows in order. For the current row, day 9, the frame ROWS BETWEEN 6 PRECEDING AND CURRENT ROW covers days 3 through 9, seven rows. When the current row moves forward one day, the frame moves with it. d1 d2 d3 d4 d5 d6 d7 d8 d9 d10 frame: 6 PRECEDING to CURRENT ROW (7 rows) current row
A ROWS frame slides along the ordered rows

The frame counts rows, not days. If a day has no orders, it has no row, and the frame quietly stretches further back in time. Cartwheel’s first month shows how badly that can mislead: in January 2024 only 8 orders arrived, on 8 different days. A 7-row average over the existing rows says “1.0 orders a day” all month. The truth is about a quarter of that.

The fix is a calendar: generate one row per day with a recursive CTE, LEFT JOIN the daily counts, and turn missing days into zeros:

SQL
WITH RECURSIVE days(day) AS (
  SELECT '2024-01-01'
  UNION ALL
  SELECT date(day, '+1 day') FROM days WHERE day < '2024-01-31'
),
daily AS (
  SELECT date(order_date) AS day, COUNT(*) AS orders
  FROM orders
  WHERE status <> 'cancelled'
    AND order_date >= '2024-01-01' AND order_date < '2024-02-01'
  GROUP BY day
),
filled AS (
  SELECT d.day, COALESCE(x.orders, 0) AS orders
  FROM days AS d
  LEFT JOIN daily AS x ON x.day = d.day
),
smoothed AS (
  SELECT day, orders,
         ROUND(AVG(orders) OVER (
           ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
         ), 2) AS orders_7d_avg
  FROM filled
)
SELECT * FROM smoothed
WHERE day >= '2024-01-25'
ORDER BY day;

Now 31 January shows 0.43 orders a day over the last week, the honest number. Notice where the date filter sits: in the final SELECT, after the moving average is computed in smoothed. Window functions run after WHERE, so filtering in the same query would leave the first displayed days with no history to average. The same calendar trick protects LAG(orders, 12) from missing months.

You can now rank, accumulate and compare. The next lesson puts these tools to work on the two analyses every product team asks for: funnels and cohort retention.

Key takeaways

  • SUM(x) OVER (ORDER BY period) is a running total; adding PARTITION BY year makes it reset each year, which is year-to-date.
  • LAG(x) reads the previous row’s value and LEAD(x) the next one, so period-over-period change is x - LAG(x).
  • A moving average needs an explicit frame, such as ROWS BETWEEN 6 PRECEDING AND CURRENT ROW, and a row for every period, including empty ones.
  • Window functions run after WHERE, so filter the displayed range in an outer query or the window will only see the filtered rows.

Further reading

Check your understanding

4 questions

Question 1 of 4

SUM(revenue) OVER () is added to a monthly revenue table. What does every row show?

Question 2 of 4

Monthly orders are 84, 63 and 70 for January to March. What does orders - LAG(orders) OVER (ORDER BY month) return for January, February and March?

Question 3 of 4

Daily orders are missing on days with no sales. What’s wrong with AVG(orders) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) on that table?

Question 4 of 4

This query should show a 7-day moving average for the last week of December only. Why are the first values too low?

SQL
SELECT day, AVG(orders) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
FROM filled_days
WHERE day >= '2025-12-25';

Practice

SQL exercise

Rania's revenue trend

Build the slide table for Rania: one row per month of 2025 (non-cancelled orders) with month (‘YYYY-MM’), revenue, running_revenue (revenue accumulated from January up to and including that month) and mom_change (this month’s revenue minus the previous month’s; NULL for January). Round the money columns to 2 decimals and sort by month. Order matters.

The monthly CTE is correct. Both window columns in the final SELECT are wrong.

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)
-- 2025 monthly revenue: running total and change vs previous month
WITH monthly AS (
  SELECT
    strftime('%Y-%m', o.order_date) AS month,
    SUM(oi.quantity * oi.unit_price * (1 - oi.discount)) AS revenue
  FROM orders AS o
  JOIN order_items AS oi ON oi.order_id = o.order_id
  WHERE o.status <> 'cancelled'
    AND o.order_date >= '2025-01-01'
    AND o.order_date < '2026-01-01'
  GROUP BY month
)
SELECT
  month,
  ROUND(revenue, 2) AS revenue,
  ROUND(SUM(revenue) OVER (), 2) AS running_revenue,
  ROUND(revenue - LEAD(revenue) OVER (ORDER BY month), 2) AS mom_change
FROM monthly
ORDER BY month;Loading the editor…

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