Seasonality in Metrics: Day-of-Week and Holiday Effects

⏱️ 3 min read 📈 Data Analysis

Seasonality is a repeating, predictable pattern in a metric tied to the calendar, most commonly a day-of-week cycle (weekdays vs. weekends) or a holiday effect. Ignoring it means a Tuesday-to-Sunday drop reads as a crisis and a partial-week A/B test result reads as a real effect, when both are just the calendar doing what it always does.

Quick answer: Build a day-of-week index by dividing each weekday's average value by the overall average and multiplying by 100; a B2B SaaS tool typically shows Monday–Thursday running 15–30% above average and weekends 40–50% below. Compare metrics WoW for short-term tracking and YoY when holidays or long-term trend could distort the picture, and always run A/B tests in full-week increments (7, 14, 21 days) so every variant gets equal exposure to every day of the week.

What is seasonality in a metric?

It's any regularly repeating fluctuation driven by the calendar rather than by an underlying trend or a one-off event. Day-of-week seasonality repeats every 7 days (traffic dips on weekends for B2B products, spikes for consumer shopping products); annual seasonality repeats every 12 months (holiday shopping, back-to-school, fiscal year-end); and both can be present in the same metric at once.

How do I measure the day-of-week effect?

Average each weekday's value across several weeks, then divide by the overall average and multiply by 100 to get an index where 100 means "typical" for that metric. Here's four weeks of daily sessions for a B2B analytics product:

DayAvg. sessions (4-wk)Index
Monday1,195121.1
Tuesday1,255127.2
Wednesday1,220123.6
Thursday1,160117.6
Friday988100.1
Saturday52553.2
Sunday56557.3

The seven index values sum to 700 (7 days × an average of 100) as a sanity check on the math. This particular product runs at roughly 2.4x the traffic on a Tuesday (127.2) as on a Saturday (53.2) — treating that gap as a "trend" instead of a known weekly cycle would be a mistake.

import pandas as pd

day_avg = df.groupby('day_of_week')['sessions'].mean()
overall_avg = df['sessions'].mean()
index = (day_avg / overall_avg * 100).round(1)
print(index)

Should I compare week-over-week or year-over-year?

Use WoW for short-term operational monitoring, since it's timely and reacts fast to real changes, but be aware it's sensitive to holidays and one-off events that shift a single week's mix of business days. Use YoY when the metric has meaningful annual seasonality (retail, travel, anything with a holiday shopping season) or when you need to compare against the same calendar position, since it controls for both day-of-week composition and annual effects at once — at the cost of a slower reaction time to genuine recent changes.

Continuing the example above, week 3 totaled 6,800 sessions and week 4 totaled 7,000, a legitimate full-week WoW change of +2.94%. But comparing only the first three days of each week (Mon–Wed) gives 3,640 vs. 3,710, a +1.92% change — a different number from a partial slice of the same weeks, because Monday, Tuesday, and Wednesday aren't a representative sample of the full week's mix.

Why does test duration need to cover full weeks?

Because day-of-week composition is itself a confound if the two halves of your comparison don't see the same mix of weekdays and weekends. In an A/B test, both variants run simultaneously so this mostly isn't an issue between arms — but it matters enormously for your own before/after or trend read on the results. Stopping a test after 5 days means your final numbers are weighted toward whichever days you happened to include, and a test that started on a Wednesday and stopped the following Monday gives Wednesday–Friday extra representation versus the weekend.

Combined with sample size requirements from a proper power calculation, the practical rule is: round your planned duration up to the nearest full week (7, 14, 21 days), never stop mid-week, and treat any interim look at partial-week data as directional only.

How do I remove seasonality from a metric (detrending)?

The simplest method is dividing by the seasonal index you already computed: adjusted_value = raw_value / (index / 100). Monday of week 4 had 1,240 raw sessions, and the Monday index is 121.1, so the seasonally adjusted value is 1,240 / 1.211 ≈ 1,024 — directly comparable to any other day's adjusted figure without the day-of-week effect baked in.

adjusted = raw_value / (day_index / 100)
# 1240 / (121.1 / 100) = 1023.9

Two other common approaches: a 7-day trailing moving average, which smooths day-of-week noise by construction since every window contains one of each weekday, and comparing the same period YoY, which cancels weekly and annual seasonality together. For a more rigorous decomposition into trend, seasonal, and residual components, see time series analysis.

Common mistakes with seasonality

Pro Tip: Build the day-of-week index once per metric and keep it on hand as a reference table. Any time a daily number looks alarming, divide it by that day's index before deciding whether to investigate — most "sudden drops" reported on a Saturday morning are just Saturday.

← Back to Data Analysis Tips