sqlpostgresqldatesinterval

AGE в PostgreSQL: как считать возраст, стаж и давность по календарю

AGE возвращает разницу как годы, месяцы и дни, поэтому подходит для возраста и стажа, но не заменяет подсчет точных суток.

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

Функция AGE в PostgreSQL отвечает на очень человеческий вопрос:

Сколько времени прошло?

Не в сухих секундах и не просто в количестве дней, а по календарю: в годах, месяцах и днях.

Это удобно, когда нужно посчитать:

  • возраст пользователя;
  • стаж сотрудника;
  • срок жизни аккаунта;
  • давность заказа;
  • сколько времени прошло между двумя датами.

Например, если человек зарегистрировался почти три года назад, нам обычно хочется увидеть не 1065 days, а что-то вроде:

2 years 11 mons

Именно для таких задач подходит AGE.

Что делает AGE

AGE возвращает значение типа interval.

interval — это промежуток времени. PostgreSQL может хранить в нём годы, месяцы, дни, часы, минуты и секунды.

Например:

SELECT AGE(TIMESTAMP '2024-03-01', TIMESTAMP '2021-11-15') AS gap;

Результат:

2 years 3 mons 14 days

PostgreSQL не просто вычитает одну дату из другой в днях. Он считает по календарю:

  1. Сколько полных лет прошло.
  2. Сколько полных месяцев осталось после этих лет.
  3. Сколько дней осталось после этих месяцев.

Поэтому AGE хорошо подходит для человекочитаемых сроков.

Две формы AGE

У функции AGE есть две основные формы.

Первая форма — с двумя аргументами:

AGE(end_ts, start_ts)

Она считает промежуток между двумя датами или моментами времени.

SELECT AGE(TIMESTAMP '2024-03-01', TIMESTAMP '2021-11-15') AS gap;

Результат:

2 years 3 mons 14 days

Читается так:

Сколько прошло от 2021-11-15 до 2024-03-01.

Важно не перепутать порядок аргументов.

Сначала идёт конец периода, потом начало:

AGE(end_ts, start_ts)

Если поменять их местами, получится отрицательный интервал.

SELECT AGE(TIMESTAMP '2021-11-15', TIMESTAMP '2024-03-01') AS gap;

Результат будет примерно таким:

-2 years -3 mons -14 days

PostgreSQL не считает это ошибкой. Он честно говорит: если идти от будущего к прошлому, интервал отрицательный.

AGE с одним аргументом

Вторая форма — с одним аргументом:

AGE(start_ts)

В этом случае PostgreSQL считает время от переданной даты до текущей даты.

SELECT AGE(TIMESTAMP '1990-06-17') AS since_birth;

Такой запрос отвечает на вопрос:

Сколько прошло с 1990-06-17 до сегодняшнего дня?

Но здесь есть важная особенность: результат меняется каждый день. Сегодня возраст один, завтра станет на день больше.

Поэтому форма с одним аргументом удобна для живых отчётов, но не всегда подходит для воспроизводимых расчётов. Если вам нужен стабильный результат, лучше явно передать конечную дату вторым аргументом.

SELECT AGE(DATE '2026-06-17', DATE '1990-06-17') AS exact_age;

Так запрос всегда будет возвращать один и тот же результат, потому что обе даты зафиксированы.

AGE и обычное вычитание дат

Новички часто спрашивают: зачем нужен AGE, если даты можно просто вычитать?

Например:

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

Результат:

837

Обычное вычитание дат возвращает количество дней. Это полезно, когда вам нужна точная арифметика в сутках.

А AGE возвращает календарный интервал.

SELECT AGE(DATE '2024-03-01', DATE '2021-11-15') AS calendar_gap;

Результат:

2 years 3 mons 14 days

Разница простая:

  • вычитание дат отвечает: «сколько суток прошло»;
  • AGE отвечает: «сколько прошло лет, месяцев и дней по календарю».

