Анализ данных • 23 сентября 2025 • 5 мин чтения

Как работать с датами и временем в SQL (на примере PostgreSQL)

Рассказываем, как избежать ошибок при работе с датой и временем и какие форматы данных можно использовать в основных операциях.

Что такое время в данных и как его записывать

Время в данных — это не просто «фильтр по дате». Это календарные правила, часовые пояса, особенности недель и переходов на летнее время, индексы и пр. Если их неправильно записывать, могут возникать неочевидные ошибки. Например, отчёт за «вчера» может незаметно начать учитывать 23-часовой или 25-часовой день, а за «январь» — включать события из другого месяца. В SQL есть три разные сущности, которые часто путают:

  1. Дата — календарь (без часов).
  2. Дата-время — мгновение, или «стеночное» время.
  3. Длительность — интервал.

Время можно записывать по-разному в зависимости от вида события. Виды делят на событие в моменте и локальное время. Расскажем об этом подробнее.

  • Момент (instant)— событие, которое происходит в конкретной точке времени. Например, при транзакции в магазине важно точное время.

Для момента можно использовать два подхода:

  1. Хранить в формате с часовым поясом (UTC) и конвертировать в нужный пояс при показе.
  2. Перевод в универсальный часовой пояс, например московский или по Гринвичу. 
  • Локальное время— это время без пояса, которое не «прыгает» при конвертациях. Например, расписания самолётов или поездов составляются по местному времени: «каждый день в 09:00». Ещё локальное время называется «стеночным» — по аналогии с настенными часами. 

В PostgreSQL момент и локальное время записывают как timestamptz и timestamp:

  • Timestamptz— это момент во времени. При записи Postgres переводит его в UTC и не запоминает название пояса; при выводе преобразует в пояс текущей сессии (SHOW TIME ZONE).
  • Timestamp— время без пояса (настенные часы). При записи Postgres ничего не конвертирует.

Работать с данными и аналитикой на языке SQL учат на курсе «SQL для работы с данными и аналитики». За 3 месяца студенты осваивают навыки работы с PostgreSQL, чтобы применять их в других СУБД. Курс подходит для опытных специалистов и новичков в аналитике, а также для специалистов смежных профессий, например маркетологов и финансистов.

Форматы данных даты и времени на примере PostgreSQL

Типы данных времени на примере PostgreSQL

Основные операции и фильтрация по датам

Операций и фильтраций по датам может быть много. Мы расскажем про основные шесть.

1. Нужно посчитать количество заказов. Допустим, нужно посчитать, сколько было заказов с 1 по 30 сентября:

SELECT COUNT(*) AS orders_sep
FROM orders
WHERE created_at >= TIMESTAMP '2025-09-01 00:00:00'
  AND created_at < TIMESTAMP '2025-10-01 00:00:00';

Смотрим количество строчек с помощью COUNT(*), добавляем фильтрацию в WHERE с помощью диапазона дат.

2. Когда нужно перевести дату в одном часовом поясе:

SET TIME ZONE 'UTC';
SELECT TIMESTAMPTZ '2025-06-01 00:30+03'::date; -- 2025-05-31
SET TIME ZONE 'Europe/Moscow';
SELECT TIMESTAMPTZ '2025-06-01 00:30+03'::date; -- 2025-06-01

Сначала устанавливаем время сессии в UTC. При попытке перевести дату и время с часовым поясом +3 только в формат даты получаем 2025-05-31, так как изначально «0:30 в часовом поясе +3». Но если мы находимся в часовом поясе Москвы, то получаем дату «2025-06-01». Чтобы получить стабильную дату в UTC, можно использовать:

SELECT (created_at AT TIME ZONE 'UTC')::date -- стабильная UTC-дата

Когда нужно «усечь» дату. Существует удобная функция — date_trunc (). Она позволяет «усечь» до определённого момента. Например, при усечении до дня из «2025-06-01 7:30» получим «2025-06-01 0:00».

Если использовать date_trunc ('day', timestamptz), то может показаться, что усечение происходит по UTC. Но это не так. Границы «00:00» берутся в текущем TIME ZONE.

timestamptz — это момент. Когда мы «усекаем до дня», Postgres берёт полночь в текущем часовом поясе сеанса и возвращает момент, соответствующий этой локальной полуночи. Если поменять TIME ZONE, изменится и результат.

-- Исходный момент: 2025-06-01 00:30 в Москве (UTC+3)
-- Это тот же момент, что 2025-05-31 21:30 UTC

SET TIME ZONE 'UTC';
SELECT
     TIMESTAMPTZ '2025-06-01 00:30+03'                                 AS src_utc,
     date_trunc('day', TIMESTAMPTZ '2025-06-01 00:30+03')  AS bucket_utc;
