«В марте выросли на 11% к февралю» — такая фраза (или подобная) живёт примерно в каждом втором отчёте, примерно в каждом втором случае она ничего не значит.
В феврале 28 дней, в марте 31, продавайте одинаково каждый день, и рост появится сам, без новых клиентов, без рекламы, вообще без причины.

Для такой ошибки не нужен ни сломанный пайплайн, ни кривой запрос, достаточно сравнить два периода, которые сравнивать нельзя.
Дальше про то, где календарь и сезонность ломают отчёт и что с этим делать. Без ARIMA, Prophet и прочих временных рядов всё на SQL и на уровне арифметики, потому что в большинстве рабочих задач этого хватает.
Три вопроса до того, как считать проценты
Прежде чем сравнивать любые два периода, ответьте себе на три вопроса.
Сколько дней в каждом периоде? 28 против 31 дают 11% разницы на ровном месте.
Сколько рабочих дней и выходных? Тут разброс бывает больше, чем по длине месяца, в одном восемь выходных, в другом десять, плюс праздники, плюс переносы, для B2B-сервиса это решает всё, для доставки еды тоже, только наоборот.
Были ли разовые события? Чёрная пятница, распродажа, большая рекламная кампания, сбой у конкурента, если в одном периоде такое было, а в другом нет, сравниваете вы уже не продукт.
Если хотя бы по одному пункту периоды разъехались, проценты «месяц к месяцу» считать бессмысленно, точнее, посчитать-то можно, но делать из них выводы уже нельзя.
Что считать вместо этого
Среднее за день
Самый простой приём, он закрывает разницу в длине месяца.
select date_trunc('month', order_date)::date as month, count(*) as orders_total, count(distinct order_date) as days, round(count(*)::numeric / count(distinct order_date), 1) as orders_per_day from orders group by 1 order by 1;
count(distinct order_date) здесь стоит вместо жёсткого числа дней не просто так. Считаются реальные дни с данными, а не календарные, и если какой-то день выпал из выгрузки, это сразу видно и теперь февраль и март сравнимы - было 1000 в день, стало 1000 в день, роста нет.
Среднее за рабочий день
Если у продукта выраженная недельная сезонность, одного деления на дни мало. Считаем отдельно по типам дней.
select date_trunc('month', order_date)::date as month, case when extract(isodow from order_date) in (6,7) then 'выходной' else 'будний' end as day_type, count(*) as orders, count(distinct order_date) as days, round(count(*)::numeric / count(distinct order_date), 1) as per_day from orders group by 1, 2 order by 1, 2;
isodow нумерует дни от понедельника (1) до воскресенья (7) независимо от настроек базы. У обычного dow неделя начинается с воскресенья, и на этом регулярно попадаются - особенно те, кто привык к другой СУБД.
Если в компании есть таблица производственного календаря с праздниками и переносами, берите её, а не isodow, тридцатое декабря формально будний день, но ведёт себя совсем не как вторник в октябре.
Скользящее среднее
Когда надо показать тренд, а данные скачут, выручает среднее за последние семь дней. Именно семь: в окно попадает ровно одна полная неделя, и недельная сезонность гасится сама собой.
select order_date, count(*) as orders, round(avg(count(*)) over ( order by order_date rows between 6 preceding and current row ), 1) as ma7 from orders group by 1 order by 1;

Один ряд, два разных впечатления. По сырым дням хочется объяснять каждый скачок, по скользящему среднему видно, что там вообще происходит.
Три вещи, о которых легко забыть, но лучше все учесть:
Первая. Окно должно быть кратно периоду сезонности: семь дней или четырнадцать, но не десять, иначе в окно будет попадать разное число выходных и вы получите новую волну, которой в данных нет.
Вторая. Первые шесть дней ряда считать не из чего, и лучше их вообще не рисовать, чем рисовать по неполному окну.
Третья, такое среднее смотрит только назад, поэтому отстаёт от реальности примерно на три дня, если тренд развернулся во вторник, на графике вы увидите это в лучшем случае к пятнице, все лечится центрированным окном, когда берут три дня до и три дня после:
round(avg(count(*)) over ( order by order_date rows between 3 preceding and 3 following ), 1) as ma7_centered
Такая линия ложится на данные без запаздывания, но у неё своя плата: последние три дня посчитать невозможно, там ещё нет будущего. Отсюда простое правило - для разбора истории берите центрированное окно, для мониторинга свежих данных обычное, помня про отставание.
Год к году и его ловушки
Классический способ убрать сезонность — сравнивать март с мартом прошлого года. Способ рабочий, но подвохов в нём больше, чем кажется.

