Как извлечь год и месяц из даты в PostgreSQL без to_char()

EXTRACT(YEAR FROM дата) и EXTRACT(MONTH FROM дата) возвращают год и месяц числом, а date_trunc('month', дата) обрезает дату до первого числа месяца.

Показываем оба способа на запросах с выводом psql, собираем подпись «2025-03» без to_char и разбираем пять частых ошибок: от даты, записанной текстом, до месяца, сдвинутого часовым поясом.

Статью написал:
Ваня Буявец, продюсер, основатель Checkroi
Ваня Буявец
Основатель Checkroi, продюсер, эксперт в выборе онлайн-курсов
Все 3202 статьи автора Подписаться на Телеграм-канал
Одобрено экспертом:
Наташа Буявец, основатель Checkroi, эксперт по онлайн-курсам
Наташа Буявец
Основательница Checkroi, продюсер Youtube-каналов, эксперт по онлайн-курсам
Все 3856 экспертных мнений Подписаться на Телеграм-канал
Обложка: Как извлечь год и месяц из даты в PostgreSQL без to_char()

Чтобы достать год и месяц из даты в PostgreSQL, используйте функцию EXTRACT: EXTRACT(YEAR FROM created_at) вернёт 2025, а EXTRACT(MONTH FROM created_at) вернёт 3. Результат приходит числом, поэтому to_char() для этой задачи не нужен. Для группировки по месяцам подойдёт date_trunc('month', created_at)::date: функция обрезает дату до первого числа месяца и оставляет её датой.

Ниже оба способа с выводом из psql, сборка строки «2025-03» без to_char, фильтр по месяцу, который не отключает индекс, и пять ошибок с готовыми исправлениями. Если SQL пока в новинку, сначала прочитайте что такое язык SQL, а чем date отличается от timestamp, показывает таблица типов данных SQL.

CheckroiCheckroiПодборка курсов по PostgreSQL157 курсов • 27 школСравните цены, школы, программу и найдите выгодные предложения по обучениюСравнить→

Как получить год и месяц через EXTRACT

Рой разбирает даты заказов в таблице PostgreSQL

Возьмём таблицу заказов с датой оформления. Все запросы ниже выполнены в PostgreSQL 17 через psql, часовой пояс сессии Europe/Moscow. Запустить их можно в любом клиенте, например в DBeaver, подключённом к PostgreSQL.

CREATE TABLE orders (
  id         int PRIMARY KEY,
  created_at date,
  amount     int
);

INSERT INTO orders VALUES
  (1, '2025-01-14', 1200), (2, '2025-01-30', 800),
  (3, '2025-02-03', 1500), (4, '2025-03-15', 700),
  (5, '2025-03-28', 2100), (6, '2026-01-09', 950);

Синтаксис функции такой: EXTRACT(поле FROM значение). Поле пишется ключевым словом без кавычек:

  • YEAR: год;
  • MONTH: номер месяца от 1 до 12;
  • DAY: число месяца;
  • QUARTER: квартал от 1 до 4.
SELECT id,
       created_at,
       EXTRACT(YEAR FROM created_at)  AS year,
       EXTRACT(MONTH FROM created_at) AS month
FROM orders
ORDER BY id;

 id | created_at | year | month 
----+------------+------+-------
  1 | 2025-01-14 | 2025 |     1
  2 | 2025-01-30 | 2025 |     1
  3 | 2025-02-03 | 2025 |     2
  4 | 2025-03-15 | 2025 |     3
  5 | 2025-03-28 | 2025 |     3
  6 | 2026-01-09 | 2026 |     1
(6 rows)

Значением может быть столбец типа date, timestamp, timestamptz, time или interval. Полный список полей, от century до epoch, приведён в описании функции extract на русском. Про epoch, то есть секунды с 1970 года, подробно рассказано в статье о Unix timestamp.

Какой тип возвращает EXTRACT

С версии 14 функция возвращает numeric, а до неё возвращала double precision. Это изменение записано в примечаниях к выпуску PostgreSQL 14.

