DATE_TRUNC в SQL: группировка по неделям, месяцам и кварталам
Как работает date_trunc в PostgreSQL: выручка по месяцам, недели с понедельника и с воскресенья, неполный период, часовой пояс и аналоги в MySQL, ClickHouse и SQL Server.
Содержание статьи
Почти любой отчёт аналитика начинается со слов «по месяцам» или «по неделям»: выручка по месяцам, недельная аудитория, заказы по кварталам. В 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) складывает в одну строку все платежи месяца, а сам результат удобно показывать как подпись периода.
| Единица | Результат | Для чего |
|---|---|---|
minute | 2026-08-17 14:37:00 | нагрузка по минутам, мониторинг |
hour | 2026-08-17 14:00:00 | активность по часам |
day | 2026-08-17 00:00:00 | DAU, заказы по дням |
week | 2026-08-17 00:00:00 | WAU, недельные отчёты; 17 августа — понедельник |
month | 2026-08-01 00:00:00 | MAU, выручка по месяцам |
quarter | 2026-07-01 00:00:00 | квартальная отчётность |
year | 2026-01-01 00:00:00 | год к году |
Выручка по месяцам
Самая частая задача. В учебной базе таблица payments хранит дату платежа paid_at и сумму amount. Группируем по началу месяца и считаем число платежей и выручку.
Обратите внимание на ::date. Без него PostgreSQL вернёт 2026-06-01 00:00:00+03: если передать в date_trunc дату, PostgreSQL сначала превращает её в timestamp с часовым поясом. В отчёте и на графике такая подпись лишняя, а при выгрузке в BI часовой пояс иногда сдвигает дату на предыдущий день.
| month | payments | revenue |
|---|---|---|
| 2026-06-01 | 167 | 3 983.00 |
| 2026-07-01 | 442 | 10 868.00 |
| 2026-08-01 | 642 | 15 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('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 показывают, какие дни попали в неделю: так легко проверить, что неделя полная.
Учебная база начинается 1 июня, поэтому первые недели — время набора аудитории, а не падение.
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-недели и отнять день обратно. Воскресенье после сдвига становится понедельником и открывает свою неделю.
Тот же приём работает для любого дня: чтобы неделя начиналась со среды, сдвигайте на пять дней вперёд и обратно. Главное — договориться о начале недели один раз и подписать это в отчёте, иначе два дашборда покажут разные недельные цифры по одним и тем же данным.
| day | week_monday | week_sunday |
|---|---|---|
| 2026-08-29 (сб) | 2026-08-24 | 2026-08-23 |
| 2026-08-30 (вс) | 2026-08-24 | 2026-08-30 |
| 2026-08-31 (пн) | 2026-08-31 | 2026-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 поверх месячных итогов. Число дней с данными стоит держать рядом: читатель отчёта сразу видит, какой период неполный.
| month | revenue | days_with_payments | prev_revenue | growth_pct |
|---|---|---|---|---|
| 2026-06-01 | 3 983.00 | 23 | — | — |
| 2026-07-01 | 10 868.00 | 31 | 3 983.00 | 172.9 |
| 2026-08-01 | 15 788.00 | 30 | 10 868.00 | 45.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.
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, DuckDB | date_trunc('month', d) | date_trunc('week', d), понедельник |
| ClickHouse | toStartOfMonth(d) | toMonday(d); есть и date_trunc |
| MySQL | date_format(d, '%Y-%m-01') | d - interval weekday(d) day |
| SQL Server 2022+ | datetrunc(month, d) | datetrunc(iso_week, d) |
| BigQuery | date_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 с нуля»: там каждое решение проверяется автоматически.
Материалы по теме
Функции даты в SQL: текущая дата, разница дат, EXTRACT и формат
Справочник функций даты в SQL на учебной базе: текущая дата, часть даты, date_trunc, разница дат в днях, часах и месяцах, интервалы, формат вывода и отличия СУБД.

INTERVAL в SQL: прибавить дни, «последние 7 дней» и date_trunc
INTERVAL в PostgreSQL и DuckDB на учебной базе: синтаксис, дата плюс дни, окно «последние 7 дней» без now(), границы месяца через date_trunc, скользящее окно и календарь.

TIMESTAMP в PostgreSQL: timestamptz, часовые пояса и перевод в дату
Чем timestamp отличается от timestamptz, как работает AT TIME ZONE, как перевести timestamp в дату, отфильтровать день и не потерять сутки на часовом поясе.