Все материалы
Продуктовая аналитикагайдстарт

DATE_TRUNC в SQL: группировка по неделям, месяцам и кварталам

Как работает date_trunc в PostgreSQL: выручка по месяцам, недели с понедельника и с воскресенья, неполный период, часовой пояс и аналоги в MySQL, ClickHouse и SQL Server.

КейсПрактика31 октября 2026 г.8 мин

Почти любой отчёт аналитика начинается со слов «по месяцам» или «по неделям»: выручка по месяцам, недельная аудитория, заказы по кварталам. В PostgreSQL для этого есть одна функция — date_trunc. Она отрезает от даты всё мельче нужной единицы и превращает 17 августа в 1 августа, а 14:37 — в 14:00. Ниже — синтаксис, рабочие запросы на учебной базе, недели с понедельника и с воскресенья, неполный последний период, часовой пояс и то, чем заменить date_trunc в MySQL, ClickHouse и SQL Server.

Коротко

  • date_trunc('month', paid_at) возвращает начало месяца: 2026-08-17 → 2026-08-01. По этому значению и группируют.
  • Единица пишется строкой в кавычках: day, week, month, quarter, year, а для времени — hour и minute.
  • Неделя в date_trunc начинается с понедельника. Для недели с воскресенья сдвиньте дату на день и обратно.
  • В PostgreSQL date_trunc от даты возвращает timestamp с часовым поясом. Допишите ::date, чтобы в отчёте была дата без времени.
  • Последний месяц в данных почти всегда неполный. Перед сравнением периодов проверьте, сколько в нём дней.

Синтаксис date_trunc

Функция принимает два аргумента: единицу, до которой округлять, и значение — дату, timestamp или интервал. Округление всегда идёт вниз, к началу периода: 31 августа 23:59 при month превращается в 1 августа 00:00, а не в 1 сентября.

Все значения внутри одного периода получают одинаковый результат. Поэтому group by date_trunc('month', paid_at) складывает в одну строку все платежи месяца, а сам результат удобно показывать как подпись периода.

Что возвращает date_trunc для 2026-08-17 14:37:12
ЕдиницаРезультатДля чего
minute2026-08-17 14:37:00нагрузка по минутам, мониторинг
hour2026-08-17 14:00:00активность по часам
day2026-08-17 00:00:00DAU, заказы по дням
week2026-08-17 00:00:00WAU, недельные отчёты; 17 августа — понедельник
month2026-08-01 00:00:00MAU, выручка по месяцам
quarter2026-07-01 00:00:00квартальная отчётность
year2026-01-01 00:00:00год к году

Выручка по месяцам

Самая частая задача. В учебной базе таблица payments хранит дату платежа paid_at и сумму amount. Группируем по началу месяца и считаем число платежей и выручку.

Обратите внимание на ::date. Без него PostgreSQL вернёт 2026-06-01 00:00:00+03: если передать в date_trunc дату, PostgreSQL сначала превращает её в timestamp с часовым поясом. В отчёте и на графике такая подпись лишняя, а при выгрузке в BI часовой пояс иногда сдвигает дату на предыдущий день.

Результат на учебной базе
monthpaymentsrevenue
2026-06-011673 983.00
2026-07-0144210 868.00
2026-08-0164215 788.00
Число платежей и выручка по месяцам
select
  date_trunc('month', paid_at)::date as month,
  count(*)                           as payments,
  sum(amount)                        as revenue
from payments
group by 1
order by 1;

Почему не EXTRACT(month)

Частая ошибка новичка — группировать по номеру месяца через extract(month from paid_at). Пока данные лежат в пределах одного года, разницы не видно. Как только в таблице появляется прошлый август, он складывается с нынешним: у обоих номер 8.

date_trunc сохраняет год, поэтому август 2025-го и август 2026-го остаются разными строками. extract нужен для другого вопроса — «в какие месяцы года продажи выше» или «какой день недели самый активный», где годы как раз нужно сложить.

