Recipe Tableau and SQL
How do I calculate a 7-day or 28-day moving average?
In Tableau, a trailing 7-day average is WINDOW_AVG(SUM([passengers]), -6, 0): today and the six days before. In SQL it is AVG(passengers) OVER (ORDER BY travel_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), on one row per calendar day. Pick a multiple of seven: on this ferry data a 30-day average moves about as much from day to day as a 7-day one, and twice as much as a 28-day one.
The data
Harbour Line ferry, daily passengers in 2025
A commuter ferry across the harbour: busy weekdays, quiet weekends, a summer peak and a January ice storm that stopped every crossing for four days, which left no rows at all.
travel_date | passengers | rain_mm |
|---|---|---|
| 2025-01-01 | 1130 | 0 |
| 2025-01-02 | 1093 | 0 |
| 2025-01-03 | 1039 | 0 |
| 2025-01-04 | 584 | 0 |
Step 1
Plot the daily passengers
Every weekend drops to about half a weekday. The summer peak is in there, but the weekly teeth are louder than the trend.
Tableau
SUM([passengers])SQL
SELECT travel_date, SUM(passengers) AS passengers
FROM input
GROUP BY travel_date
ORDER BY travel_dateRows
| Travel date | Passengers |
|---|---|
| Jan 1, 2025 | 1,130 |
| Jan 2, 2025 | 1,093 |
| Jan 3, 2025 | 1,039 |
| Jan 4, 2025 | 584 |
| Jan 5, 2025 | 464 |
| Jan 6, 2025 | 1,086 |
| Jan 7, 2025 | 1,177 |
| Jan 8, 2025 | 1,113 |
| Jan 9, 2025 | 1,212 |
| Jan 10, 2025 | 1,149 |
| Jan 11, 2025 | 588 |
| Jan 12, 2025 | 440 |
| Jan 13, 2025 | 964 |
| Jan 14, 2025 | 1,174 |
| Jan 15, 2025 | 1,189 |
| Jan 16, 2025 | 1,058 |
| Jan 17, 2025 | 1,048 |
| Jan 18, 2025 | 581 |
| Jan 19, 2025 | 464 |
| Jan 20, 2025 | 594 |
| Jan 25, 2025 | 295 |
| Jan 26, 2025 | 274 |
| Jan 27, 2025 | 1,096 |
| Jan 28, 2025 | 1,078 |
| Jan 29, 2025 | 1,155 |
| Jan 30, 2025 | 1,266 |
| Jan 31, 2025 | 1,096 |
| Feb 1, 2025 | 583 |
| Feb 2, 2025 | 471 |
| Feb 3, 2025 | 1,161 |
| Feb 4, 2025 | 1,373 |
| Feb 5, 2025 | 1,205 |
| Feb 6, 2025 | 1,337 |
| Feb 7, 2025 | 1,114 |
| Feb 8, 2025 | 631 |
| Feb 9, 2025 | 496 |
| Feb 10, 2025 | 1,281 |
| Feb 11, 2025 | 1,273 |
| Feb 12, 2025 | 1,325 |
| Feb 13, 2025 | 1,283 |
| Feb 14, 2025 | 1,283 |
| Feb 15, 2025 | 691 |
| Feb 16, 2025 | 535 |
| Feb 17, 2025 | 1,359 |
| Feb 18, 2025 | 1,448 |
| Feb 19, 2025 | 1,419 |
| Feb 20, 2025 | 1,493 |
| Feb 21, 2025 | 883 |
| Feb 22, 2025 | 787 |
| Feb 23, 2025 | 620 |
First 50 of 361 rows.
Step 2
Average the last few rows
Each point is the average of the current row and the ones before it. In Tableau, put travel_date on Columns as an exact date and compute along it (Compute Using: Table (across) or travel_date). This version counts rows, not days, which matters in January.
Tableau
WINDOW_AVG(SUM([passengers]), -(7 - 1), 0)SQL
WITH d AS (
SELECT travel_date, SUM(passengers) AS passengers
FROM input
GROUP BY travel_date
)
SELECT
travel_date,
passengers,
AVG(passengers) OVER (ORDER BY travel_date ROWS BETWEEN 7 - 1 PRECEDING AND CURRENT ROW) AS avg_passengers
FROM d
ORDER BY travel_dateRows
| Travel date | Passengers | Avg passengers |
|---|---|---|
| Jan 1, 2025 | 1,130 | 1,130 |
| Jan 2, 2025 | 1,093 | 1,112 |
| Jan 3, 2025 | 1,039 | 1,087 |
| Jan 4, 2025 | 584 | 962 |
| Jan 5, 2025 | 464 | 862 |
| Jan 6, 2025 | 1,086 | 899 |
| Jan 7, 2025 | 1,177 | 939 |
| Jan 8, 2025 | 1,113 | 937 |
| Jan 9, 2025 | 1,212 | 954 |
| Jan 10, 2025 | 1,149 | 969 |
| Jan 11, 2025 | 588 | 97 |
| Jan 12, 2025 | 440 | 966 |
| Jan 13, 2025 | 964 | 949 |
| Jan 14, 2025 | 1,174 | 949 |
| Jan 15, 2025 | 1,189 | 959 |
| Jan 16, 2025 | 1,058 | 937 |
| Jan 17, 2025 | 1,048 | 923 |
| Jan 18, 2025 | 581 | 922 |
| Jan 19, 2025 | 464 | 925 |
| Jan 20, 2025 | 594 | 873 |
| Jan 25, 2025 | 295 | 747 |
| Jan 26, 2025 | 274 | 616 |
| Jan 27, 2025 | 1,096 | 622 |
| Jan 28, 2025 | 1,078 | 626 |
| Jan 29, 2025 | 1,155 | 708 |
| Jan 30, 2025 | 1,266 | 823 |
| Jan 31, 2025 | 1,096 | 894 |
| Feb 1, 2025 | 583 | 935 |
| Feb 2, 2025 | 471 | 964 |
| Feb 3, 2025 | 1,161 | 973 |
| Feb 4, 2025 | 1,373 | 1,015 |
| Feb 5, 2025 | 1,205 | 1,022 |
| Feb 6, 2025 | 1,337 | 1,032 |
| Feb 7, 2025 | 1,114 | 1,035 |
| Feb 8, 2025 | 631 | 1,042 |
| Feb 9, 2025 | 496 | 1,045 |
| Feb 10, 2025 | 1,281 | 1,062 |
| Feb 11, 2025 | 1,273 | 1,048 |
| Feb 12, 2025 | 1,325 | 1,065 |
| Feb 13, 2025 | 1,283 | 1,058 |
| Feb 14, 2025 | 1,283 | 1,082 |
| Feb 15, 2025 | 691 | 1,09 |
| Feb 16, 2025 | 535 | 1,096 |
| Feb 17, 2025 | 1,359 | 1,107 |
| Feb 18, 2025 | 1,448 | 1,132 |
| Feb 19, 2025 | 1,419 | 1,145 |
| Feb 20, 2025 | 1,493 | 1,175 |
| Feb 21, 2025 | 883 | 1,118 |
| Feb 22, 2025 | 787 | 1,132 |
| Feb 23, 2025 | 620 | 1,144 |
First 50 of 361 rows.
Why does the January line look wrong?
Because the average counts rows, not days. The ice storm stopped the ferry for four days and left no rows, so right after it the window reaches back past the storm to busy days in mid-January. Give every day a row, with zero where zero is true, and the dip shows up where it happened.
Common mistake
Averaging rows when days are missing
The ferry stopped from 21 to 24 January and those days have no rows. Counted by rows, the 7-day average on Monday 27 January reaches back to the 17th and says 622 passengers a day. Over the real last seven days it was 238. Fill in a row for every day first.
Tableau
WINDOW_AVG(SUM([passengers]), -6, 0)
// missing dates hiddenSQL
SELECT travel_date, avg_passengers
FROM (
SELECT
travel_date,
ROUND(AVG(passengers) OVER (ORDER BY travel_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)) AS avg_passengers
FROM input
)
WHERE travel_date = DATE '2025-01-27'Tableau
WINDOW_AVG(ZN(SUM([passengers])), -6, 0)
// travel_date pill: Show Missing ValuesSQL
WITH days AS (
SELECT CAST (RANGE AS DATE) AS travel_date
FROM RANGE (DATE '2025-01-01', DATE '2026-01-01', INTERVAL 1 DAY)
), d AS (
SELECT days.travel_date, COALESCE(i.passengers, 0) AS passengers
FROM days LEFT JOIN input i ON i.travel_date = days.travel_date
)
SELECT travel_date, avg_passengers
FROM (
SELECT
travel_date,
ROUND(AVG(passengers) OVER (ORDER BY travel_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)) AS avg_passengers
FROM d
)
WHERE travel_date = DATE '2025-01-27'Step 3
Give every calendar day a row
The storm days now exist, with zero passengers, so the window covers real calendar days. The average also stays blank until a full window exists, instead of averaging fewer days at the start. Use zero only when zero is the truth; if the days are missing because data wasn't recorded, leave them empty.
Tableau
IF INDEX() >= 7 THEN WINDOW_AVG(ZN(SUM([passengers])), -(7 - 1), 0)
END
// travel_date pill: Show Missing ValuesSQL
WITH days AS (
SELECT CAST (RANGE AS DATE) AS travel_date
FROM RANGE (DATE '2025-01-01', DATE '2026-01-01', INTERVAL 1 DAY)
), d AS (
SELECT
days.travel_date,
COALESCE(SUM(i.passengers), 0) AS passengers
FROM days LEFT JOIN input i ON i.travel_date = days.travel_date
GROUP BY days.travel_date
)
SELECT
travel_date,
passengers,
CASE
WHEN ROW_NUMBER() OVER (ORDER BY travel_date) >= 7 THEN AVG(passengers) OVER (ORDER BY travel_date ROWS BETWEEN 7 - 1 PRECEDING AND CURRENT ROW)
END AS avg_passengers
FROM d
ORDER BY travel_dateRows
| Travel date | Passengers | Avg passengers |
|---|---|---|
| Jan 1, 2025 | 1,130 | – |
| Jan 2, 2025 | 1,093 | – |
| Jan 3, 2025 | 1,039 | – |
| Jan 4, 2025 | 584 | – |
| Jan 5, 2025 | 464 | – |
| Jan 6, 2025 | 1,086 | – |
| Jan 7, 2025 | 1,177 | 939 |
| Jan 8, 2025 | 1,113 | 937 |
| Jan 9, 2025 | 1,212 | 954 |
| Jan 10, 2025 | 1,149 | 969 |
| Jan 11, 2025 | 588 | 97 |
| Jan 12, 2025 | 440 | 966 |
| Jan 13, 2025 | 964 | 949 |
| Jan 14, 2025 | 1,174 | 949 |
| Jan 15, 2025 | 1,189 | 959 |
| Jan 16, 2025 | 1,058 | 937 |
| Jan 17, 2025 | 1,048 | 923 |
| Jan 18, 2025 | 581 | 922 |
| Jan 19, 2025 | 464 | 925 |
| Jan 20, 2025 | 594 | 873 |
| Jan 21, 2025 | 0 | 705 |
| Jan 22, 2025 | 0 | 535 |
| Jan 23, 2025 | 0 | 384 |
| Jan 24, 2025 | 0 | 234 |
| Jan 25, 2025 | 295 | 193 |
| Jan 26, 2025 | 274 | 166 |
| Jan 27, 2025 | 1,096 | 238 |
| Jan 28, 2025 | 1,078 | 392 |
| Jan 29, 2025 | 1,155 | 557 |
| Jan 30, 2025 | 1,266 | 738 |
| Jan 31, 2025 | 1,096 | 894 |
| Feb 1, 2025 | 583 | 935 |
| Feb 2, 2025 | 471 | 964 |
| Feb 3, 2025 | 1,161 | 973 |
| Feb 4, 2025 | 1,373 | 1,015 |
| Feb 5, 2025 | 1,205 | 1,022 |
| Feb 6, 2025 | 1,337 | 1,032 |
| Feb 7, 2025 | 1,114 | 1,035 |
| Feb 8, 2025 | 631 | 1,042 |
| Feb 9, 2025 | 496 | 1,045 |
| Feb 10, 2025 | 1,281 | 1,062 |
| Feb 11, 2025 | 1,273 | 1,048 |
| Feb 12, 2025 | 1,325 | 1,065 |
| Feb 13, 2025 | 1,283 | 1,058 |
| Feb 14, 2025 | 1,283 | 1,082 |
| Feb 15, 2025 | 691 | 1,09 |
| Feb 16, 2025 | 535 | 1,096 |
| Feb 17, 2025 | 1,359 | 1,107 |
| Feb 18, 2025 | 1,448 | 1,132 |
| Feb 19, 2025 | 1,419 | 1,145 |
First 50 of 365 rows.
Why 7 and 28, not 30?
Because commuting follows the week. Any window that holds whole weeks has each weekday the same number of times, so as it slides, a Monday drops out and a Monday comes in. Any other size swaps a weekday for a weekend day and back, and the line wobbles for no reason.
Step 4
Which window sizes jitter?
For each window size, how far the average moves from one day to the next, on average over the year. Tableau shows one window size at a time; the SQL tries every size from 3 to 35. Bigger windows should move less. They do, except for a sawtooth: every multiple of seven (7, 14, 21, 28, 35) drops to a low, and the sizes just after it jump back up.
Tableau
WINDOW_AVG(ABS([Moving average] - LOOKUP([Moving average], -1)))
// [Moving average] is the previous step saved as a fieldSQL
WITH days AS (
SELECT CAST (RANGE AS DATE) AS travel_date
FROM RANGE (DATE '2025-01-01', DATE '2026-01-01', INTERVAL 1 DAY)
), d AS (
SELECT
days.travel_date,
COALESCE(SUM(i.passengers), 0) AS passengers
FROM days LEFT JOIN input i ON i.travel_date = days.travel_date
GROUP BY days.travel_date
), w AS (
SELECT RANGE AS window_days
FROM RANGE (3, 36)
), a AS (
SELECT
w.window_days,
d.travel_date,
AVG(d2.passengers) AS avg_passengers
FROM w CROSS JOIN d JOIN d d2 ON d2.travel_date BETWEEN d.travel_date - (w.window_days - 1) * INTERVAL 1 DAY AND d.travel_date
WHERE d.travel_date >= DATE '2025-01-01' + (w.window_days - 1) * INTERVAL 1 DAY
GROUP BY w.window_days, d.travel_date
), c AS (
SELECT
window_days,
ABS(avg_passengers - LAG(avg_passengers) OVER (PARTITION BY window_days ORDER BY travel_date)) AS change
FROM a
)
SELECT window_days, AVG(change) AS daily_wobble
FROM c
GROUP BY window_days
ORDER BY window_daysRows
| Window days | Change |
|---|---|
| 3 | 327 |
| 4 | 246 |
| 5 | 188 |
| 6 | 101 |
| 7 | 38.84 |
| 8 | 76.86 |
| 9 | 107 |
| 10 | 101 |
| 11 | 92.96 |
| 12 | 81.49 |
| 13 | 49.76 |
| 14 | 24.46 |
| 15 | 44.95 |
| 16 | 63.86 |
| 17 | 62.26 |
| 18 | 59.4 |
| 19 | 54.33 |
| 20 | 35.17 |
| 21 | 20.2 |
| 22 | 33.35 |
| 23 | 46 |
| 24 | 45.85 |
| 25 | 44.47 |
| 26 | 41.41 |
| 27 | 28.5 |
| 28 | 17.71 |
| 29 | 27.5 |
| 30 | 37.13 |
| 31 | 36.89 |
| 32 | 36.55 |
| 33 | 34.41 |
| 34 | 24.82 |
| 35 | 17.01 |