В выводе разницы не видно. Но если результат уходит в приложение или в сравнение с целым числом, приведите его явно через ::int. Так выглядит отчёт по месяцам с подсчётом заказов через функцию COUNT:

SELECT EXTRACT(YEAR FROM created_at)::int  AS year,
       EXTRACT(MONTH FROM created_at)::int AS month,
       COUNT(*)    AS orders,
       SUM(amount) AS revenue
FROM orders
GROUP BY 1, 2
ORDER BY 1, 2;

 year | month | orders | revenue 
------+-------+--------+---------
 2025 |     1 |      2 |    2000
 2025 |     2 |      1 |    1500
 2025 |     3 |      2 |    2800
 2026 |     1 |      1 |     950
(4 rows)

date_part: тот же результат, другой синтаксис

date_part('year', created_at) делает то же, что EXTRACT, только поле передаётся строкой в кавычках. В описании date_part сказано, что по историческим причинам функция date_part возвращает значения типа double precision, что в некоторых случаях может привести к потере точности, и вместо неё рекомендуется extract.

SELECT pg_typeof(EXTRACT(YEAR FROM DATE '2025-03-15'))   AS extract_type,
       pg_typeof(date_part('year', DATE '2025-03-15')) AS date_part_type;

 extract_type |  date_part_type  
--------------+------------------
 numeric      | double precision
(1 row)

В старом коде date_part встречается часто. Для года и месяца результат совпадает, так что рабочие запросы можно не трогать. В новых пишите EXTRACT, как советует справка PostgreSQL.

Как обрезать дату до месяца через date_trunc

Настольный календарь с отмеченными первыми числами месяцев

date_trunc('month', значение) сбрасывает всё, что мельче месяца: день становится первым, время полуночью. Результат остаётся датой со временем, поэтому по нему сразу можно группировать.

SELECT date_trunc('month', DATE '2025-03-15')            AS truncated,
       pg_typeof(date_trunc('month', DATE '2025-03-15')) AS type;

       truncated        |           type           
------------------------+--------------------------
 2025-03-01 00:00:00+03 | timestamp with time zone
(1 row)

Обратите внимание на +03 в конце. В разделе про date_trunc написано, что значения типов date и time автоматически приводятся к типам timestamp и interval. На деле для date PostgreSQL выбирает вариант функции с часовым поясом, поэтому результат имеет тип timestamp with time zone. Чтобы получить обычную дату, добавьте ::date.

SELECT date_trunc('month', created_at)::date AS month_start,
       COUNT(*)    AS orders,
       SUM(amount) AS revenue
FROM orders
GROUP BY 1
ORDER BY 1;

 month_start | orders | revenue 
-------------+--------+---------
 2025-01-01  |      2 |    2000
 2025-02-01  |      1 |    1500
 2025-03-01  |      2 |    2800
 2026-01-01  |      1 |     950
(4 rows)

У этого способа два плюса перед парой «год + месяц». В группировке один столбец вместо двух, а сортировка идёт по календарю, потому что сортируется дата, а не текст. Подробнее о направлениях сортировки написано в разборе ORDER BY в SQL.

Начало квартала, года и последний день месяца

Та же функция принимает quarter, year, week и другие единицы. Последний день месяца получается так: первое число плюс месяц минус день.

SELECT EXTRACT(QUARTER FROM DATE '2025-08-20')             AS quarter,
       date_trunc('quarter', DATE '2025-08-20')::date         AS quarter_start,
       date_trunc('year', DATE '2025-08-20')::date            AS year_start,
       (date_trunc('month', DATE '2024-02-10')
          + INTERVAL '1 month - 1 day')::date                 AS last_day_feb;

 quarter | quarter_start | year_start | last_day_feb 
---------+---------------+------------+--------------
       3 | 2025-07-01    | 2025-01-01 | 2024-02-29
(1 row)

Високосный февраль 2024 года обработан правильно: 29-е число, а не 28-е. Обратная задача, собрать дату из чисел, решается функцией make_date(2025, 3, 1). А если месяцы нужно разложить по сезонам, оберните номер месяца в выражение CASE.