-- Результат:
-- src_utc    = 2025-05-31 21:30:00+00
-- bucket_utc = 2025-05-31 00:00:00+00 ← полночь UTC для «того же дня» в UTC


SET TIME ZONE 'Europe/Moscow';
SELECT
     TIMESTAMPTZ '2025-06-01 00:30+03'                                 AS src_msk,
     date_trunc('day', TIMESTAMPTZ '2025-06-01 00:30+03')  AS bucket_msk;
-- src_msk    = 2025-06-01 00:30:00+03
-- bucket_msk = 2025-06-01 00:00:00+03 ← полночь в Москве (это 2025-05-31 21:00 UTC)

bucket_utc и bucket_msk — это разные точки времени (бакеты). Если нужны дни по UTC независимо от настроек сеанса, явно переводите во временную зону UTC в запросе:

-- Бакет как "просто дата" в UTC:
SELECT date_trunc('day', (created_at AT TIME ZONE 'UTC')) AS bucket_utc_date;

-- Или как точный момент "полночь UTC" (возвращаем обратно timestamptz):
SELECT (date_trunc('day', (created_at AT TIME ZONE 'UTC')) AT TIME ZONE 'UTC') AS bucket_utc_tstz;

3. Когда нужно, чтобы 1 января не считалось прошлым годом. Например, неделя по ISO начинается в понедельник, «первая неделя» — та, где есть четверг января. Поэтому 1 января может относиться к неделе прошлого года.

SELECT EXTRACT(week FROM created_at) AS week -- может "увезти" 1 января

Чтобы корректно и прозрачно сделать запрос, можно использовать ISO:

SELECT EXTRACT(isoyear FROM created_at) AS iso_year,
               EXTRACT(isoweek FROM created_at) AS iso_week

ISO 8601 — международный стандарт форматов даты/времени. Для недель он задаёт следующее:
● неделя начинается в понедельник;
● первая неделя года — та, в которой есть четверг (или (эквивалентно) которая содержит 4 января);
● часть дат в начале/конце календарного года может относиться к ISO-году соседнего года.

В PostgreSQL:
● EXTRACT (isoweek FROM ts) — номер ISO-недели (1–53);
● EXTRACT (isoyear FROM ts) — ISO-год (может отличаться от календарного);
● EXTRACT (isodow FROM ts) — день недели (понедельник = 1, воскресенье = 7).

Пример в коде:

SELECT
     DATE '2016-01-01'                           AS d1,   -- Пт
      style="color: #fcc28c;">EXTRACT(isoweek FROM DATE '2016-01-01') AS iso_week1,   -- 53
      EXTRACT(isoyear FROM DATE '2016-01-01')        AS iso_year1;                                         -- 2015

SELECT
     DATE '2017-01-01'                           AS d2,    -- Вс
     EXTRACT
(isoweek FROM DATE '2017-01-01')      AS iso_week2,   -- 52
     EXTRACT(isoyear FROM DATE '2017-01-01')        AS iso_year2;    -- 2016

4. Как нужно посчитать, сколько прошло секунд:

SELECT now() - started_at -- interval, неудобно

Получился тип interval, что не всегда удобно для дальнейшего использования. В результате такого запроса получилось чистое число секунд:

SELECT EXTRACT(EPOCH FROM (now() - started_at)) AS seconds

● EPOCH — это количество секунд с начала Unix-эпохи (1970-01-01 00:00:00+00).
● В PostgreSQL EXTRACT (EPOCH FROM…) всегда возвращает число в секундах (тип double precision).
● Для интервалов (как в нашем запросе) это просто «сколько секунд длился интервал». Он может быть отрицательным, если started_at > now ().
Для timestamptz epoch считает секунды от UTC-эпохи — отображение пояса не влияет на результат.

Если делать INTERVAL '1 month', то при переводе в секунды PostgreSQL берёт 30 дней на месяц (2 592 000 секунд) — это результат соглашения, а не календарная точность. Для разницы реальных дат лучше сначала вычесть даты/времена (получить интервал), а уже потом брать epoch.

SELECT EXTRACT(EPOCH FROM INTERVAL '1 month') AS sec;
-- 2 592 000 (это 30 * 86 400)

Но в календаре месяцы разные: 28, 29, 30, 31 день. Поэтому «секунды в месяце» зависят от конкретных дат.

Пример, как делать не надо:

-- Хотим "сколько секунд между 10 февраля и 10 марта"
-- Неправильно опираться на EXTRACT(EPOCH FROM INTERVAL '1 month')
SELECT EXTRACT(EPOCH FROM INTERVAL '1 month'); -- всегда 2 592 000

