sqlpostgresqldatesinterval

SQL Date Arithmetic: Adding Days, Subtracting Dates, and Intervals

How to add integers to dates, subtract dates into day counts, and add intervals to timestamps in PostgreSQL, MySQL and ClickHouse.

3 min čítaniaReferencesql · postgresql · dates · interval · timestamp
Tento článok je momentálne v ruštine — anglický preklad sa pripravuje.

Арифметика дат в SQL — это сложение и вычитание значений date и timestamp прямо оператором + и -: так считают срок оплаты счёта, стаж сотрудника и «давность» события. Правила зависят от типа: date ведёт себя не так, как timestamp, и понимание этой разницы избавляет от классических ошибок в подобных расчётах.

Главная ловушка здесь не в синтаксисе, а в том, какой именно тип вы складываете. Прибавление целого числа к date означает дни, а к timestamp целое число прибавить вообще нельзя — нужен interval. Вычитание двух date даёт целое число суток, а вычитание двух timestampinterval с часами и минутами. Поэтому один и тот же запрос ведёт себя по-разному в зависимости от типов столбцов, и проблемы вылезают на границах месяца, в високосный год и при переходах на летнее время. Дальше разберём каждый из этих случаев на конкретных выражениях.

Прибавляем дни к дате

Самый частый случай — добавить к date целое число. PostgreSQL трактует его как дни:

SELECT DATE '2024-03-01' + 7  AS next_week;   -- 2024-03-08
SELECT DATE '2024-03-01' - 30 AS month_ago;   -- 2024-01-31

Это удобно для расчёта срока оплаты: счёт обычно надо погасить через N дней после создания заказа.

SELECT id,
       created_at::date              AS placed_on,
       created_at::date + 14         AS due_date
FROM orders
WHERE status = 'pending';
  • К date целое прибавляется как дни.
  • К timestamp целое прибавить нельзя — будет ошибка типов, нужен interval.
  • Для «через месяц» не пишите + 30: месяцы разной длины, берите INTERVAL '1 month'.

Разница дат против разницы timestamp

Вот ключевая ловушка. Вычитание двух date даёт целое число дней (тип integer), а вычитание двух timestampinterval.

SELECT DATE '2024-03-01' - DATE '2024-01-15'             AS days;   -- 46 (integer)
SELECT TIMESTAMP '2024-03-01 09:00'
     - TIMESTAMP '2024-01-15 18:30'                       AS span;   -- 45 days 14:30:00

Из-за этого один и тот же запрос ведёт себя по-разному в зависимости от типов столбцов: для date вы получите число и сможете сравнивать его с 30, а для timestamp придётся работать с интервалом или приводить типы. Чтобы гарантированно получить целые сутки и не зависеть от часов и минут, приводите оба операнда к date:

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

Ловушка: NOW() - created_at над двумя timestamp вернёт interval вроде 45 days 14:30:00, и сравнение > 30 упадёт с ошибкой типа. Либо приводите к date, либо сравнивайте с интервалом: NOW() - created_at > INTERVAL '30 days'.

Интервалы для timestamp

К timestamp прибавляют не число, а interval. Он календарно-корректен: + INTERVAL '1 month' к 31 января даст 28 (или 29) февраля, а не «через 30 дней».

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;

Интервалы складываются и масштабируются, поэтому окно «за последние полгода» пишется компактно:

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

Стаж и возраст в днях против месяцев

Для стажа сотрудника часто нужны именно сутки — тогда вычитаем date. Для «человеческого» возраста в годах и месяцах берут AGE, который нормализует интервал по календарю.

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

Помните: 365 дней не всегда «год». Если нужны полные годы, считайте через AGE и EXTRACT(YEAR ...), а не делите дни на 365.

Различия в других СУБД

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

  • MySQL: вычитание дат само по себе не даёт дни. Используйте DATEDIFF(end, start) для суток и DATE_ADD(d, INTERVAL 7 DAY) / DATE_SUB для сдвига. Голый d + 7 даст не то, что вы ждёте.
  • ClickHouse: применяйте date + 7 (дни) и функции dateDiff('day', start, end), addDays, addMonths; единицу всегда задаёте явно.

Проблемы при переносе арифметики дат между PostgreSQL, MySQL и ClickHouse возникают не на типичных датах, а на краях: на конце месяца (31 января плюс INTERVAL '1 month'), в високосный год (29 февраля) и на сутках с переходом DST, где их 23 или 25 часов. Прежде чем полагаться на выражение в другом движке, прогоните именно эти пограничные даты: date + 7 в MySQL не даст ожидаемого сдвига, а dateDiff в ClickHouse требует явной единицы. На «чистых» датах середины месяца все три движка совпадают и расходятся как раз там, где тест слишком мягкий.

Ещё одно практическое замечание: фильтр вроде created_at + INTERVAL '30 days' < NOW() оборачивает арифметику вокруг колонки и мешает использовать индекс по created_at. Переносите сдвиг на сторону константы — created_at < NOW() - INTERVAL '30 days' — тогда сравнение остаётся sargable и план может взять индекс. На маленькой выборке разницы не видно, но на большой таблице это решает.

Если код должен быть переносимым, не полагайтесь на date - date: оборачивайте логику в DATEDIFF/dateDiff и используйте именованные интервалы.

Cvičte na reálnych úlohách

Riešte úlohy v SQL trénerovi s okamžitým hodnotením a nápovedami.

Otvoriť tréner