Для возраста, стажа и давности аккаунта обычно удобнее AGE.

Для биллинга, SLA, таймеров и точных расчётов обычно лучше считать дни, часы или секунды.

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

Допустим, у нас есть таблица пользователей. В поле created_at хранится дата регистрации.

Посчитаем, как давно существует каждый аккаунт.

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

Результат может выглядеть так:

3 years 2 mons 5 days
1 year 8 mons 12 days
4 mons 20 days

Это удобно для интерфейса администратора или аналитического отчёта. Сразу видно не просто дату регистрации, а возраст аккаунта в понятном виде.

Пример: давность заказа

То же самое можно сделать для заказов.

SELECT
    o.id,
    o.amount,
    AGE(o.created_at) AS order_age
FROM orders o
WHERE o.status = 'paid'
ORDER BY o.created_at;

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

Например, заказ может быть старым:

1 year 2 mons 9 days

А может быть совсем свежим:

3 days

Фильтрация по возрасту записи

PostgreSQL умеет сравнивать interval с другим interval.

Например, можно найти оплаченные заказы старше 90 дней.

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';

Запрос читается приятно:

Покажи оплаченные заказы, возраст которых больше 90 дней.

Но для больших таблиц такой вариант может быть не самым быстрым.

Проблема в том, что мы применяем функцию к колонке:

AGE(o.created_at)

Из-за этого обычный индекс по created_at может использоваться хуже.

Чаще для производительности лучше переписать условие так:

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

Смысл тот же: заказ создан раньше, чем 90 дней назад.

Но колонка created_at остаётся в условии без оборачивания в функцию, и базе проще использовать индекс.

Пример: стаж сотрудника

Для сотрудников AGE хорошо подходит для расчёта стажа.

Допустим, в таблице employees есть дата найма hired_at.

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

Если отдельного поля hired_at нет, в учебных примерах иногда используют created_at как дату появления сотрудника в системе.

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

Здесь мы явно передали NOW() первым аргументом. Получается:

AGE(NOW(), created_at)

То есть:

Сколько прошло от created_at до текущего момента.

Почему месяцы в AGE не равны 30 дням

Главная особенность AGE — он считает по календарю.

А календарь неровный.

В январе 31 день. В феврале 28 или 29. В апреле 30. В июле снова 31.

Поэтому месяц в AGE — это не фиксированные 30 дней. Это именно календарный месяц между конкретными датами.

Посмотрим пример:

SELECT AGE(DATE '2024-03-31', DATE '2024-01-31') AS feb_gap;

Результат:

2 mons

С точки зрения календаря от 31 января до 31 марта прошло два месяца.

Но если считать в днях, внутри окажется не «два раза по 30». Там участвует февраль, а в 2024 году он високосный.

Вот почему AGE удобен для человеческих сроков, но может удивить, если вы ждёте строгую математику в днях.

AGE нормализует интервал

AGE старается представить результат в привычном виде:

years
mons
days

Например:

SELECT AGE(DATE '2026-06-20', DATE '2024-03-15') AS gap;

Результат будет не просто количеством дней, а календарным интервалом:

2 years 3 mons 5 days

Это хорошо для чтения.

Но важно помнить: интервал с месяцами нельзя всегда безопасно переводить в дни без контекста. Один месяц может быть 28, 29, 30 или 31 день.

Если вам нужна точная длительность, лучше использовать другой подход.

Для дней:

SELECT DATE '2026-06-20' - DATE '2024-03-15' AS days_count;

Для секунд между двумя моментами:

SELECT EXTRACT(EPOCH FROM TIMESTAMP '2026-06-20 12:00:00' - TIMESTAMP '2024-03-15 12:00:00') AS seconds_count;

Как достать из AGE количество полных лет

Интервал красиво выглядит в отчёте, но иногда нужно получить число.

Например, возраст пользователя в полных годах.