Что тут произошло - распродажа в прошлом году шла в конце года, в этом её передвинули на начало, за год продали ровно столько же, но помесячное сравнение покажет драму: в феврале и марте рост в полтора раза, в октябре и ноябре такое же падение, ни то, ни другое не правда, просто акция переехала.
Что ещё стоит проверить:
Плавающие даты - такие как майские праздники, Чёрная пятница, каждый год они попадают в разные календарные отрезки, так что сравнение недели с неделей разъезжается само.
Разное число выходных, в марте прошлого года их было десять, в этом девять, для B2B это лишний рабочий день и 4–5% разницы сразу.
Разная длина самих месяцев между годами, февраль високосного года на день длиннее обычного, а это 3.5% разницы в помесячном сравнении, выглядит мелочью ровно до того момента, пока кто-нибудь не построит на этой мелочи вывод.
В общем, год к году лучше, чем месяц к месяцу, но индульгенции не даёт, и перед тем как произносить цифру вслух, проверьте хотя бы календарь: не сдвинулись ли праздники и не изменилось ли число рабочих дней.
Когда сезонность на самом деле не сезонность
Обратная ошибка встречается не реже, «Нуууу это же лето, у нас всегда летом просадка» - но лучше проверить так ли это.
Отличить одно от другого несложно, сравните форму провала с тем же периодом прошлого года, если прошлым летом просадка была 15%, а в этом году 30%, сезонность объясняет половину, а вторую половину надо разбирать отдельно.
Ещё полезно прикинуть, сколько сезонность вообще объясняет в вашей метрике, без всякой декомпозиции: посмотрите размах между средним по будням и средним по выходным, или между самым высоким и самым низким месяцем за пару лет, если размах 5%, а метрика упала на 20%, списывать особо нечего.
Ещё несколько мест, где спотыкаются
Неполный последний период. Сравнивать текущий месяц, который идёт шестой день, с прошлым целиком бессмысленно, но в отчётах такое попадается постоянно, либо шесть дней против шести дней прошлого месяца, либо ждём конца месяца.
Недели, которые не совпадают с месяцами. В месяце не четыре недели, а четыре с хвостиком, поэтому «недельная выручка по месяцам» будет плавать сама по себе. Если считаете по неделям, то и сравнивайте по неделям, используя date_trunc('week', ...).
Разные часовые пояса у клиентов. Если продукт работает на несколько регионов, «день» у пользователей начинается в разное время, и суточная сезонность размазывается.
Проценты без базы. Рост на 10% от ста и от десяти тысяч — очень разные новости, а в отчёте выглядят одинаково, абсолютные числа должны стоять рядом с процентами всегда.
Как формулировать вывод
Сравните две формулировки одного и того же факта:
«В марте выросли на 11% к февралю».
«В марте 31 000 заказов против 28 000 в феврале, в пересчёте на день обе цифры дают примерно 1000, то есть фактического роста нет, разница целиком объясняется длиной месяца, год к году март вырос на 6% при сопоставимом числе рабочих дней».
Вторая длиннее, зато после первой продакт идёт праздновать несуществующий рост.
Итого, чек-лист проверки:
Посчитать, сколько дней и сколько рабочих дней в каждом периоде.
Перевести метрику в «на день» или «на рабочий день».
Проверить, не попали ли в период разовые события.
Для тренда использовать скользящее среднее с окном, кратным неделе.
Сравнивать сопоставимое: год к году, неделю к неделе, шесть дней к шести дням.
Проверить, не сдвинулись ли праздники и не поменялось ли определение метрики.
Показывать абсолютные числа рядом с процентами.
Прежде чем списать просадку на сезонность, посчитать, сколько сезонность объясняет.
Обидно тут то, что математики никакой, вся сложность в календаре, но SQL учат все, а число рабочих дней в месяце почему-то считается чем-то самоочевидным, а понимание этого приходит только с опытом и допущенными ошибками.
А в комментариях расскажите, какая сезонность есть у вашего продукта и как вы её учитываете, особенно интересны нестандартные циклы: зарплатные дни, учебный год, отопительный сезон, всё то, что не сводится к «выходные и праздники».
✔️ Больше про аналитику пишу в телеграм-канале Таня и Данные.