Функция 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 не просто вычитает одну дату из другой в днях. Он считает по календарю:
- Сколько полных лет прошло.
- Сколько полных месяцев осталось после этих лет.
- Сколько дней осталось после этих месяцев.
Поэтому 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 ...).
Функция
AGEв PostgreSQL отвечает на очень человеческий вопрос:Не в сухих секундах и не просто в количестве дней, а по календарю: в годах, месяцах и днях.
Это удобно, когда нужно посчитать:
Например, если человек зарегистрировался почти три года назад, нам обычно хочется увидеть не
1065 days, а что-то вроде:Именно для таких задач подходит
AGE.Что делает AGE
AGEвозвращает значение типаinterval.interval— это промежуток времени. PostgreSQL может хранить в нём годы, месяцы, дни, часы, минуты и секунды.Например:
SELECT AGE(TIMESTAMP '2024-03-01', TIMESTAMP '2021-11-15') AS gap;Результат:
PostgreSQL не просто вычитает одну дату из другой в днях. Он считает по календарю:
Поэтому
AGEхорошо подходит для человекочитаемых сроков.Две формы AGE
У функции
AGEесть две основные формы.Первая форма — с двумя аргументами:
Она считает промежуток между двумя датами или моментами времени.
SELECT AGE(TIMESTAMP '2024-03-01', TIMESTAMP '2021-11-15') AS gap;Результат:
Читается так:
Важно не перепутать порядок аргументов.
Сначала идёт конец периода, потом начало:
Если поменять их местами, получится отрицательный интервал.
SELECT AGE(TIMESTAMP '2021-11-15', TIMESTAMP '2024-03-01') AS gap;Результат будет примерно таким:
PostgreSQL не считает это ошибкой. Он честно говорит: если идти от будущего к прошлому, интервал отрицательный.
AGE с одним аргументом
Вторая форма — с одним аргументом:
В этом случае PostgreSQL считает время от переданной даты до текущей даты.
SELECT AGE(TIMESTAMP '1990-06-17') AS since_birth;Такой запрос отвечает на вопрос:
Но здесь есть важная особенность: результат меняется каждый день. Сегодня возраст один, завтра станет на день больше.
Поэтому форма с одним аргументом удобна для живых отчётов, но не всегда подходит для воспроизводимых расчётов. Если вам нужен стабильный результат, лучше явно передать конечную дату вторым аргументом.
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;Результат:
Обычное вычитание дат возвращает количество дней. Это полезно, когда вам нужна точная арифметика в сутках.
А
AGEвозвращает календарный интервал.SELECT AGE(DATE '2024-03-01', DATE '2021-11-15') AS calendar_gap;Результат:
Разница простая:
AGEотвечает: «сколько прошло лет, месяцев и дней по календарю».Для возраста, стажа и давности аккаунта обычно удобнее
AGE.Для биллинга, SLA, таймеров и точных расчётов обычно лучше считать дни, часы или секунды.
Пример: возраст аккаунта пользователя
Допустим, у нас есть таблица пользователей. В поле
created_atхранится дата регистрации.Посчитаем, как давно существует каждый аккаунт.
SELECT id, email, AGE(created_at) AS account_age FROM users ORDER BY created_at;Результат может выглядеть так:
Это удобно для интерфейса администратора или аналитического отчёта. Сразу видно не просто дату регистрации, а возраст аккаунта в понятном виде.
Пример: давность заказа
То же самое можно сделать для заказов.
SELECT o.id, o.amount, AGE(o.created_at) AS order_age FROM orders o WHERE o.status = 'paid' ORDER BY o.created_at;Так мы увидим, сколько времени прошло с момента создания каждого оплаченного заказа.
Например, заказ может быть старым:
А может быть совсем свежим:
Фильтрация по возрасту записи
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';Запрос читается приятно:
Но для больших таблиц такой вариант может быть не самым быстрым.
Проблема в том, что мы применяем функцию к колонке:
Из-за этого обычный индекс по
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 не равны 30 дням
Главная особенность
AGE— он считает по календарю.А календарь неровный.
В январе 31 день. В феврале 28 или 29. В апреле 30. В июле снова 31.
Поэтому месяц в
AGE— это не фиксированные 30 дней. Это именно календарный месяц между конкретными датами.Посмотрим пример:
SELECT AGE(DATE '2024-03-31', DATE '2024-01-31') AS feb_gap;Результат:
С точки зрения календаря от 31 января до 31 марта прошло два месяца.
Но если считать в днях, внутри окажется не «два раза по 30». Там участвует февраль, а в 2024 году он високосный.
Вот почему
AGEудобен для человеческих сроков, но может удивить, если вы ждёте строгую математику в днях.AGE нормализует интервал
AGEстарается представить результат в привычном виде:Например:
SELECT AGE(DATE '2026-06-20', DATE '2024-03-15') AS gap;Результат будет не просто количеством дней, а календарным интервалом:
Это хорошо для чтения.
Но важно помнить: интервал с месяцами нельзя всегда безопасно переводить в дни без контекста. Один месяц может быть 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 месяцев, результат будет:
Это не округление. PostgreSQL просто достаёт поле лет из интервала.
То есть
EXTRACT(YEAR FROM AGE(...))возвращает количество полных лет.Для возраста это обычно как раз то, что нужно.
Ловушка: месяцы не превращаются в округление лет
Представьте интервал:
Если выполнить:
SELECT EXTRACT(YEAR FROM INTERVAL '2 years 11 months') AS years_part;Результат будет:
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, результат будет:Потому что:
Такой вариант полезен, когда нужно сгруппировать пользователей по количеству полных месяцев с регистрации.
Например:
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:Для таких задач лучше использовать разницу дат, разницу 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возвращаетinterval, например:Это удобно для возраста, стажа и человекочитаемой давности.
Но
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 ...).