Recipe Tableau and SQL

How do I measure seasonality when the business is growing?

Divide each month by a centered 12-month moving average, then average those ratios for each calendar month. Dividing by the year's average month instead lets growth leak in: in this bike-share, that simple index calls October 1.03 of a typical month, above average, when with growth removed it is 0.94.

Updated

The data

Riverside Bikes, daily rides 2022 to 2025

Synthetic datarides.csv

A city bike-share that keeps adding stations: ridership grows about 3% a month, peaks in July, and had a free-ride week in September 2023.

ride_daterides
2022-01-01484
2022-01-02542
2022-01-03419
2022-01-04455

Step 1

Total rides per month

Two things at once: a summer hump every year, and a line that keeps climbing. Each year has roughly half again the rides of the year before. A seasonality index has to separate the two.

Tableau

SUM([rides])
// MONTH([ride_date]) as a continuous month on Columns

SQL

SELECT
  CAST (date_trunc('month', ride_date) AS DATE) AS month,
  SUM(rides) AS rides
FROM input
GROUP BY 1
ORDER BY 1

Rows

MonthRides
2022-01-0114,665
2022-02-0115,131
2022-03-0123,265
2022-04-0127,316
2022-05-0135,859
2022-06-0138,498
2022-07-0141,062
2022-08-0141,458
2022-09-0134,184
2022-10-0131,334
2022-11-0126,384
2022-12-0128,352
2023-01-0122,622
2023-02-0122,155
2023-03-0132,690
2023-04-0139,968
2023-05-0150,508
2023-06-0156,395
2023-07-0161,251
2023-08-0159,387
2023-09-0175,443
2023-10-0145,236
2023-11-0139,160
2023-12-0143,544
2024-01-0132,186
2024-02-0133,925
2024-03-0147,315
2024-04-0159,286
2024-05-0175,204
2024-06-0180,258
2024-07-0190,506
2024-08-0190,688
2024-09-0172,679
2024-10-0165,442
2024-11-0157,238
2024-12-0164,256
2025-01-0148,211
2025-02-0147,998
2025-03-0170,769
2025-04-0184,720
2025-05-01107,383
2025-06-01119,756
2025-07-01130,013
2025-08-01127,277
2025-09-01101,679
2025-10-0197,667
2025-11-0181,533
2025-12-0188,510

Step 2

The simple index: each month against its year

Each month divided by the average month of its own year, then averaged over the four years. It's the common recipe, and it's quick. But inside each year the business grew, so January is compared with a year average that is mostly later, bigger months, and late months look stronger than they are.

Tableau

AVG({ INCLUDE YEAR([ride_date]) : SUM([rides]) / (MIN({ FIXED YEAR([ride_date]) : SUM([rides]) }) / 12) })
// MONTH([ride_date]) on Columns

SQL

WITH m AS (
  SELECT
    CAST (date_trunc('month', ride_date) AS DATE) AS month,
    SUM(rides) AS rides
  FROM input
  GROUP BY 1
), r AS (
  SELECT
    month,
    rides
      / (SUM(rides) OVER (PARTITION BY year(month)) / 12.0) AS ratio
  FROM m
)
SELECT
  month(month) AS month_of_year,
  strftime(MIN(month), '%b') AS month_name,
  AVG(ratio) AS naive_index
FROM r
GROUP BY 1
ORDER BY 1

Rows

Month of yearMonth nameRatio
1Jan0.5
2Feb0.51
3Mar0.75
4Apr0.91
5May1.16
6Jun1.27
7Jul1.39
8Aug1.37
9Sep1.26
10Oct1.03
11Nov0.88
12Dec0.97

Is October a busy month or a quiet one?

It depends on how you ask, and the common recipe gets it wrong. Compared with the average month of its own year, October runs at 1.03: a little above a typical month. Take growth out and October is 0.94, below average. January moves the other way, from 0.5 to 0.61. The ridership didn't change; the yardstick did. A year's average month is pulled up by the bigger months late in that year, so in a growing business every early month looks weak and every late month looks strong.

