sqlpostgresqldatesinterval

Арифметика дат в SQL: как складывать даты, интервалы и не путать дни с часами

Как прибавлять дни к date, вычитать даты в число суток и прибавлять interval к timestamp в PostgreSQL, MySQL и ClickHouse.

10 мин чтенияСправочникsql · postgresql · dates · interval · timestamp

Арифметика дат в SQL — это когда мы прибавляем к дате несколько дней, вычитаем одну дату из другой, считаем срок оплаты, стаж сотрудника или давность события.

Звучит просто:

SELECT DATE '2024-03-01' + 7 AS next_week;

Но в датах есть тонкость: результат зависит от типа данных.

date ведёт себя не так, как timestamp.

К date можно прибавить целое число — PostgreSQL поймёт его как количество дней. А вот к timestamp целое число просто так не прибавляется: нужен interval.

Вычитание тоже разное:

  • date - date возвращает целое число дней;
  • timestamp - timestamp возвращает interval, где могут быть дни, часы, минуты и секунды.

Если этого не понимать, запросы начинают ломаться в самых неприятных местах: на конце месяца, в високосный год, при расчёте просрочки, стажа или фильтра «старше 30 дней».

Разберём всё по шагам.

Что такое date, timestamp и interval

В PostgreSQL для работы со временем часто встречаются три типа.

date — это только дата, без времени.

Например:

2024-03-01

timestamp — это дата и время.

Например:

2024-03-01 14:30:00

interval — это длительность.

Например:

7 days
1 month
36 hours

Именно interval нужен, когда мы хотим сказать: «прибавь к дате или времени не конкретную дату, а промежуток».

Например:

SELECT TIMESTAMP '2024-03-01 14:30:00' + INTERVAL '7 days' AS reminder_at;

Результат:

reminder_at
2024-03-08 14:30:00

Как прибавить дни к date

Самый простой случай — прибавить целое число к date.

В PostgreSQL такое число считается количеством дней.

SELECT DATE '2024-03-01' + 7 AS next_week;

Результат:

next_week
2024-03-08

Можно и вычитать:

SELECT DATE '2024-03-01' - 30 AS month_ago;

Результат:

month_ago
2024-01-31

Здесь PostgreSQL не думает про месяцы. Он просто отнимает 30 календарных дней.

Это удобно для задач вроде «срок оплаты через 14 дней после заказа».

SELECT
    id,
    created_at::date AS placed_on,
    created_at::date + 14 AS due_date
FROM orders
WHERE status = 'pending';

Результат может быть таким:

id placed_on due_date
101 2024-03-01 2024-03-15
102 2024-03-05 2024-03-19

Здесь created_at::date превращает дату-время в обычную дату, а + 14 добавляет 14 дней.

Почему к timestamp нельзя просто прибавить число

С timestamp так не получится.

Например, такой запрос в PostgreSQL не сработает:

SELECT TIMESTAMP '2024-03-01 10:00:00' + 7 AS result;

PostgreSQL не понимает, что означает 7 рядом с timestamp.

Это 7 дней? 7 часов? 7 секунд? 7 месяцев?

Для timestamp нужно явно указать единицу через interval:

SELECT TIMESTAMP '2024-03-01 10:00:00' + INTERVAL '7 days' AS result;

Результат:

result
2024-03-08 10:00:00

Если нужно прибавить часы:

SELECT TIMESTAMP '2024-03-01 10:00:00' + INTERVAL '7 hours' AS result;

Результат:

result
2024-03-01 17:00:00

Если нужно прибавить 30 минут:

SELECT TIMESTAMP '2024-03-01 10:00:00' + INTERVAL '30 minutes' AS result;

Результат:

result
2024-03-01 10:30:00

Правило простое: для date целое число — это дни, а для timestamp используйте interval.

Когда использовать interval

interval нужен, когда вы работаете не просто с датой, а с длительностью.

Например:

SELECT
    created_at + INTERVAL '7 days' AS reminder_at,
    created_at + INTERVAL '1 month' AS renew_at,
    created_at + INTERVAL '36 hours' AS grace_until
FROM users;

