Чтобы достать год и месяц из даты в 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.
Как получить год и месяц через EXTRACT

Возьмём таблицу заказов с датой оформления. Все запросы ниже выполнены в 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.
Как получить строку «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)
Где освоить PostgreSQL системно
EXTRACT и date_trunc закрывают большую часть отчётов по датам. Дальше обычно идут оконные функции для сравнения месяцев между собой и планы запросов. Если работаете с данными каждый день, посмотрите, как стать SQL-разработчиком, и сравните программы в каталоге курсов по PostgreSQL.
| Курс | Школа | Стоимость со скидкой | В рассрочку | Длительность | Обзор курса от Checkroi |
|---|---|---|---|---|---|
| Нейросети для рабочих задач Перейти на сайт курса | 31 290 ₽ | - | 1 месяц | Обзор курса | |
| SQL для анализа данных Перейти на сайт курса | 50 865 ₽ | 6203 ₽/мес. | 2 месяца | Обзор курса | |
| SQL-разработчик: тариф PRO Перейти на сайт курса | 89 900 ₽ | 7491 ₽/мес. | 2 месяца | Обзор курса | |
| SQL-разработчик: тариф Базовый Перейти на сайт курса | 79 900 ₽ | 3329 ₽/мес. | 3 месяца | Обзор курса | |
| Data-Driven подход в разработке продукта: SQL и Yandex DataLens Перейти на сайт курса | 29 990 ₽ | 2999 ₽/мес. | 1 месяц | Обзор курса |
Больше программ — в полном каталоге курсов по SQL