date_trunc, extract и to_char: что выбрать
ФункцияВозвращаетКогда нужна
date_trunc('month', d)дату начала месяцагруппировка по периодам, временной ряд
extract(month from d)число 1–12сезонность: сравнить месяцы разных лет между собой
to_char(d, 'YYYY-MM')строку «2026-08»подпись в выгрузке; на графике это не ось времени. В DuckDB вместо to_char — strftime
Номер месяца у двух разных августов одинаковый, начало месяца — разное
select
  d                              as paid_at,
  extract(month from d)          as month_number,
  date_trunc('month', d)::date   as month
from (values (date '2025-08-14'), (date '2026-08-03')) as t(d)
order by 1;

Группировка по неделям

Неделя в date_trunc — ISO-неделя, она начинается с понедельника. Результат — дата этого понедельника. Неделя не привязана к месяцу: неделя с 27 июля по 2 августа целиком попадёт в строку «2026-07-27», хотя четыре её дня — августовские.

Запрос ниже считает недельную аудиторию — уникальных пользователей с хотя бы одним событием за неделю. Столбцы first_day и last_day показывают, какие дни попали в неделю: так легко проверить, что неделя полная.

Недельная аудитория учебного продукта, июнь — август 2026

Учебная база начинается 1 июня, поэтому первые недели — время набора аудитории, а не падение.

WAU
WAU по неделям с понедельника
select
  date_trunc('week', event_time)::date as week_start,
  count(distinct user_id)              as wau,
  min(event_time)::date                as first_day,
  max(event_time)::date                as last_day
from events
group by 1
order by 1;

Неделя с воскресенья

В американских отчётах и части BI-систем неделя начинается с воскресенья. Параметра для этого у date_trunc нет, но есть приём: сдвинуть дату на день вперёд, взять начало ISO-недели и отнять день обратно. Воскресенье после сдвига становится понедельником и открывает свою неделю.

Тот же приём работает для любого дня: чтобы неделя начиналась со среды, сдвигайте на пять дней вперёд и обратно. Главное — договориться о начале недели один раз и подписать это в отчёте, иначе два дашборда покажут разные недельные цифры по одним и тем же данным.

Результат: суббота, воскресенье и понедельник
dayweek_mondayweek_sunday
2026-08-29 (сб)2026-08-242026-08-23
2026-08-30 (вс)2026-08-242026-08-30
2026-08-31 (пн)2026-08-312026-08-30
Начало недели с понедельника и с воскресенья
select
  d                                        as day,
  date_trunc('week', d)::date              as week_monday,
  date_trunc('week', d + 1)::date - 1      as week_sunday
from (values (date '2026-08-29'), (date '2026-08-30'), (date '2026-08-31')) as t(d)
order by 1;

Неполный период: сравнение месяцев без самообмана

Выручка за август выросла на 45 % — или нет? Прежде чем сравнивать месяцы, посчитайте, сколько дней с данными в каждом. В учебной базе первый платёж — 6 июня, а срез данных — 30 августа. Значит, в июне 23 дня с платежами, в августе 30 из 31. Рост в июле на 173 % — во многом эффект неполного июня, а не продукта.

Рост к прошлому месяцу считают оконной функцией lag поверх месячных итогов. Число дней с данными стоит держать рядом: читатель отчёта сразу видит, какой период неполный.

Результат на учебной базе
monthrevenuedays_with_paymentsprev_revenuegrowth_pct
2026-06-013 983.0023——
2026-07-0110 868.00313 983.00172.9
2026-08-0115 788.003010 868.0045.3
Выручка по месяцам, число дней с платежами и рост к прошлому месяцу
with monthly as (
  select
    date_trunc('month', paid_at)::date as month,
    sum(amount)                        as revenue,
    count(distinct paid_at)            as days_with_payments
  from payments
  group by 1
)
select
  month,
  revenue,
  days_with_payments,
  lag(revenue) over (order by month)   as prev_revenue,
  round(100.0 * (revenue - lag(revenue) over (order by month))
        / lag(revenue) over (order by month), 1) as growth_pct
from monthly
order by month;
Текущий месяц — тоже неполный

В рабочей базе последний месяц всегда идёт прямо сейчас. Отрежьте его условием paid_at < date_trunc('month', current_date) или сравнивайте одинаковое число дней: 1–15 августа с 1–15 июля.

date_trunc в WHERE замедляет запрос