Такие выражения читаются почти как обычный текст:

  • напомнить через 7 дней;
  • продлить через 1 месяц;
  • дать льготный период на 36 часов.

Интервал можно вычитать:

SELECT NOW() - INTERVAL '30 days' AS threshold;

И использовать в фильтрах:

SELECT
    id,
    email,
    created_at
FROM users
WHERE created_at >= NOW() - INTERVAL '6 months';

Такой запрос найдёт пользователей, созданных за последние 6 месяцев.

Почему месяц — это не всегда 30 дней

Очень частая ошибка — считать месяц как 30 дней.

Например:

SELECT DATE '2024-01-31' + 30 AS result;

Результат:

result
2024-03-01

Но если по бизнес-смыслу нужно «через один календарный месяц», это уже другая логика.

Используйте INTERVAL '1 month':

SELECT DATE '2024-01-31' + INTERVAL '1 month' AS result;

Результат будет календарным сдвигом на месяц. На конце месяца PostgreSQL подберёт ближайшую корректную дату следующего месяца.

Например, в 2024 году февраль високосный:

expression result
DATE '2024-01-31' + INTERVAL '1 month' 2024-02-29 00:00:00

А в невисокосный год:

expression result
DATE '2023-01-31' + INTERVAL '1 month' 2023-02-28 00:00:00

Это важная разница.

+ 30 означает ровно 30 дней.

INTERVAL '1 month' означает один календарный месяц.

Для скидки «действует 30 дней» подходит + 30.

Для подписки «продлить на месяц» чаще подходит INTERVAL '1 month'.

Вычитание двух date

Если вычесть одну дату из другой, PostgreSQL вернёт количество дней.

SELECT DATE '2024-03-01' - DATE '2024-01-15' AS days;

Результат:

days
46

Тип результата — целое число.

Это удобно, когда нужно посчитать возраст заявки в днях:

SELECT
    id,
    NOW()::date - created_at::date AS days_open
FROM orders
WHERE status = 'pending';

Результат:

id days_open
101 12
102 35

Такой результат можно спокойно сравнивать с числом:

SELECT
    id,
    NOW()::date - created_at::date AS days_open
FROM orders
WHERE status = 'pending'
  AND NOW()::date - created_at::date > 30;

Здесь мы ищем заказы, которые открыты больше 30 календарных дней.

Вычитание двух timestamp

Теперь сравним с timestamp.

Если вычесть один timestamp из другого, PostgreSQL вернёт не число, а interval.

SELECT
    TIMESTAMP '2024-03-01 09:00:00'
  - TIMESTAMP '2024-01-15 18:30:00' AS span;

Результат:

span
45 days 14:30:00

Здесь важны уже не только дни, но и часы с минутами.

И это логично: между двумя моментами времени может быть не ровное количество суток.

Поэтому такой запрос может сломаться:

SELECT
    id,
    NOW() - created_at AS age
FROM orders
WHERE NOW() - created_at > 30;

Проблема в сравнении:

NOW() - created_at > 30

Левая часть — interval.

Правая часть — число.

PostgreSQL не будет угадывать, что вы имели в виду: 30 дней, 30 часов или 30 секунд.

Правильно так:

SELECT
    id,
    NOW() - created_at AS age
FROM orders
WHERE NOW() - created_at > INTERVAL '30 days';

Теперь обе части сравнения понятны: интервал сравнивается с интервалом.

Как получить целые дни из timestamp

Иногда время неважно. Нужно именно количество календарных дней.

Например, заказ создан 2024-03-01 23:50, а сегодня 2024-03-02 00:10.

Между моментами прошло всего 20 минут, но календарная дата уже изменилась.

Если вам важны календарные дни, приводите оба значения к date:

SELECT
    id,
    NOW()::date - created_at::date AS days_open
FROM orders;

Если вам важна точная длительность, оставляйте timestamp и сравнивайте с interval:

SELECT
    id,
    NOW() - created_at AS exact_age
FROM orders;

Это разные бизнес-смыслы.

NOW()::date - created_at::date отвечает на вопрос: «сколько календарных дат прошло?»