Для этого используют EXTRACT.

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

Если пользователю 34 года и 11 месяцев, результат будет:

34

Это не округление. PostgreSQL просто достаёт поле лет из интервала.

То есть EXTRACT(YEAR FROM AGE(...)) возвращает количество полных лет.

Для возраста это обычно как раз то, что нужно.

Ловушка: месяцы не превращаются в округление лет

Представьте интервал:

2 years 11 mons

Если выполнить:

SELECT EXTRACT(YEAR FROM INTERVAL '2 years 11 months') AS years_part;

Результат будет:

2

PostgreSQL не округляет 2 years 11 mons до 3.

Он просто берёт часть YEAR.

Это удобно, когда нужны полные годы. Но если вы хотели получить примерное число лет с дробной частью, нужно считать отдельно.

Как получить общее количество месяцев

Если вам нужно не поле месяцев, а общее количество полных месяцев, нужно сложить годы и месяцы вручную.

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

Если аккаунту 2 years 11 mons, результат будет:

35

Потому что:

2 * 12 + 11 = 35

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

Например:

SELECT
    (
        EXTRACT(YEAR FROM AGE(created_at)) * 12
      + EXTRACT(MONTH FROM AGE(created_at))
    )::int AS account_months,
    COUNT(*) AS users_count
FROM users
GROUP BY account_months
ORDER BY account_months;

День рождения 29 февраля

У календарных расчётов есть неприятные края. Один из них — 29 февраля.

Если человек родился 29 февраля, то в невисокосный год такой даты нет. Поэтому поведение возраста рядом с этой датой может отличаться от интуитивного ожидания.

Например, стоит отдельно проверить такие случаи:

SELECT
    AGE(DATE '2025-02-28', DATE '2000-02-29') AS on_feb_28,
    AGE(DATE '2025-03-01', DATE '2000-02-29') AS on_mar_01;

Для обычных пользователей это редкий случай, но для серьёзных систем лучше не надеяться на глаз. Если возраст влияет на доступ к сервису, юридические ограничения, скидки или документы, такие даты нужно покрывать тестами.

AGE и timestamp with time zone

Если вы работаете с timestamptz, помните про часовой пояс сессии.

Форма с одним аргументом сравнивает значение с текущей датой или текущим моментом в контексте сессии. Для пользователей в разных часовых поясах «сегодня» может начинаться в разное время.

Например, когда в одном часовом поясе уже наступило новое число, в другом ещё вчерашний вечер.

Поэтому для пользовательского возраста и локальных дат лучше явно фиксировать, в какой зоне вы считаете дату.

Например, можно сначала получить локальную дату пользователя, а потом считать от неё.

SELECT
    id,
    email,
    AGE(
        (NOW() AT TIME ZONE user_tz)::date,
        birth_date
    ) AS age_in_user_zone
FROM users;

Здесь идея такая:

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

Это особенно важно, если от результата зависит не просто текст в профиле, а право выполнить действие.

Когда AGE подходит хорошо

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

Хорошие задачи для AGE:

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

Пример:

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

Такой результат приятно читать человеку.

Когда AGE лучше не использовать

AGE не стоит использовать как точную длительность для технических расчётов.

Плохие задачи для AGE:

  • биллинг по точному количеству суток;
  • SLA в часах;
  • таймауты;
  • длительность сессии в секундах;
  • точная сортировка по времени жизни;
  • расчёт штрафов за просрочку в часах.

Для таких задач лучше использовать разницу дат, разницу timestamp или EXTRACT(EPOCH FROM ...).

Например, для количества секунд:

SELECT
    EXTRACT(EPOCH FROM finished_at - started_at) AS duration_seconds
FROM sessions;

Для количества дней:

SELECT
    finished_at::date - started_at::date AS duration_days
FROM tasks;

Здесь результат будет технически точным и предсказуемым.

Как сделать запрос понятнее