Пример правильного кода: делаем разницу конкретных дат, а уже её переводим в секунды:

-- Февраль → март 2025 (в феврале 28 дней)
SELECT EXTRACT(EPOCH FROM (TIMESTAMP '2025-03-10' - TIMESTAMP '2025-02-10')) AS sec_feb_to_mar;
-- 2 419 200 (28 * 86 400)

-- Март → апрель 2025 (в марте 31 день)
SELECT EXTRACT(EPOCH FROM (TIMESTAMP '2025-04-10' - TIMESTAMP '2025-03-10')) AS sec_mar_to_apr;
-- 2 678 400 (31 * 86 400)

INTERVAL '1 month' удобно добавлять к дате. Например, «через месяц от X».

SELECT (DATE '2025-02-10' + INTERVAL '1 month')::date; -- 2025-03-10

Но переводить «месяц» сразу в секунды нельзя, если нужна календарная точность.
Всегда берём разницу конкретных дат/времён, а затем EXTRACT (EPOCH FROM …).

5. Когда важно получить реальное «сейчас». Есть удобная функция now (). Но она получает время в момент срабатывания и «замораживает» его. Простыми словами, функция хранит время после получения и до конца выполнения запроса. Чтобы время всегда было актуальным, можно использовать:

SELECT clock_timestamp(); -- реальное "сейчас" (меняется в ходе запроса)

● now () ≡ transaction_timestamp () — время начала транзакции.
● statement_timestamp () — время старта текущего SQL-оператора.
● clock_timestamp () — часы в реальном времени (обновляется даже внутри запроса).

Представьте, что вы выполняете огромный список операций начисления бонусов пользователям в онлайн-игре. Для упрощения представим, что это выполняется посредством ручного, сложного запроса для всех игроков, и весь пул запросов будет идти целый час. Допустим, выполнять начали в 16:00. Тогда:

● при now () получится «время зачисления бонусов — 16:00»;
● при clock_timestamp () получится реальное время зачисления бонусов на каждое такое событие.

6. Неочевидный фильтр по дате. Допустим, нужно отсортировать все события по дате с 2025-09-01 до 2025-09-30. Сразу возникает желание использовать BETWEEN в WHERE-условии:

WHERE created_at BETWEEN '2025-09-01' AND '2025-09-30'

Но в таком случае получится событие только до 2025-09-30 0:00:00. То есть фактически «до 29 сентября включительно», хотя корректнее сказать «до 30 сентября не включительно».

Правильнее будет взять «до 1 октября не включительно», что соответствует всем событиям с начала до последних секунд сентября. Такой код вернёт корректный результат:

WHERE created_at >= TIMESTAMPTZ '2025-09-01'
       AND created_at < TIMESTAMPTZ '2025-10-01'

Либо используйте ключевое слово DATE — это позволит уйти от проблемы с «обрезанием по времени».

WHERE created_at BETWEEN DATE '2025-09-01' AND DATE '2025-09-30'

Памятка по основным функциям

Основные функции даты и времени в SQL

Рекомендации эксперта

Александр Вальд

1. Перед выполнением задачи детально разберитесь в данных: с каким типом вы работаете и в каком часовом поясе.
2. Разберитесь, какую задачу решаете и что должно быть на выходе, — это поможет допускать меньше неявных ошибок.
3. При решении аналитических задач в первую очередь важно думать о качестве получаемых данных и о читаемости самого запроса. Во вторую очередь — о быстродействии запроса. Оптимизация ради оптимизации съедает время аналитика.
4. Памятка по выбору типа:
● События/логи → timestamptz.
● Расписания / «каждый день в 09:00» → timestamp/time + zone_name.
● Разницы/длительности → interval (с оговоркой про месяцы).
● Периоды → tstzrange/tsrange/daterange.

Проверяйте, какой у вас формат столбца. Иногда можно встретить хранение даты и времени, например, в строчном формате. Тогда как минимум будет необходимо перевести такой формат в «дата-время».

Статью подготовили:
Александр Вальд
Яндекс Практикум
Старший эксперт курса «Аналитик данных»
Валентина Бокова
Яндекс Практикум
Редактор
Анастасия Павлова
Яндекс Практикум
Иллюстратор

Подпишитесь на наш ежемесячный дайджест статей —
а мы подарим вам полезную книгу про обучение!

Поделиться
Какой вы IT-монстр на Хэллоуин? Пройдите тест и получите скидку на курсы.
Tue Sep 30 2025 08:30:55 GMT+0300 (Moscow Standard Time)