Common mistake

Comparing months with the year they sit in

In a growing business, the year's average month is pulled toward the later, bigger months. Early months then look too weak and late months too strong. Divide by a trend that moves with the month instead.

WrongStarts with 2022-01-01, 2022-02-01, 2022-03-01…
Point at the line to read it.
00.511.5Months in this order →

Tableau

SUM([rides]) / (MIN({ FIXED YEAR([ride_date]) : SUM([rides]) }) / 12)

SQL

WITH m AS (
  SELECT
    CAST (date_trunc('month', ride_date) AS DATE) AS month,
    SUM(rides) AS rides
  FROM input
  GROUP BY 1
)
SELECT
  month,
  rides
    / (SUM(rides) OVER (PARTITION BY year(month)) / 12.0) AS ratio
FROM m
RightStarts with 2022-01-01, 2022-02-01, 2022-03-01…
Point at the line to read it.
00.511.5Months in this order →

Tableau

SUM([rides]) / ((WINDOW_AVG(SUM([rides]), -6, 5) + WINDOW_AVG(SUM([rides]), -5, 6)) / 2)

SQL

WITH m AS (
  SELECT
    CAST (date_trunc('month', ride_date) AS DATE) AS month,
    SUM(rides) AS rides
  FROM input
  GROUP BY 1
)
SELECT
  month,
  rides
    / ((AVG(rides) OVER (ORDER BY month ROWS BETWEEN 6 PRECEDING AND 5 FOLLOWING) + AVG(rides) OVER (ORDER BY month ROWS BETWEEN 5 PRECEDING AND 6 FOLLOWING)) / 2) AS ratio
FROM m

Step 3

Draw the trend with a centered 12-month average

Twelve months have no middle month, so the classical trend averages two 12-month windows, one shifted a month, which gives the months at each end half weight. Every window holds each calendar month exactly once, so the season cancels and only the trend is left. The first and last six months have no full window and stay blank.

Tableau

IF INDEX() > 6 AND INDEX() <= SIZE() - 6 THEN (WINDOW_AVG(SUM([rides]), -6, 5) + WINDOW_AVG(SUM([rides]), -5, 6)) / 2
END
// Compute Using: ride_date (month)

SQL

WITH m AS (
  SELECT
    CAST (date_trunc('month', ride_date) AS DATE) AS month,
    SUM(rides) AS rides
  FROM input
  GROUP BY 1
)
SELECT
  month,
  rides,
  CASE
    WHEN ROW_NUMBER() OVER (ORDER BY month) > 6 AND ROW_NUMBER() OVER (ORDER BY month) <= COUNT(*) OVER () - 6 THEN (AVG(rides) OVER (ORDER BY month ROWS BETWEEN 6 PRECEDING AND 5 FOLLOWING) + AVG(rides) OVER (ORDER BY month ROWS BETWEEN 5 PRECEDING AND 6 FOLLOWING)) / 2
    END AS trend
FROM m
ORDER BY month

Rows

MonthRidesTrend
2022-01-0114,665–
2022-02-0115,131–
2022-03-0123,265–
2022-04-0127,316–
2022-05-0135,859–
2022-06-0138,498–
2022-07-0141,06230,124
2022-08-0141,45830,748
2022-09-0134,18431,433
2022-10-0131,33432,353
2022-11-0126,38433,491
2022-12-0128,35234,847
2023-01-0122,62236,434
2023-02-0122,15538,022
2023-03-0132,69040,488
2023-04-0139,96842,787
2023-05-0150,50843,898
2023-06-0156,39545,064
2023-07-0161,25146,095
2023-08-0159,38746,984
2023-09-0175,44348,084
2023-10-0145,23649,498
2023-11-0139,16051,332
2023-12-0143,54453,355
2024-01-0132,18655,569
2024-02-0133,92558,092
2024-03-0147,31559,281
2024-04-0159,28660,008
2024-05-0175,20461,603
2024-06-0180,25863,219
2024-07-0190,50664,75
2024-08-0190,68866,004
2024-09-0172,67967,567
2024-10-0165,44269,604
2024-11-0157,23872,005
2024-12-0164,25674,991
2025-01-0148,21178,283
2025-02-0147,99881,454
2025-03-0170,76984,187
2025-04-0184,72086,738
2025-05-01107,38389,093
2025-06-01119,75691,116
2025-07-01130,013–
2025-08-01127,277–
2025-09-01101,679–
2025-10-0197,667–
2025-11-0181,533–
2025-12-0188,510–