Когда в запросе есть AGE, называйте результат по смыслу.

Не очень хорошо:

SELECT
    id,
    AGE(created_at) AS age
FROM orders;

Для заказа слово age может быть непонятным. Возраст чего именно?

Лучше:

SELECT
    id,
    AGE(created_at) AS order_age
FROM orders;

Для пользователя:

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

Для сотрудника:

SELECT
    id,
    AGE(hired_at) AS tenure
FROM employees;

Чем понятнее имя колонки, тем меньше шансов, что следующий человек прочитает запрос неправильно.

AGE в MySQL

AGE — это функция PostgreSQL. В стандарте SQL прямого аналога нет, и в других СУБД всё устроено иначе.

В MySQL для целых лет обычно используют TIMESTAMPDIFF.

SELECT TIMESTAMPDIFF(YEAR, birth_date, CURDATE()) AS full_years
FROM users;

Для дней есть DATEDIFF.

SELECT DATEDIFF(CURDATE(), created_at) AS days_count
FROM users;

Но готового красивого интервала в стиле 2 years 3 mons 14 days в MySQL из коробки нет. Его приходится собирать вручную или форматировать на стороне приложения.

AGE в ClickHouse

В ClickHouse тоже нет полного аналога PostgreSQL AGE в виде одного календарного интервала с годами, месяцами и днями.

Обычно там явно указывают единицу измерения.

Например, для лет:

SELECT age('year', birth_date, today()) AS full_years
FROM users;

Для дней часто используют dateDiff.

SELECT dateDiff('day', created_at, today()) AS days_count
FROM users;

Главное отличие: в PostgreSQL AGE возвращает интервал, а в ClickHouse и MySQL чаще сразу считают конкретную единицу: годы, месяцы, дни или секунды.

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

Первая ошибка — перепутать порядок аргументов.

Правильно:

SELECT AGE(DATE '2024-03-01', DATE '2021-11-15') AS gap;

Сначала конец, потом начало.

Вторая ошибка — использовать AGE для точных длительностей. Для красивого возраста — да. Для SLA в секундах — нет.

Третья ошибка — забыть, что форма с одним аргументом меняется со временем.

SELECT AGE(created_at) AS account_age
FROM users;

Сегодня результат один, завтра другой. Для отчётов это нормально, для воспроизводимых расчётов — не всегда.

Четвёртая ошибка — ожидать, что месяц всегда равен 30 дням. В AGE месяц календарный, а не фиксированный.

Пятая ошибка — фильтровать большие таблицы через AGE(column), хотя можно сравнить саму колонку с границей.

Вместо этого:

WHERE AGE(created_at) > INTERVAL '90 days'

часто лучше так:

WHERE created_at < NOW() - INTERVAL '90 days'

Смысл похожий, но второй вариант обычно дружелюбнее к индексу.

Главное из статьи

AGE в PostgreSQL считает календарный интервал между датами или моментами времени.

У функции есть две формы:

AGE(end_ts, start_ts)

и

AGE(start_ts)

Форма с двумя аргументами считает промежуток между началом и концом. Порядок важен: сначала конец, потом начало.

Форма с одним аргументом считает время от переданной даты до текущей даты, поэтому результат меняется каждый день.

AGE возвращает interval, например:

2 years 3 mons 14 days

Это удобно для возраста, стажа и человекочитаемой давности.

Но AGE считает по календарю: месяцы не равны 30 дням, февраль короче июля, а високосный год добавляет отдельные сюрпризы.

Чтобы получить полные годы, используйте:

SELECT EXTRACT(YEAR FROM AGE(birth_date))::int AS full_years
FROM users;

Чтобы получить общее количество месяцев, сложите годы и месяцы:

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

Используйте AGE, когда нужен понятный календарный срок. А для точных расчётов в днях, часах и секундах лучше брать разницу дат или EXTRACT(EPOCH FROM ...).

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

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

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