Канал основателя Checkroi Вани Буявца3 800 человек читают мой Телеграм-канал про нейросетиСобрал промпты для Claude Code и ChatGPT, разборы ИИ-инструментов и лайфхаки по продвижению бизнеса в одном местеПрисоединиться

Как получить строку «2025-03» без to_char

Иногда нужна подпись «год-месяц» текстом. Склейте два EXTRACT оператором ||, а месяц дополните нулём слева через LPAD:

SELECT created_at,
       EXTRACT(YEAR FROM created_at) || '-' ||
       LPAD(EXTRACT(MONTH FROM created_at)::text, 2, '0') AS year_month
FROM orders
WHERE id IN (1, 4);

 created_at | year_month 
------------+------------
 2025-01-14 | 2025-01
 2025-03-15 | 2025-03
(2 rows)

Ноль слева влияет на сортировку. Без него строки встают в порядке «октябрь, ноябрь, сентябрь», потому что текст сравнивается по символам:

SELECT ym
FROM (VALUES ('2025-9'), ('2025-10'), ('2025-11')) AS t(ym)
ORDER BY ym;

   ym    
---------
 2025-10
 2025-11
 2025-9
(3 rows)

Для подписи в отчёте to_char() занимает одну строку. В справке прямо сказано, что функция extract в основном предназначена для вычислительных целей, а форматирование даты описано в разделе 9.8:

SELECT to_char(DATE '2025-03-15', 'YYYY-MM') AS year_month;

 year_month 
------------
 2025-03
(1 row)

Итого: для расчётов, фильтров и группировки берите EXTRACT или date_trunc, для подписей в интерфейсе подойдёт to_char.

Какую функцию выбрать под задачу

Задача Запрос Что вернёт
Год числом EXTRACT(YEAR FROM d) 2025
Номер месяца EXTRACT(MONTH FROM d) 3
Квартал EXTRACT(QUARTER FROM d) 1
Первое число месяца date_trunc('month', d)::date 2025-03-01
Первое января date_trunc('year', d)::date 2025-01-01
Номер недели по ISO EXTRACT(WEEK FROM d) в паре с ISOYEAR 11
День недели EXTRACT(ISODOW FROM d) 6 (суббота)
Подпись «год-месяц» to_char(d, 'YYYY-MM') 2025-03

Значения в таблице даны для даты 15 марта 2025 года. Как узнать день недели для любой даты без SQL, показано в статье про день недели по дате.

Как фильтровать по месяцу, чтобы работал индекс

Условие WHERE EXTRACT(MONTH FROM created_at) = 3 читается легко, но индекс по столбцу created_at с ним не работает. Проверим на таблице в 200 тысяч строк. Её можно создать запросом ниже или через мастер, как в инструкции по созданию таблицы в pgAdmin 4:

CREATE TABLE big_orders AS
SELECT g AS id,
       DATE '2020-01-01' + (g % 2190) AS created_at,
       g % 5000 AS amount
FROM generate_series(1, 200000) AS g;

CREATE INDEX big_orders_created_at_idx ON big_orders (created_at);
ANALYZE big_orders;
EXPLAIN (COSTS OFF)
SELECT COUNT(*) FROM big_orders
WHERE EXTRACT(YEAR FROM created_at) = 2025
  AND EXTRACT(MONTH FROM created_at) = 3;

                                                             QUERY PLAN                                                              
-------------------------------------------------------------------------------------------------------------------------------------
 Finalize Aggregate
   ->  Gather
         Workers Planned: 1
         ->  Partial Aggregate
               ->  Parallel Seq Scan on big_orders
                     Filter: ((EXTRACT(year FROM created_at) = '2025'::numeric) AND (EXTRACT(month FROM created_at) = '3'::numeric))
(6 rows)

В плане Seq Scan: PostgreSQL прочитал всю таблицу и для каждой строки посчитал EXTRACT. Тот же март, записанный диапазоном дат:

EXPLAIN (COSTS OFF)
SELECT COUNT(*) FROM big_orders
WHERE created_at >= DATE '2025-03-01'
  AND created_at <  DATE '2025-04-01';

                                              QUERY PLAN                                              