Фильтр «платежи за июль» хочется написать как where date_trunc('month', paid_at) = '2026-07-01'. Результат будет правильным, но PostgreSQL придётся вычислить функцию для каждой строки, и обычный индекс по paid_at не поможет. На таблице в сотни миллионов строк это разница между секундами и минутами.

Тот же фильтр через диапазон работает с индексом и читается не хуже. Правая граница — строго меньше начала следующего месяца: так в период попадают и последние секунды 31-го числа, если столбец хранит время.

Платежи за июль: диапазон вместо функции в условии
select
  count(*)    as payments,
  sum(amount) as revenue
from payments
where paid_at >= date '2026-07-01'
  and paid_at <  date '2026-08-01';

Часовой пояс

Если столбец имеет тип timestamp with time zone, начало дня или месяца зависит от часового пояса сессии. Событие в 23:30 по UTC — это уже 02:30 следующего дня по Москве. Аналитик в Москве и сервер с настройкой UTC посчитают DAU за одно и то же число по-разному.

Начиная с PostgreSQL 12 у date_trunc есть третий аргумент — часовой пояс, в котором резать период. Он не зависит от настройки сессии, и запрос даёт один ответ на любом сервере. Подробнее о типах времени и переводе между поясами — в статье про TIMESTAMP в PostgreSQL.

PostgreSQL only: начало дня в UTC и по Москве для одного момента
set timezone = 'UTC';

select
  date_trunc('day', timestamptz '2026-08-30 23:30:00+00')                  as utc_day,
  date_trunc('day', timestamptz '2026-08-30 23:30:00+00', 'Europe/Moscow') as msk_day;
-- utc_day: 2026-08-30 00:00:00+00
-- msk_day: 2026-08-30 21:00:00+00 — это полночь 31 августа по Москве

Аналоги date_trunc в других СУБД

Идея одна и та же — привести дату к началу периода, — но названия функций разные. Если запрос переезжает из PostgreSQL в другую базу, эту строку придётся переписать первой.

Начало месяца в разных СУБД
СУБДНачало месяцаНачало недели
PostgreSQL, DuckDBdate_trunc('month', d)date_trunc('week', d), понедельник
ClickHousetoStartOfMonth(d)toMonday(d); есть и date_trunc
MySQLdate_format(d, '%Y-%m-01')d - interval weekday(d) day
SQL Server 2022+datetrunc(month, d)datetrunc(iso_week, d)
BigQuerydate_trunc(d, month)date_trunc(d, isoweek)

Частые ошибки

  • Группировка по extract(month ...) на данных за несколько лет: разные годы складываются в одну строку.
  • Подпись периода с временем и поясом 2026-06-01 00:00:00+03 в выгрузке: забыли ::date.
  • Сравнение неполного месяца с полным. Проверьте число дней с данными или отрежьте текущий период.
  • Неделя, начатая не с того дня. Договоритесь, с какого дня начинается неделя, и напишите это в подписи отчёта.
  • Пропуски на графике. Если в какой-то месяц не было ни одного платежа, group by просто не вернёт эту строку. Календарь без дыр строят через generate_series и left join.

Частые вопросы

Что возвращает date_trunc?

Значение того же «семейства» времени: для timestamp — timestamp, для timestamp with time zone — его же. Дату PostgreSQL сначала превращает в timestamp with time zone, поэтому для отчётов допишите ::date.

Как сгруппировать по дням?

date_trunc('day', event_time) или проще event_time::date: для группировки по дням оба варианта равнозначны.

Как получить начало текущего месяца?

date_trunc('month', current_date)::date. Конец месяца — начало следующего минус один день: (date_trunc('month', current_date) + interval '1 month')::date - 1.

С какого дня начинается неделя в date_trunc?

С понедельника, по стандарту ISO 8601. Для недели с воскресенья используйте сдвиг date_trunc('week', d + 1)::date - 1.

Есть ли date_trunc в MySQL?

Нет. Начало месяца получают через date_format(d, '%Y-%m-01'), а для недели — через weekday.

Запросы из статьи можно выполнить на учебной базе в песочнице, а группировки по периодам отработать в курсе «SQL с нуля»: там каждое решение проверяется автоматически.

Продолжить чтение
Вся библиотека