INTERVAL в SQL: прибавить дни, «последние 7 дней» и date_trunc
INTERVAL в PostgreSQL и DuckDB на учебной базе: синтаксис, дата плюс дни, окно «последние 7 дней» без now(), границы месяца через date_trunc, скользящее окно и календарь.
Содержание статьи
Дашборд «Активность за последние 7 дней» собрали на выгрузке прошлого квартала и отдали продакту. Он открыл его и увидел нули: ноль событий, ноль активных пользователей. Данные на месте, запрос не падает. Просто в фильтре стоит event_time >= now() - interval '7 days', а последняя строка в таблице — от 30 августа, почти месяц назад. Окно честно отсчитало семь дней от сегодняшнего утра и ничего там не нашло. Ниже — синтаксис интервалов в PostgreSQL и DuckDB, окна «последние N дней», границы месяца через date_trunc и скользящие окна. Все примеры выполняются в песочнице SQL-курса.
Коротко
Учебная база — снимок продукта: регистрации с 1 июня по 29 августа 2026 года, последнее событие 30 августа в 12:59.
- Переносимая запись —
interval '7 days'. Формуinterval 7 dayпонимает DuckDB, а PostgreSQL отвечает синтаксической ошибкой. date + 7остаётся датой,date + interval '7 days'превращается в метку времени. Для колонок DATE удобнее целое число.- Окно «последние 7 дней» на исторических данных отсчитывают от
max(event_time), а не отnow(). Отnow()учебная база даёт 0 событий, от последнего полного дня — 4 331. - Последний день выгрузки обычно неполный. Если его включить, неделя «падает» на 7% без всякой причины.
- Границы месяца —
date_trunc('month', d)и+ interval '1 month', фильтр — полуоткрытый:>= началаи< начала следующего. - Скользящее окно пишите как
range between interval '6 days' preceding, а неrows between 6 preceding: на данных с пропущенными днями ROWS захватывает лишние даты.
Синтаксис INTERVAL в PostgreSQL и DuckDB
Интервал — это отдельный тип: длительность в месяцах, днях и долях дня. Литерал пишут словом interval и строкой: interval '7 days', interval '2 hours 30 minutes', interval '1 month'. Эта запись одинаково работает в PostgreSQL и DuckDB.
Форма без кавычек, interval 7 day, пришла из MySQL. DuckDB её принимает, PostgreSQL останавливается на syntax error at or near "7". Если запрос может переехать между базами, пишите со строкой.
Когда число дней приходит из параметра или колонки, склеивать строку не нужно. n * interval '1 day' работает в обеих базах. В PostgreSQL есть ещё make_interval(days => n), в DuckDB такой функции нет, зато есть to_days(n).
Составной литерал interval '1 month - 1 day' PostgreSQL читает как «месяц минус день», а DuckDB падает с ошибкой преобразования строки в INTERVAL. В DuckDB то же самое пишется как interval '1 month' - interval '1 day'.
| Выражение | PostgreSQL | DuckDB |
|---|---|---|
| interval '7 days' | 7 days | 7 days |
| interval 7 day | синтаксическая ошибка | 7 days |
| 7 * interval '1 day' | 7 days | 7 days |
| interval '1 month - 1 day' | 1 mon -1 days | ошибка преобразования |
| date '2026-08-23' + 7 | 2026-08-30, тип date | 2026-08-30, тип DATE |
| date '2026-08-23' + interval '7 days' | 2026-08-30 00:00:00, timestamp | 2026-08-30 00:00:00, TIMESTAMP |
| date '2026-08-30' - date '2026-08-23' | 7, integer | 7, BIGINT |
| timestamp '2026-08-30 12:59' - timestamp '2026-08-23 09:00' | 7 days 03:59:00 | 7 days 03:59:00 |
| date '2026-01-31' + interval '1 month' | 2026-02-28 00:00:00 | 2026-02-28 00:00:00 |
| date '2026-01-31' + interval '30 days' | 2026-03-02 00:00:00 | 2026-03-02 00:00:00 |
| interval '30 days' = interval '1 month' | true | true |
Прибавить дни к дате: число или интервал
К дате можно прибавить целое число — это дни. signup_date + 7 возвращает DATE и в PostgreSQL, и в DuckDB. Интервал меняет тип: signup_date + interval '7 days' — уже TIMESTAMP с полуночью. В соединении с колонкой DATE или в выгрузке в BI лишнее время мешает. Дни к датам прибавляйте числом, часы и месяцы — интервалом.
Разность двух дат — целое число дней, разность двух меток времени — интервал вроде 7 days 03:59:00.
Последние две строки таблицы выше — главная ловушка с месяцами. При сравнении PostgreSQL и DuckDB считают месяц равным 30 дням, поэтому interval '30 days' = interval '1 month' истинно. Но при сложении это разные сдвиги: 31 января плюс месяц — 28 февраля, плюс 30 дней — 2 марта. Для «того же дня в прошлом месяце» нужен месяц, для «последних 30 дней» — дни.
Про age, date_diff и разницу в месяцах — в справочнике функций даты.
«Последние 7 дней» на исторических данных: якорь из самих данных
now() - interval '7 days' означает «семь дней до момента запуска запроса». На живой базе, куда события пишутся каждую минуту, это и есть последние семь дней. На снимке, выгрузке или учебной базе — нет: 24 сентября 2026 года такое окно на учебной базе возвращает 0 событий и 0 пользователей. Дашборд пустеет, как только данные перестают обновляться, и ошибки при этом нет.
Якорь берут из данных: max(event_time) или явную дату отчёта в параметре. Но и тут есть выбор. Последнее событие — 30 августа в 12:59, то есть 30-е — неполный день выгрузки. Если отсчитать семь календарных дней, включая его, получится 4 014 событий против 4 331 за семь полных дней: минус 7% только из-за того, что в последнем дне половина часов. Скользящее окно «168 часов до последнего события» режет неполные дни с обеих сторон и даёт 4 308 — число, которое трудно объяснить продакту.
Надёжный вариант — окно из полных дней, которое заканчивается перед днём выгрузки: с 23 по 29 августа включительно. Правая граница строгая, < data_day, и она же отрезает неполный день.
| Условие | Что попадает в окно | Событий | Пользователей |
|---|---|---|---|
| event_time >= now() - interval '7 days' | с 17 сентября, данных нет | 0 | 0 |
| event_time > max(event_time) - interval '7 days' | с 23 августа 12:59 до 30 августа 12:59 | 4 308 | 2 223 |
| 7 календарных дней, включая день выгрузки | 24–30 августа, 30-е неполное | 4 014 | 2 118 |
| 7 полных дней до дня выгрузки | 23–29 августа | 4 331 | 2 244 |
with bounds as (
select cast(max(event_time) as date) as data_day -- 2026-08-30, неполный день
from events
)
select
b.data_day - 7 as window_start, -- 2026-08-23
b.data_day as window_end_excl, -- 2026-08-30, не входит
count(*) as events,
count(distinct e.user_id) as active_users
from events e
cross join bounds b
where e.event_time >= b.data_day - 7
and e.event_time < b.data_day
group by b.data_day;
-- 2026-08-23 | 2026-08-30 | 4331 | 2244Границы месяца через date_trunc и interval
date_trunc('month', d) возвращает начало месяца, + interval '1 month' — начало следующего. Из этих двух точек собирается любой месячный фильтр: d >= начало и d < начало следующего. Полуоткрытый интервал не требует знать, сколько дней в месяце, и не теряет последние часы 31-го числа, как between с датой конца на колонке timestamp.
date_trunc от даты возвращает метку времени: в DuckDB — TIMESTAMP, в PostgreSQL — timestamp with time zone. Если граница потом сравнивается с колонкой DATE или выводится в отчёт, приводите её к дате сразу.
Частая рабочая задача — сравнить текущий неполный месяц с прошлым. Выручка учебной базы с 1 по 29 августа — $15 067. Полный июль — $10 868, и сравнение «август к июлю» показывает рост на 38,6%. Но в августе прошло 29 дней, а в июле 31. Честная пара — те же 29 дней июля: $9 867, и рост становится 52,7%. Граница «тот же день прошлого месяца» считается как last_day - interval '1 month'.
Нюанс тот же, что выше: для 31 марта - interval '1 month' даст 28 февраля, и прошлый период окажется короче на три дня.
with bounds as (
select
last_day,
cast(date_trunc('month', last_day) as date) as cur_start, -- 2026-08-01
cast(date_trunc('month', last_day) - interval '1 month' as date) as prev_start, -- 2026-07-01
cast(last_day - interval '1 month' as date) as prev_last -- 2026-07-29
from (select cast(max(event_time) as date) - 1 as last_day from events) as d -- 2026-08-29
)
select
sum(p.amount) filter (where p.paid_at >= b.cur_start and p.paid_at < b.last_day + 1) as revenue_mtd,
sum(p.amount) filter (where p.paid_at >= b.prev_start and p.paid_at < b.prev_last + 1) as revenue_prev_mtd,
sum(p.amount) filter (where p.paid_at >= b.prev_start and p.paid_at < b.cur_start) as revenue_prev_full
from payments p
cross join bounds b
group by b.cur_start, b.last_day, b.prev_start, b.prev_last;
-- 15067 | 9867 | 10868Скользящее среднее за 7 дней: RANGE BETWEEN INTERVAL
Дневной DAU шумит: в учебной базе он прыгает между 406 и 586 в течение августа. Скользящее среднее за семь дней убирает недельные колебания и показывает тренд. Оконная функция с range between interval '6 days' preceding and current row берёт в окно все строки, дата которых отстоит от текущей не больше чем на шесть дней. Вместе с текущим днём — семь календарных дней.
RANGE с интервалом поддерживают обе базы, PostgreSQL — с 11-й версии. В order by окна должна быть одна колонка с датой или временем.
За август среднее выросло с 425 до 535,4 пользователя в день. На графике видно, что в первой половине месяца дневные значения держатся в районе 450, а в последнюю неделю доходят до 586. Неполный день 30 августа исключён фильтром event_time < date '2026-08-30', иначе последняя точка упала бы до 223 и потянула среднее вниз.
Учебная база SQL-курса. Среднее посчитано через range between interval '6 days' preceding по всему периоду, поэтому окна в начале августа полные.
with daily as (
select cast(event_time as date) as day, count(distinct user_id) as dau
from events
where event_time < date '2026-08-30' -- неполный день выгрузки не берём
group by cast(event_time as date)
),
smoothed as (
select
day,
dau,
avg(dau) over (
order by day
range between interval '6 days' preceding and current row
) as dau_7d
from daily
)
select day, dau, round(dau_7d, 1) as dau_7d
from smoothed
where day >= date '2026-08-23'
order by day;
-- 2026-08-29 | 530 | 535.4ROWS против RANGE: что происходит в дни без данных
В событиях учебной базы нет пустых дней, поэтому rows between 6 preceding и range between interval '6 days' preceding дают одно и то же. На редких событиях это не так. Платежи тарифа team приходят не каждый день: с 6 июня по 29 августа было 19 дней без единой оплаты.
ROWS отсчитывает шесть предыдущих строк, а не шесть предыдущих дней. 13 и 18 августа team-платежей не было, и для 19 августа окно из семи строк растянулось на 11–19 августа, девять календарных дней. Результат — 16 платежей «за неделю». RANGE с интервалом считает только 13–19 августа и даёт 11. Разница в полтора раза, и график с ROWS рисует ложные всплески после каждого пропуска.
with team_daily as (
select paid_at, count(*) as payments
from payments
where plan = 'team'
group by paid_at
),
rolled as (
select
paid_at,
payments,
sum(payments) over (order by paid_at rows between 6 preceding and current row) as by_rows,
sum(payments) over (order by paid_at range between interval '6 days' preceding and current row) as by_range
from team_daily
)
select * from rolled
where paid_at between date '2026-08-14' and date '2026-08-19'
order by paid_at;
-- 2026-08-19 | 5 | 16 | 11Календарь через generate_series: не терять пустые дни
RANGE чинит окно, но не чинит среднее. Если посчитать среднее число team-платежей в день по сгруппированной таблице, пустые дни в него не попадут вообще: их там нет. Получится 2,11 платежа в день. На самом деле за 85 дней было 139 платежей, то есть 1,64 в день. Завышение на 29% — это средний день среди тех, где что-то было, а не средний день.
Лечится календарём: ряд всех дат периода, к которому данные присоединяют через left join, а пропуски заменяют нулём. Ряд строит generate_series(начало, конец, interval '1 day'). Конец в DuckDB и PostgreSQL включается. Возвращает функция метку времени — в PostgreSQL даже timestamp with time zone, — поэтому результат приводят к дате. В DuckDB есть и range(…) с тем же набором аргументов, но без правой границы.
Границы календаря тоже берутся из данных: от первой оплаты до последнего полного дня. Об этом подробнее — в статье про периоды и календарь дат.
with bounds as (
select min(paid_at) as first_day, max(paid_at) - 1 as last_day -- 2026-06-06 и 2026-08-29
from payments
),
calendar as (
select cast(g.day as date) as day
from bounds b, generate_series(b.first_day, b.last_day, interval '1 day') as g(day)
),
team as (
select paid_at, count(*) as payments
from payments
where plan = 'team'
group by paid_at
)
select
count(*) as days,
count(t.paid_at) as days_with_payments,
sum(coalesce(t.payments, 0)) as payments,
round(avg(t.payments), 2) as avg_on_active_days,
round(avg(coalesce(t.payments, 0)), 2) as avg_per_calendar_day
from calendar c
left join team t on t.paid_at = c.day;
-- 85 | 66 | 139 | 2.11 | 1.64Скользящий WAU: интервал в условии соединения
Скользящее среднее DAU не отвечает на вопрос «сколько разных людей было за последние 7 дней». С 23 по 29 августа сумма дневных DAU — 3 748, а уникальных пользователей — 2 244: один человек, заходивший три дня, трижды попадает в сумму. count(distinct …) over (…) PostgreSQL не поддерживает, поэтому уникальных считают через соединение календаря с событиями по интервальному условию.
Каждый день календаря забирает события из окна от day - interval '6 days' до day + interval '1 day', правая граница строгая. Каждое событие попадает в семь окон, поэтому на больших таблицах соединение ограничивают периодом отчёта.
with calendar as (
select cast(g.day as date) as day
from generate_series(date '2026-08-23', date '2026-08-29', interval '1 day') as g(day)
)
select c.day, count(distinct e.user_id) as wau_7d
from calendar c
join events e
on e.event_time >= c.day - interval '6 days'
and e.event_time < c.day + interval '1 day'
group by c.day
order by c.day;
-- 2026-08-23: 2045 … 2026-08-29: 2244D1 и D7 retention через signup_date + interval
Retention дня N — доля пользователей, которые вернулись ровно на N-й день после регистрации. Окно дня задаётся двумя интервалами от даты регистрации: >= signup_date + interval '7 days' и < signup_date + interval '8 days'.
Второй интервал нужен в знаменателе. Пользователь, зарегистрированный 25 августа, не мог вернуться на седьмой день: этот день ещё не наступил к концу данных. Если оставить его в знаменателе, retention занижается. На учебной базе D7 по всем 4 613 пользователям — 19,1%, а по 4 109 тем, у кого седьмой день уже прошёл к 29 августа, — 21,4%. D1 по той же логике — 2 954 из 4 575, 64,6%.
with activity as (
select
u.user_id,
u.signup_date,
max(case when e.event_time >= u.signup_date + interval '1 day'
and e.event_time < u.signup_date + interval '2 days' then 1 else 0 end) as d1,
max(case when e.event_time >= u.signup_date + interval '7 days'
and e.event_time < u.signup_date + interval '8 days' then 1 else 0 end) as d7
from users u
left join events e on e.user_id = u.user_id
group by u.user_id, u.signup_date
)
select
count(*) filter (where signup_date + interval '1 day' <= date '2026-08-29') as d1_base,
sum(d1) filter (where signup_date + interval '1 day' <= date '2026-08-29') as d1_returned,
count(*) filter (where signup_date + interval '7 days' <= date '2026-08-29') as d7_base,
sum(d7) filter (where signup_date + interval '7 days' <= date '2026-08-29') as d7_returned
from activity;
-- 4575 | 2954 | 4109 | 881Часовые пояса: interval '1 day' — не всегда 24 часа
В учебной базе event_time — TIMESTAMP без пояса, и интервалы там просто сдвигают стрелки. С timestamptz в PostgreSQL добавляется нюанс: interval '1 day' сохраняет время на часах в поясе сессии, а interval '24 hours' добавляет ровно 24 часа. В Москве перехода на летнее время нет, и разницы не видно. В Берлине в ночь на 25 октября 2026 года часы переводят назад, и результаты расходятся на час.
Второй вопрос — в каком поясе резать дни. Если метки хранятся в UTC, а отчёт нужен по Москве, их переводят явно: (event_time at time zone 'UTC') at time zone 'Europe/Moscow'. Событие 29 августа в 22:30 UTC — это 30 августа, 01:30 по Москве. Если бы метки учебной базы были в UTC, в московский следующий день переехало бы 281 событие из 35 341. Немного, но именно на границах окон.
set timezone = 'Europe/Berlin';
select
timestamptz '2026-10-24 12:00' + interval '1 day' as plus_1_day, -- 2026-10-25 12:00:00+01
timestamptz '2026-10-24 12:00' + interval '24 hours' as plus_24h; -- 2026-10-25 11:00:00+01Частые вопросы
Как прибавить дни к дате в SQL? В PostgreSQL и DuckDB — d + 7 для колонки DATE, результат остаётся датой. d + interval '7 days' тоже работает, но возвращает метку времени. В MySQL пишут date_add(d, interval 7 day).
Как посчитать разницу между датами? Для двух дат — вычитанием, результат в днях. Для меток времени вычитание даёт интервал, в часы его переводят через extract(epoch from t2 - t1) / 3600. В DuckDB есть date_diff('day', t1, t2), но он считает пересечённые полуночи: от 29 августа 23:50 до 30 августа 00:10 прошло 20 минут, а date_diff вернёт 1.
Как выбрать данные за прошлый месяц? d >= date_trunc('month', якорь) - interval '1 month' и d < date_trunc('month', якорь), где якорь — дата отчёта или последний полный день данных.
Что попробовать в песочнице
Два упражнения на той же учебной базе. Посчитайте D7 по каналам регистрации с тем же условием зрелости. Постройте календарь за июль и найдите дни без единой team-оплаты.
Даты и интервалы разобраны в десятой главе SQL-курса на заданиях с автоматической проверкой. Первые главы открыты бесплатно.
Материалы по теме
Функции даты в SQL: текущая дата, разница дат, EXTRACT и формат
Справочник функций даты в SQL на учебной базе: текущая дата, часть даты, date_trunc, разница дат в днях, часах и месяцах, интервалы, формат вывода и отличия СУБД.

LATERAL JOIN в SQL: последняя запись, top-N на пользователя и функции в FROM
LATERAL JOIN на реальной учебной базе: последний платёж пользователя со всеми полями, разница между LEFT и CROSS, почему подзапрос с агрегатом никогда не отбрасывает строки, три последних события на человека, индекс, без которого LATERAL читает таблицу на каждой строке, и когда хватит GROUP BY, DISTINCT ON или окна.

Типы данных в SQL и CAST: почему конверсия равна нулю и сумма не сходится
Типы данных в SQL на реальной учебной базе: целочисленное деление в PostgreSQL и DuckDB, DECIMAL против DOUBLE для денег, round и boolean, текст в число и дату, BETWEEN по timestamp и приведение типа в WHERE, которое выключает индекс.