Арифметика дат в 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;
Результат:
Можно и вычитать:
SELECT DATE '2024-03-01' - 30 AS month_ago;
Результат:
Здесь 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;
Результат:
Но если по бизнес-смыслу нужно «через один календарный месяц», это уже другая логика.
Используйте 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;
Результат:
Тип результата — целое число.
Это удобно, когда нужно посчитать возраст заявки в днях:
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;
Результат:
Здесь важны уже не только дни, но и часы с минутами.
И это логично: между двумя моментами времени может быть не ровное количество суток.
Поэтому такой запрос может сломаться:
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;
Результат:
Для прибавления дней используют DATE_ADD.
SELECT DATE_ADD('2024-03-01', INTERVAL 7 DAY) AS next_week;
Результат:
Для вычитания — DATE_SUB.
SELECT DATE_SUB('2024-03-01', INTERVAL 30 DAY) AS month_ago;
Результат:
Лучше не писать в 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;
Результат:
Для прибавления дней можно использовать addDays.
SELECT addDays(toDate('2024-03-01'), 7) AS next_week;
Результат:
Для месяцев — 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 — это когда мы прибавляем к дате несколько дней, вычитаем одну дату из другой, считаем срок оплаты, стаж сотрудника или давность события.
Звучит просто:
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— это только дата, без времени.Например:
timestamp— это дата и время.Например:
interval— это длительность.Например:
Именно
intervalнужен, когда мы хотим сказать: «прибавь к дате или времени не конкретную дату, а промежуток».Например:
SELECT TIMESTAMP '2024-03-01 14:30:00' + INTERVAL '7 days' AS reminder_at;Результат:
Как прибавить дни к date
Самый простой случай — прибавить целое число к
date.В PostgreSQL такое число считается количеством дней.
SELECT DATE '2024-03-01' + 7 AS next_week;Результат:
Можно и вычитать:
SELECT DATE '2024-03-01' - 30 AS month_ago;Результат:
Здесь PostgreSQL не думает про месяцы. Он просто отнимает 30 календарных дней.
Это удобно для задач вроде «срок оплаты через 14 дней после заказа».
SELECT id, created_at::date AS placed_on, created_at::date + 14 AS due_date FROM orders WHERE status = 'pending';Результат может быть таким:
Здесь
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;Результат:
Если нужно прибавить часы:
SELECT TIMESTAMP '2024-03-01 10:00:00' + INTERVAL '7 hours' AS result;Результат:
Если нужно прибавить 30 минут:
SELECT TIMESTAMP '2024-03-01 10:00:00' + INTERVAL '30 minutes' AS result;Результат:
Правило простое: для
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;Такие выражения читаются почти как обычный текст:
Интервал можно вычитать:
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;Результат:
Но если по бизнес-смыслу нужно «через один календарный месяц», это уже другая логика.
Используйте
INTERVAL '1 month':SELECT DATE '2024-01-31' + INTERVAL '1 month' AS result;Результат будет календарным сдвигом на месяц. На конце месяца PostgreSQL подберёт ближайшую корректную дату следующего месяца.
Например, в 2024 году февраль високосный:
DATE '2024-01-31' + INTERVAL '1 month'А в невисокосный год:
DATE '2023-01-31' + INTERVAL '1 month'Это важная разница.
+ 30означает ровно 30 дней.INTERVAL '1 month'означает один календарный месяц.Для скидки «действует 30 дней» подходит
+ 30.Для подписки «продлить на месяц» чаще подходит
INTERVAL '1 month'.Вычитание двух date
Если вычесть одну дату из другой, PostgreSQL вернёт количество дней.
SELECT DATE '2024-03-01' - DATE '2024-01-15' AS days;Результат:
Тип результата — целое число.
Это удобно, когда нужно посчитать возраст заявки в днях:
SELECT id, NOW()::date - created_at::date AS days_open FROM orders WHERE status = 'pending';Результат:
Такой результат можно спокойно сравнивать с числом:
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;Результат:
Здесь важны уже не только дни, но и часы с минутами.
И это логично: между двумя моментами времени может быть не ровное количество суток.
Поэтому такой запрос может сломаться:
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;Результат:
Но если нужно показать стаж по-человечески — в годах, месяцах и днях — лучше использовать
AGE.SELECT name, dept, AGE(NOW(), hired_at) AS tenure_human FROM employees;Результат может быть таким:
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 часа:
Поэтому выражения вроде «прибавить 1 день» и «прибавить 24 часа» в задачах с часовыми поясами могут иметь разный смысл.
SELECT created_at + INTERVAL '1 day' AS plus_one_day, created_at + INTERVAL '24 hours' AS plus_24_hours FROM events;На обычных датах разницы вы можете не заметить. Но на границах перехода времени она способна проявиться.
Практическое правило:
Крайние даты, которые нужно тестировать
Арифметика дат часто ломается не на обычных датах, а на краях.
Если вы пишете важную логику, проверьте такие случаи:
Например:
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;Результат:
Для прибавления дней используют
DATE_ADD.SELECT DATE_ADD('2024-03-01', INTERVAL 7 DAY) AS next_week;Результат:
Для вычитания —
DATE_SUB.SELECT DATE_SUB('2024-03-01', INTERVAL 30 DAY) AS month_ago;Результат:
Лучше не писать в 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;Результат:
Для прибавления дней можно использовать
addDays.SELECT addDays(toDate('2024-03-01'), 7) AS next_week;Результат:
Для месяцев —
addMonths.SELECT addMonths(toDate('2024-01-31'), 1) AS next_month;ClickHouse хорош тем, что функции часто заставляют вас явно назвать единицу: день, месяц, час и так далее. Это снижает риск случайной неоднозначности.
PostgreSQL, MySQL и ClickHouse: короткое сравнение
date_col + 7илиts_col + INTERVAL '7 days'DATE_ADD(date_col, INTERVAL 7 DAY)addDays(date_col, 7)end_date - start_dateDATEDIFF(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)created_at >= NOW() - INTERVAL '30 days'created_at >= NOW() - INTERVAL 30 DAYcreated_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 суток. А потом выбирайте правильный тип и правильное выражение.