Step 4

Divide each month by its trend

Now growth is gone: the same wave repeats at the same height every year. One spike sticks out, September 2023, the free-ride week.

Tableau

SUM([rides]) / [Trend]

SQL

WITH m AS (
  SELECT
    CAST (date_trunc('month', ride_date) AS DATE) AS month,
    SUM(rides) AS rides
  FROM input
  GROUP BY 1
), t AS (
  SELECT
    month,
    rides,
    ROW_NUMBER() OVER (ORDER BY month) AS k,
    COUNT(*) OVER () AS n,
    (AVG(rides) OVER (ORDER BY month ROWS BETWEEN 6 PRECEDING AND 5 FOLLOWING) + AVG(rides) OVER (ORDER BY month ROWS BETWEEN 5 PRECEDING AND 6 FOLLOWING))
      / 2 AS trend
  FROM m
)
SELECT month, rides / trend AS ratio_to_trend
FROM t
WHERE k > 6 AND k <= n - 6
ORDER BY month

Rows

MonthRatio to trend
2022-07-011.36
2022-08-011.35
2022-09-011.09
2022-10-010.97
2022-11-010.79
2022-12-010.81
2023-01-010.62
2023-02-010.58
2023-03-010.81
2023-04-010.93
2023-05-011.15
2023-06-011.25
2023-07-011.33
2023-08-011.26
2023-09-011.57
2023-10-010.91
2023-11-010.76
2023-12-010.82
2024-01-010.58
2024-02-010.58
2024-03-010.8
2024-04-010.99
2024-05-011.22
2024-06-011.27
2024-07-011.4
2024-08-011.37
2024-09-011.08
2024-10-010.94
2024-11-010.79
2024-12-010.86
2025-01-010.62
2025-02-010.59
2025-03-010.84
2025-04-010.98
2025-05-011.21
2025-06-011.31

Step 5

Average the ratios per calendar month

Side by side, the simple index tilts the whole curve toward the end of the year: winter looks deeper and autumn looks busier than they are. In Tableau this is a nested table calculation; set each level's Compute Using in Edit Table Calculation.

Tableau

WINDOW_AVG([Ratio to trend])
// YEAR and MONTH of ride_date on the view. Ratio to trend: Compute Using Year, Month. This calc: Compute Using Year, restarting every Month

SQL

WITH m AS (
  SELECT
    CAST (date_trunc('month', ride_date) AS DATE) AS month,
    SUM(rides) AS rides
  FROM input
  GROUP BY 1
), t AS (
  SELECT
    month,
    rides,
    ROW_NUMBER() OVER (ORDER BY month) AS k,
    COUNT(*) OVER () AS n,
    (AVG(rides) OVER (ORDER BY month ROWS BETWEEN 6 PRECEDING AND 5 FOLLOWING) + AVG(rides) OVER (ORDER BY month ROWS BETWEEN 5 PRECEDING AND 6 FOLLOWING))
      / 2 AS trend,
    rides
      / (SUM(rides) OVER (PARTITION BY year(month)) / 12.0) AS year_ratio
  FROM m
)
SELECT
  month(month) AS month_of_year,
  strftime(MIN(month), '%b') AS month_name,
  'Simple' AS method,
  AVG(year_ratio) AS seasonality_index
FROM t
GROUP BY 1
UNION ALL 
SELECT
  month(month),
  strftime(MIN(month), '%b'),
  'Growth removed',
  AVG (rides / trend)
FROM t
WHERE k > 6 AND k <= n - 6
GROUP BY 1
ORDER BY 1, 3 DESC

