Šis straipsnis šiuo metu yra rusų kalba — vertimas į anglų kalbą rengiamas.
Арифметика дат в SQL — это сложение и вычитание значений date и timestamp прямо оператором + и -: так считают срок оплаты счёта, стаж сотрудника и «давность» события. Правила зависят от типа: date ведёт себя не так, как timestamp, и понимание этой разницы избавляет от классических ошибок в подобных расчётах.
Главная ловушка здесь не в синтаксисе, а в том, какой именно тип вы складываете. Прибавление целого числа к date означает дни, а к timestamp целое число прибавить вообще нельзя — нужен interval. Вычитание двух date даёт целое число суток, а вычитание двух timestamp — interval с часами и минутами. Поэтому один и тот же запрос ведёт себя по-разному в зависимости от типов столбцов, и проблемы вылезают на границах месяца, в високосный год и при переходах на летнее время. Дальше разберём каждый из этих случаев на конкретных выражениях.
Прибавляем дни к дате
Самый частый случай — добавить к date целое число. PostgreSQL трактует его как дни:
SELECT DATE '2024-03-01' + 7 AS next_week;
SELECT DATE '2024-03-01' - 30 AS month_ago;
Это удобно для расчёта срока оплаты: счёт обычно надо погасить через 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), а вычитание двух timestamp — interval.
SELECT DATE '2024-03-01' - DATE '2024-01-15' AS days;
SELECT TIMESTAMP '2024-03-01 09:00'
- TIMESTAMP '2024-01-15 18:30' AS span;
Из-за этого один и тот же запрос ведёт себя по-разному в зависимости от типов столбцов: для 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 и используйте именованные интервалы.
Арифметика дат в SQL — это сложение и вычитание значений
dateиtimestampпрямо оператором+и-: так считают срок оплаты счёта, стаж сотрудника и «давность» события. Правила зависят от типа:dateведёт себя не так, какtimestamp, и понимание этой разницы избавляет от классических ошибок в подобных расчётах.Главная ловушка здесь не в синтаксисе, а в том, какой именно тип вы складываете. Прибавление целого числа к
dateозначает дни, а кtimestampцелое число прибавить вообще нельзя — нуженinterval. Вычитание двухdateдаёт целое число суток, а вычитание двухtimestamp—intervalс часами и минутами. Поэтому один и тот же запрос ведёт себя по-разному в зависимости от типов столбцов, и проблемы вылезают на границах месяца, в високосный год и при переходах на летнее время. Дальше разберём каждый из этих случаев на конкретных выражениях.Прибавляем дни к дате
Самый частый случай — добавить к
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), а вычитание двухtimestamp—interval.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';Интервалы для 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.Различия в других СУБД
Синтаксис заметно расходится между движками:
DATEDIFF(end, start)для суток иDATE_ADD(d, INTERVAL 7 DAY)/DATE_SUBдля сдвига. Голыйd + 7даст не то, что вы ждёте.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и используйте именованные интервалы.