NOW() - created_at отвечает на вопрос: «сколько точного времени прошло?»

Пример: просроченные заказы

Допустим, заказ нужно оплатить в течение 14 дней после создания.

Если created_at — это timestamp, можно написать так:

SELECT
    id,
    created_at,
    created_at + INTERVAL '14 days' AS due_at
FROM orders
WHERE status = 'pending';

А теперь найдём просроченные:

SELECT
    id,
    created_at,
    created_at + INTERVAL '14 days' AS due_at
FROM orders
WHERE status = 'pending'
  AND created_at < NOW() - INTERVAL '14 days';

Обратите внимание на условие:

created_at < NOW() - INTERVAL '14 days'

Мы не пишем так:

created_at + INTERVAL '14 days' < NOW()

Хотя по смыслу это похоже.

Почему первый вариант лучше? Потому что он оставляет колонку created_at «чистой» в левой части сравнения. Так базе проще использовать индекс по created_at.

Это называется sargable-условие: условие, в котором индекс по колонке может работать эффективно.

На маленькой таблице разницы почти не видно. На большой таблице с миллионами заказов — очень даже видно.

Стаж сотрудника: дни или человеческий возраст

Представим таблицу сотрудников:

CREATE TABLE employees (
    id integer,
    name text,
    dept text,
    hired_at date
);

Если нужен стаж в днях, можно просто вычесть даты:

SELECT
    name,
    dept,
    NOW()::date - hired_at AS tenure_days
FROM employees
ORDER BY tenure_days DESC;

Результат:

name dept tenure_days
Anna Engineering 1450
Boris Support 870

Но если нужно показать стаж по-человечески — в годах, месяцах и днях — лучше использовать AGE.

SELECT
    name,
    dept,
    AGE(NOW(), hired_at) AS tenure_human
FROM employees;

Результат может быть таким:

name dept tenure_human
Anna Engineering 3 years 11 mons 20 days
Boris Support 2 years 4 mons 18 days

AGE учитывает календарь: месяцы разной длины, високосные годы и реальные даты.

Поэтому не стоит считать годы так:

SELECT
    name,
    (NOW()::date - hired_at) / 365 AS years
FROM employees;

Это грубое приближение.

365 дней — не всегда год. Есть високосные годы, а возраст и стаж обычно считаются по календарю.

Если нужны полные годы, используйте AGE вместе с EXTRACT.

SELECT
    name,
    EXTRACT(YEAR FROM AGE(NOW(), hired_at)) AS full_years
FROM employees;

Пример: пользователи старше 30 дней

Допустим, нужно найти пользователей, которые зарегистрировались больше 30 дней назад.

Если нужна точная длительность от момента регистрации:

SELECT
    id,
    email,
    created_at
FROM users
WHERE created_at < NOW() - INTERVAL '30 days';

Это хороший вариант: он понятный и дружит с индексом по created_at.

Если нужны именно календарные дни:

SELECT
    id,
    email,
    created_at::date AS registered_on,
    NOW()::date - created_at::date AS days_since_registration
FROM users
WHERE NOW()::date - created_at::date > 30;

Этот запрос отвечает немного на другой вопрос: сколько дат прошло между регистрацией и сегодняшним днём.

В аналитике чаще используют первый вариант для точных периодов, а второй — когда отчёт строится именно по календарным датам.

Переход на летнее время и часовые пояса

Если вы работаете только с date, переход на летнее время обычно не волнует: дата есть дата.

Но если вы работаете с timestamp with time zone, появляются нюансы.

Некоторые сутки в конкретном часовом поясе могут длиться не 24 часа:

  • при переходе на летнее время — 23 часа;
  • при переходе обратно — 25 часов.

Поэтому выражения вроде «прибавить 1 день» и «прибавить 24 часа» в задачах с часовыми поясами могут иметь разный смысл.

SELECT
    created_at + INTERVAL '1 day' AS plus_one_day,
    created_at + INTERVAL '24 hours' AS plus_24_hours
FROM events;

На обычных датах разницы вы можете не заметить. Но на границах перехода времени она способна проявиться.