------------------------------------------------------------------------------------------------------
 Aggregate
   ->  Bitmap Heap Scan on big_orders
         Recheck Cond: ((created_at >= '2025-03-01'::date) AND (created_at < '2025-04-01'::date))
         ->  Bitmap Index Scan on big_orders_created_at_idx
               Index Cond: ((created_at >= '2025-03-01'::date) AND (created_at < '2025-04-01'::date))
(5 rows)

Здесь уже Bitmap Index Scan по индексу, и обе версии вернули одинаковые 2 821 строку. Решение: фильтруйте диапазоном «больше или равно первому числу и строго меньше первого числа следующего месяца». Если столбец хранит ещё и время, такой диапазон захватит заказы 31-го числа после полуночи, которые BETWEEN '2025-03-01' AND '2025-03-31' потеряет.

Второй путь подходит, когда запросы с EXTRACT менять нельзя. Тогда можно создать индекс по выражению, это описано в главе об индексах по выражениям. Для столбца date такой индекс создаётся, а для timestamptz PostgreSQL его не примет:

CREATE TABLE events (happened_at timestamptz);
CREATE INDEX ON events ((EXTRACT(MONTH FROM happened_at)));

ERROR:  functions in index expression must be marked IMMUTABLE

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

Частые ошибки и что с ними делать

Рой радуется, что запрос наконец выдал верный отчёт по месяцам

Сообщения ниже показаны в английской локали сервера. В русской сборке PostgreSQL текст будет переведён, а смысл останется тем же.

Поле в date_part без кавычек

SELECT date_part(year, DATE '2025-03-15');

ERROR:  column "year" does not exist
LINE 1: SELECT date_part(year, DATE '2025-03-15');
                         ^

PostgreSQL принял year за имя столбца. Решение: date_part('year', ...) с кавычками или EXTRACT(YEAR FROM ...) без них.

Дата хранится текстом

CREATE TABLE raw_orders (created_at text);
INSERT INTO raw_orders VALUES ('2025-03-15');
SELECT EXTRACT(YEAR FROM created_at) FROM raw_orders;

ERROR:  function pg_catalog.extract(unknown, text) does not exist
LINE 1: SELECT EXTRACT(YEAR FROM created_at) FROM raw_orders;
               ^
HINT:  No function matches the given name and argument types. You might need to add explicit type casts.

EXTRACT не работает со строками. Если текст записан как «2025-03-15», хватит приведения ::date. Если формат русский, «15.03.2025», разберите его через to_date с шаблоном:

SELECT EXTRACT(YEAR FROM created_at::date)                     AS iso_text,
       EXTRACT(YEAR FROM to_date('15.03.2025', 'DD.MM.YYYY'))    AS ru_text
FROM raw_orders;

 iso_text | ru_text 
----------+---------
     2025 |    2025
(1 row)

Пустые строки отсейте заранее: на ''::date PostgreSQL отвечает ошибкой invalid input syntax for type date. Как ведёт себя NULL в выражениях, разобрано в статье про NULL в SQL.

Час или минута из типа date

SELECT EXTRACT(HOUR FROM DATE '2025-03-15');

ERROR:  unit "hour" not supported for type date

В date нет времени, поэтому поля меньше суток из него не достать. Если время нужно, храните столбец как timestamp или timestamptz.

Месяц сдвинулся из-за часового пояса

Заказ оформлен 31 января в 22:30 по UTC. В Москве в этот момент уже 1 февраля, и EXTRACT у timestamptz считает месяц по часовому поясу сессии:

SELECT EXTRACT(MONTH FROM TIMESTAMPTZ '2025-01-31 22:30:00+00')                    AS month_msk,
       EXTRACT(MONTH FROM TIMESTAMPTZ '2025-01-31 22:30:00+00' AT TIME ZONE 'UTC') AS month_utc;

 month_msk | month_utc 
-----------+-----------
         2 |         1
(1 row)

Решение: явно укажите пояс отчёта через AT TIME ZONE, иначе цифры поменяются, если запрос запустить с сервера с другой настройкой.

Номер недели на стыке лет