Rows

Month of yearMonth nameMethodYear ratio
1JanSimple0.5
1JanGrowth removed0.61
2FebSimple0.51
2FebGrowth removed0.59
3MarSimple0.75
3MarGrowth removed0.82
4AprSimple0.91
4AprGrowth removed0.97
5MaySimple1.16
5MayGrowth removed1.19
6JunSimple1.27
6JunGrowth removed1.28
7JulSimple1.39
7JulGrowth removed1.36
8AugSimple1.37
8AugGrowth removed1.33
9SepSimple1.26
9SepGrowth removed1.24
10OctSimple1.03
10OctGrowth removed0.94
11NovSimple0.88
11NovGrowth removed0.78
12DecSimple0.97
12DecGrowth removed0.83

What does one bad month do to the index?

In September 2023 the city ran a free-ride week, and rides that month jumped far above trend. With three years of ratios, averaging carries that one week into every future September: the index says 1.24. Switch to the median and September drops to 1.09, in line with the months around it. Use the median when you know a one-off event sits in the history.

Average the ratios with
Point at the line to read it.
1Jul1.392Aug1.373Jul1.364Aug1.335Jun1.286Jun1.277Sep1.268Sep1.249May1.1910May1.16
Growth removedSimple

Common mistake

Using a trailing 12-month average as the trend

A trailing average only looks back, so on a growing series it sits below where the trend is today. Every month then looks above trend: here the trailing ratios average 1.17 instead of about 1. Center the window on the month.

WrongStarts with 2022-01-01, 2022-02-01, 2022-03-01…
Point at the line to read it.
00.511.5Months in this order →

Tableau

SUM([rides]) / WINDOW_AVG(SUM([rides]), -11, 0)

SQL

WITH m AS (
  SELECT
    CAST (date_trunc('month', ride_date) AS DATE) AS month,
    SUM(rides) AS rides
  FROM input
  GROUP BY 1
)
SELECT
  month,
  rides
    / AVG(rides) OVER (ORDER BY month ROWS BETWEEN 11 PRECEDING AND CURRENT ROW) AS ratio
FROM m
RightStarts with 2022-01-01, 2022-02-01, 2022-03-01…
Point at the line to read it.
00.511.5Months in this order →

Tableau

SUM([rides]) / ((WINDOW_AVG(SUM([rides]), -6, 5) + WINDOW_AVG(SUM([rides]), -5, 6)) / 2)

SQL

WITH m AS (
  SELECT
    CAST (date_trunc('month', ride_date) AS DATE) AS month,
    SUM(rides) AS rides
  FROM input
  GROUP BY 1
)
SELECT
  month,
  rides
    / ((AVG(rides) OVER (ORDER BY month ROWS BETWEEN 6 PRECEDING AND 5 FOLLOWING) + AVG(rides) OVER (ORDER BY month ROWS BETWEEN 5 PRECEDING AND 6 FOLLOWING)) / 2) AS ratio
FROM m

Run it on your own data

Any table with a date and a measure works: sales, sign-ups, tickets, visits. Drop your CSV and pick the two columns; the steps above recompute in your browser, and nothing is uploaded. Use whole years, at least three of them.

Drop a CSV anywhere on this page

Up to 10,000 rows. The sample, rides.csv, shows the shape.

Questions

How much history do I need?

Three complete years at least. The centered average loses six months at each end, so three years give three ratios per calendar month, which is the minimum for a median to mean anything. Two years work for a rough simple index.

What do I do with the index once I have it?

Divide a month's actual by its index to get a seasonally adjusted figure: a January of 50,000 rides with an index of 0.6 runs at about 83,000 a month. Multiply a flat trend forecast by each month's index to get a seasonal forecast.

Why do my twelve indices not average exactly 1?

The trend ratios come from different years for different months, so their average drifts a little. Divide each index by the mean of the twelve if you need them to average exactly 1.

Can I do day-of-week seasonality the same way?

Yes. Use daily totals, a centered 7-day average as the trend, and group the ratios by weekday.

Sources