sqlpostgresqldatesinterval

SQL AGE: Date Differences as Calendar-Aware Intervals

PostgreSQL's AGE returns the gap between two timestamps as years, months and days instead of raw days.

4 min de lectureReferencesql · postgresql · dates · interval · age
Cet article est actuellement en russe — la traduction en anglais est en cours.

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

Возраст, стаж и срок жизни заказа звучат как простая разница дат, но AGE отвечает на них именно по-календарному: она прокручивает дату на целые годы и месяцы, а остаток выражает в днях. Поэтому «2 года 11 месяцев» она покажет как 2 years 11 mons, а не как абстрактные 1065 дней. У AGE две формы — с одним аргументом и с двумя, — и обе возвращают тип interval, который PostgreSQL умеет печатать, сравнивать с литералом и разбирать через EXTRACT. Ниже разберём обе формы, ловушку с порядком аргументов, календарную природу месяцев и то, как достать из интервала чистое число лет.

Два аргумента против одного

У AGE есть две формы. С двумя аргументами она считает интервал между двумя моментами, причём порядок — AGE(end_ts, start_ts), то есть «конец минус начало».

SELECT AGE(TIMESTAMP '2024-03-01', TIMESTAMP '2021-11-15') AS gap;
-- 2 years 3 mons 14 days

С одним аргументом точкой отсчёта неявно становится current_date (полночь сегодня), поэтому результат меняется день ото дня:

SELECT AGE(TIMESTAMP '1990-06-17') AS since_birth;
-- evaluated against midnight today

Сравните это с обычным вычитанием. Оператор - над двумя датами возвращает просто число дней — это полезно для арифметики, но не отвечает на вопрос «сколько лет и месяцев». Именно поэтому для человекочитаемого возраста почти всегда берут AGE, а сырое вычитание оставляют для расчётов, где важна точность в сутках.

SELECT DATE '2024-03-01' - DATE '2021-11-15' AS raw_days;  -- 837

Возраст пользователей и стаж заказов

Возьмём наши таблицы. Чтобы узнать, как давно зарегистрирован пользователь, передайте created_at одним аргументом:

SELECT id, email, AGE(created_at) AS account_age
FROM users
ORDER BY created_at;

«Давность» заказа считается так же. Удобно фильтровать по интервалу напрямую — PostgreSQL умеет сравнивать interval с литералом:

SELECT o.id, o.amount, AGE(o.created_at) AS order_age
FROM orders o
WHERE o.status = 'paid'
  AND AGE(o.created_at) > INTERVAL '90 days';

Для сотрудников AGE отлично описывает стаж, если в таблице есть дата найма (здесь подставлен created_at как пример точки отсчёта):

SELECT name, dept, AGE(NOW(), created_at) AS tenure
FROM employees
ORDER BY tenure DESC;

Почему месяцы зависят от календаря

Главная особенность interval, который возвращает AGE: месяцы считаются по календарю, а не как фиксированные 30 дней. PostgreSQL «прокручивает» дату сначала на целые годы, потом на целые месяцы, и только остаток выражает в днях.

SELECT AGE(DATE '2024-03-31', DATE '2024-01-31') AS feb_gap;
-- 2 mons  (not 60 days, not 59)

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

  • AGE всегда нормализует результат: годы, месяцы, дни.
  • Календарные месяцы не равны 30 дням — длина зависит от конкретных дат.
  • Для точного числа суток берите вычитание date - date или EXTRACT(EPOCH ...).

Извлекаем годы через EXTRACT

Интервал красиво печатается, но для сравнений и группировок нужно число. EXTRACT достаёт нужное поле из интервала:

SELECT id, email,
       EXTRACT(YEAR FROM AGE(created_at))::int AS full_years
FROM users;

Ловушка: EXTRACT(YEAR FROM AGE(...)) даёт только годы интервала и отбрасывает месяцы. Для пользователя со стажем «2 года 11 месяцев» вы получите 2, а не округление до 3. Если нужны полные годы — это именно то, что надо; если нужно общее число месяцев, считайте years * 12 + months.

SELECT id,
       EXTRACT(YEAR  FROM AGE(created_at)) * 12
     + EXTRACT(MONTH FROM AGE(created_at)) AS total_months
FROM users;

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

AGE — расширение PostgreSQL, прямого аналога в стандарте нет.

  • MySQL: используйте TIMESTAMPDIFF(YEAR, start, end) для целых лет или DATEDIFF для дней. Готового «годы-месяцы-дни» интервала нет.
  • ClickHouse: есть age('year', start, end) и dateDiff('day', ...); единицу всегда задаёте явно, символьного интервала тоже нет.

Главные сюрпризы AGE тоже календарные. День рождения 29 февраля в невисокосный год нормализуется к 1 марта, поэтому интервал для такого пользователя «прыгает» не так, как для остальных. Если в AGE(end, start) перепутать порядок аргументов, результат станет отрицательным интервалом (-2 years -3 mons ...), и фильтр вроде > INTERVAL '90 days' тихо перестанет срабатывать. А created_at типа timestamptz AGE приводит к текущему часовому поясу сессии — у клиента в другой зоне «сегодня» начинается в другой момент, и форма с одним аргументом может дать на сутки иной результат.

И помните про границу применимости: AGE строит удобочитаемый интервал, но не годится как точная длительность. Для биллинга по суткам, SLA в часах или сортировки по «настоящему» возрасту берите date - date или EXTRACT(EPOCH FROM ...), потому что один и тот же interval '1 mon' соответствует то 28, то 31 дню. Фильтр AGE(o.created_at) > INTERVAL '90 days' к тому же оборачивает колонку в функцию и закрывает путь к индексу по created_at — на больших таблицах быстрее условие вида o.created_at < NOW() - INTERVAL '90 days'.

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

Entraînez-vous sur de vrais exercices

Résolvez des exercices dans l'entraîneur SQL avec évaluation et indices instantanés.

Ouvrir l'entraîneur