По описанию поля week, недели ISO 8601 начинаются с понедельника, а первая неделя года включает 4 января. Поэтому 30 декабря 2024 года попадает в первую неделю 2025-го:

SELECT d,
       EXTRACT(YEAR FROM d)    AS year,
       EXTRACT(WEEK FROM d)    AS week,
       EXTRACT(ISOYEAR FROM d) AS isoyear
FROM (VALUES (DATE '2024-12-30'), (DATE '2027-01-01')) AS t(d);

     d      | year | week | isoyear 
------------+------+------+---------
 2024-12-30 | 2024 |    1 |    2025
 2027-01-01 | 2027 |   53 |    2026
(2 rows)

Если группируете по неделям, берите ISOYEAR вместе с WEEK, а не YEAR. С днём недели похожая ловушка: DOW считает воскресенье нулём, ISODOW семёркой.

SELECT EXTRACT(DOW FROM DATE '2025-03-16')    AS dow,
       EXTRACT(ISODOW FROM DATE '2025-03-16') AS isodow;

 dow | isodow 
-----+--------
   0 |      7
(1 row)
Ваня БуявецКанал основателя Checkroi Вани БуявцаЗабирайте промпты и обучение по нейросетям в моём Телеграм-каналеБольше 3 800 человек уже применяют Claude Code, ChatGPT и другие нейросети в работе, учёбе, бизнесе и жизниПерейти в канал

Где освоить PostgreSQL системно

EXTRACT и date_trunc закрывают большую часть отчётов по датам. Дальше обычно идут оконные функции для сравнения месяцев между собой и планы запросов. Если работаете с данными каждый день, посмотрите, как стать SQL-разработчиком, и сравните программы в каталоге курсов по PostgreSQL.

КурсШколаСтоимость со скидкойВ рассрочкуДлитель­ностьОбзор курса от Checkroi
Нейросети для рабочих задач
Перейти на сайт курса
SkillboxSkillbox31 290 ₽ - 1 месяцОбзор курса
SQL для анализа данных
Перейти на сайт курса
SkillboxSkillbox50 865 ₽6203 ₽/мес.2 месяцаОбзор курса
SQL-разработчик: тариф PRO
Перейти на сайт курса
Академия ЭдюсонЭдюсон89 900 ₽7491 ₽/мес.2 месяцаОбзор курса
SQL-разработчик: тариф Базовый
Перейти на сайт курса
Академия ЭдюсонЭдюсон79 900 ₽3329 ₽/мес.3 месяцаОбзор курса
Data-Driven подход в разработке продукта: SQL и Yandex DataLens
Перейти на сайт курса
ProductStarProductStar29 990 ₽2999 ₽/мес.1 месяцОбзор курса

Больше программ — в полном каталоге курсов по SQL

Часто задаваемые вопросы

Как получить год из даты в PostgreSQL?

Используйте EXTRACT(YEAR FROM столбец), для даты 15 марта 2025 года функция вернёт 2025. С версии PostgreSQL 14 результат имеет тип numeric, поэтому для сравнения с целым числом добавьте ::int.

Чем EXTRACT отличается от date_part?

Результат у них одинаковый, разница в синтаксисе и типе. В date_part поле передаётся строкой в кавычках, а возвращается double precision, поэтому справка PostgreSQL рекомендует extract.

Как получить первый день месяца в PostgreSQL?

Запрос date_trunc('month', столбец)::date вернёт первое число того же месяца, например 2025-03-01. Без ::date результат придёт с временем и часовым поясом.

Как узнать текущий месяц в PostgreSQL?

Передайте в EXTRACT текущую дату: EXTRACT(MONTH FROM current_date). Для начала текущего месяца подойдёт date_trunc('month', current_date)::date.

Чем date_trunc отличается от EXTRACT?

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

Читайте Checkroi первым в Google
Добавьте Checkroi в избранные источники — и наши разборы курсов и обзоры школ будут показываться выше в вашей выдаче Google.
Оставить комментарий
0 комментариев
Форма комментария

Оставьте комментарий

Напишите, что думаете. Нам важно ваше мнение!