Практическое правило:

  • если логика календарная, используйте дни, месяцы, даты;
  • если логика техническая и точная, используйте часы, минуты, секунды;
  • для событий во времени храните значения в подходящем типе и явно учитывайте часовой пояс.

Крайние даты, которые нужно тестировать

Арифметика дат часто ломается не на обычных датах, а на краях.

Если вы пишете важную логику, проверьте такие случаи:

Случай Почему важен
31 января плюс 1 месяц в феврале нет 31 числа
29 февраля плюс 1 год високосный год
конец месяца плюс несколько дней легко перескочить через месяц
дата перед переходом DST сутки могут быть 23 или 25 часов
событие почти в полночь календарный день и точная длительность расходятся

Например:

SELECT DATE '2024-01-31' + INTERVAL '1 month' AS result;
SELECT DATE '2024-02-29' + INTERVAL '1 year' AS result;
SELECT
    TIMESTAMP '2024-03-01 23:50:00'
  - TIMESTAMP '2024-03-01 00:10:00' AS span;

Такие проверки помогают поймать ошибки до того, как их найдёт пользователь, бухгалтерия или внешний сервис.

MySQL: используйте DATEDIFF и DATE_ADD

В MySQL арифметика дат выглядит иначе.

Для разницы в днях используют DATEDIFF.

SELECT DATEDIFF('2024-03-01', '2024-01-15') AS days;

Результат:

days
46

Для прибавления дней используют DATE_ADD.

SELECT DATE_ADD('2024-03-01', INTERVAL 7 DAY) AS next_week;

Результат:

next_week
2024-03-08

Для вычитания — DATE_SUB.

SELECT DATE_SUB('2024-03-01', INTERVAL 30 DAY) AS month_ago;

Результат:

month_ago
2024-01-31

Лучше не писать в MySQL переносимый код в стиле:

SELECT '2024-03-01' + 7 AS result;

Такой запрос может дать совсем не тот смысл, который вы ожидаете. Используйте явные функции: DATEDIFF, DATE_ADD, DATE_SUB.

ClickHouse: dateDiff, addDays и addMonths

В ClickHouse подход тоже свой.

Для разницы дат используют dateDiff, где единица измерения задаётся явно.

SELECT dateDiff('day', toDate('2024-01-15'), toDate('2024-03-01')) AS days;

Результат:

days
46

Для прибавления дней можно использовать addDays.

SELECT addDays(toDate('2024-03-01'), 7) AS next_week;

Результат:

next_week
2024-03-08

Для месяцев — addMonths.

SELECT addMonths(toDate('2024-01-31'), 1) AS next_month;

ClickHouse хорош тем, что функции часто заставляют вас явно назвать единицу: день, месяц, час и так далее. Это снижает риск случайной неоднозначности.

PostgreSQL, MySQL и ClickHouse: короткое сравнение

Задача PostgreSQL MySQL ClickHouse
Добавить 7 дней date_col + 7 или ts_col + INTERVAL '7 days' DATE_ADD(date_col, INTERVAL 7 DAY) addDays(date_col, 7)
Вычесть даты в днях end_date - start_date DATEDIFF(end_date, start_date) dateDiff('day', start_date, end_date)
Добавить месяц date_col + INTERVAL '1 month' DATE_ADD(date_col, INTERVAL 1 MONTH) addMonths(date_col, 1)
Последние 30 дней created_at >= NOW() - INTERVAL '30 days' created_at >= NOW() - INTERVAL 30 DAY created_at >= now() - INTERVAL 30 DAY

Общее правило: если код должен быть переносимым между СУБД, не полагайтесь на неявную арифметику. Используйте явные функции и явно называйте единицу измерения.

Как писать условия, чтобы работал индекс

Допустим, есть индекс:

CREATE INDEX orders_created_at_idx
ON orders (created_at);

Плохой для индекса вариант:

SELECT
    id,
    created_at
FROM orders
WHERE created_at + INTERVAL '30 days' < NOW();

Здесь мы применяем арифметику к колонке created_at. Базе сложнее использовать обычный индекс по этой колонке.

Лучше перенести вычисление на сторону константы:

SELECT
    id,
    created_at
FROM orders
WHERE created_at < NOW() - INTERVAL '30 days';

Смысл тот же: заказ старше 30 дней.

Но теперь колонка стоит в условии без обёртки:

created_at < ...

Такой запрос обычно лучше подходит для использования индекса.

Запомните практическое правило: по возможности не оборачивайте индексируемую колонку в вычисления в WHERE. Двигайте арифметику на другую сторону сравнения.

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

Первая ошибка — прибавлять число к timestamp.

Плохо:

SELECT created_at + 7 AS reminder_at
FROM orders;

Лучше:

SELECT created_at + INTERVAL '7 days' AS reminder_at
FROM orders;

Вторая ошибка — сравнивать interval с числом.

Плохо:

SELECT id
FROM orders
WHERE NOW() - created_at > 30;

Лучше:

SELECT id
FROM orders
WHERE NOW() - created_at > INTERVAL '30 days';

Третья ошибка — считать месяц как 30 дней.

Плохо, если нужен календарный месяц:

SELECT created_at + INTERVAL '30 days' AS renew_at
FROM subscriptions;

Лучше:

SELECT created_at + INTERVAL '1 month' AS renew_at
FROM subscriptions;

Четвёртая ошибка — делить дни на 365 для расчёта полных лет.

Плохо:

SELECT (NOW()::date - hired_at) / 365 AS years
FROM employees;

Лучше:

SELECT EXTRACT(YEAR FROM AGE(NOW(), hired_at)) AS full_years
FROM employees;

Пятая ошибка — писать условие так, что индекс по дате становится бесполезен.

Плохо:

SELECT id
FROM orders
WHERE created_at + INTERVAL '30 days' < NOW();

Лучше:

SELECT id
FROM orders
WHERE created_at < NOW() - INTERVAL '30 days';

Практические шаблоны

Срок оплаты через 14 дней:

SELECT
    id,
    created_at,
    created_at + INTERVAL '14 days' AS due_at
FROM orders;

Заказы старше 30 дней:

SELECT
    id,
    created_at
FROM orders
WHERE created_at < NOW() - INTERVAL '30 days';

Количество календарных дней с момента создания:

SELECT
    id,
    NOW()::date - created_at::date AS days_open
FROM orders;

Пользователи за последние 6 месяцев:

SELECT
    id,
    email,
    created_at
FROM users
WHERE created_at >= NOW() - INTERVAL '6 months';

Стаж сотрудника в днях:

SELECT
    name,
    NOW()::date - hired_at AS tenure_days
FROM employees;

Стаж сотрудника в полных годах:

SELECT
    name,
    EXTRACT(YEAR FROM AGE(NOW(), hired_at)) AS full_years
FROM employees;

Главное

Арифметика дат в SQL зависит от типов.

В PostgreSQL:

SELECT DATE '2024-03-01' + 7 AS next_week;

date + integer означает прибавить дни.

SELECT DATE '2024-03-01' - DATE '2024-01-15' AS days;

date - date возвращает целое число дней.

SELECT TIMESTAMP '2024-03-01 10:00:00' + INTERVAL '7 days' AS reminder_at;

К timestamp нужно прибавлять interval, а не число.

SELECT NOW() - created_at AS age
FROM orders;

timestamp - timestamp возвращает interval.

Главные правила:

  • к date можно прибавлять целое число как дни;
  • к timestamp прибавляйте interval;
  • date - date даёт количество дней;
  • timestamp - timestamp даёт интервал с часами и минутами;
  • для календарного месяца используйте INTERVAL '1 month', а не 30 дней;
  • для точного периода сравнивайте с INTERVAL '30 days';
  • для календарных дней приводите значения к date;
  • для стажа в годах используйте AGE, а не деление на 365;
  • в WHERE не оборачивайте индексируемую колонку в арифметику без необходимости.

Если коротко: даты в SQL любят точность. Сначала решите, что именно вы считаете — календарные дни, точное время, месяц по календарю или просто 30 суток. А потом выбирайте правильный тип и правильное выражение.

Закрепи на практике

Решай задачи в SQL-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр