Дата и время: DATE, TIME, TIMESTAMP, INTERVAL
Время — самый коварный тип данных в любой СУБД. Не потому, что оно сложно устроено внутри (как раз внутри всё просто — целое число), а потому, что вокруг него много неявной семантики: часовые пояса, переход на летнее время, високосные годы, разница между «моментом во вселенной» и «показанием настенных часов». Аналитика временных рядов — одна из главных задач DuckDB, поэтому понимать его типы даты и времени нужно точно.
В этом уроке разберём четыре основных типа — DATE, TIME, TIMESTAMP, INTERVAL — плюс критически важный TIMESTAMPTZ. Главная мысль: внутри все они хранятся как обыкновенные целые числа-смещения, а вся «сложность времени» — это правила интерпретации этих целых. И самая частая ошибка — путаница между TIMESTAMP и TIMESTAMPTZ.
Внутри — целое число-смещение
DuckDB не хранит дату как текст «2026-01-15» и не хранит структуру с полями год/месяц/день. Каждый временной тип внутри — это одно целое число, отсчитанное от фиксированной точки отсчёта (epoch).
DATE— целое число дней от 1 января 1970 года.1970-01-01это 0,1970-01-02это 1,2026-01-15это какое-то положительное число дней.TIMESTAMP— целое число микросекунд от той же точки1970-01-01 00:00:00.TIME— целое число микросекунд от начала суток (от00:00:00).
-- DATE как число дней от epoch, TIMESTAMP как микросекунды
SELECT
epoch_days(DATE '2026-01-15') AS days_since_1970,
epoch_us(TIMESTAMP '2026-01-15 12:00:00') AS us_since_1970;
Почему это устроено именно так — три причины. Первая: целое число занимает мало места и фиксированную ширину, колонка дат — это плотный массив целых. Вторая: сравнение и сортировка дат превращаются в сравнение целых — самую быструю операцию процессора; «дата A раньше даты B» это «целое A меньше целого B». Третья: смещение от общей точки отсчёта — идеальный вход для сжатия Frame of Reference, которое хранит близкие даты как маленькие отклонения от минимума (это разбирается в модуле про компрессию). Человекочитаемый вид «2026-01-15» собирается из целого только в момент вывода.
DATE, TIME и TIMESTAMP
DATE хранит календарную дату без времени суток — год, месяц, день. Используется, когда время дня не имеет смысла: дата рождения, дата отчётного периода, день сделки.
TIME хранит время суток без даты — часы, минуты, секунды, микросекунды. Используется редко само по себе: «время открытия магазина», расписание. Чаще время — часть TIMESTAMP.
TIMESTAMP хранит момент: дату и время суток вместе, с микросекундной точностью. Это основной тип для событийных данных — лог, транзакция, измерение сенсора. Большинство временных колонок в аналитике — TIMESTAMP.
-- Литералы трёх типов
SELECT
DATE '2026-01-15' AS d,
TIME '14:30:00' AS t,
TIMESTAMP '2026-01-15 14:30:00' AS ts;
-- Извлечение компонентов из TIMESTAMP
SELECT
extract(year FROM TIMESTAMP '2026-01-15 14:30:00') AS yr,
extract(month FROM TIMESTAMP '2026-01-15 14:30:00') AS mon,
extract(hour FROM TIMESTAMP '2026-01-15 14:30:00') AS hr;
-- yr=2026, mon=1, hr=14
DuckDB поддерживает и варианты TIMESTAMP с другой точностью — например, секундную или наносекундную, — но точность по умолчанию микросекундная, и для подавляющего большинства задач её достаточно. Наносекунды нужны разве что в высокочастотной торговле или приборном телеметрировании.
TIMESTAMP против TIMESTAMPTZ: главная развилка
Вот ключевой момент урока. TIMESTAMP и TIMESTAMPTZ (полное имя TIMESTAMP WITH TIME ZONE) выглядят похоже, но представляют разные сущности.
TIMESTAMP (без часового пояса) — это «показание настенных часов». Просто запись «2026-01-15 14:30:00» без привязки к тому, в каком поясе эти часы висят. Два TIMESTAMP с одинаковыми цифрами равны, даже если один записан в Москве, а другой в Нью-Йорке. TIMESTAMP не знает про пояса вообще.
TIMESTAMPTZ — это «момент во вселенной», абсолютная точка на оси времени. Внутри он хранится так же, целым числом микросекунд от epoch, но это микросекунды UTC. Важно понять: TIMESTAMPTZ не хранит «свой» часовой пояс внутри значения. Он хранит абсолютный момент в UTC, а часовой пояс применяется только при вводе (чтобы перевести введённое локальное время в UTC) и при выводе (чтобы показать UTC-момент в нужном поясе).
Разница видна на практике. TIMESTAMPTZ участвует в пересчёте поясов, TIMESTAMP — нет:
-- Текущая зона сессии влияет на ввод и вывод TIMESTAMPTZ
SET TimeZone = 'UTC';
SELECT TIMESTAMPTZ '2026-01-15 14:30:00' AS t_utc;
-- интерпретируется как 14:30 UTC
SET TimeZone = 'America/New_York';
SELECT TIMESTAMPTZ '2026-01-15 14:30:00+00' AS t_ny;
-- тот же UTC-момент, но показан в нью-йоркском времени: 09:30
Один и тот же абсолютный момент TIMESTAMPTZ показывается разными цифрами в разных поясах — потому что это один момент, на который смотрят с разных «настенных часов». TIMESTAMP так не делает: его цифры неизменны.
| Свойство | TIMESTAMP | TIMESTAMPTZ |
|---|---|---|
| Что представляет | Настенные часы, локальное время | Абсолютный момент на оси времени |
| Хранит пояс внутри | Нет | Нет (хранит UTC-момент) |
| Зависит от зоны сессии | Нет | Да — при вводе и выводе |
| Когда применять | Дата без географии: «отчётный день» | Событие, которое произошло: лог, транзакция |
Практическое правило: для любого события, которое реально произошло в определённый момент (запись в логе, оплата, клик), используйте TIMESTAMPTZ — вам важен абсолютный момент, и пользователи из разных поясов должны видеть его корректно в своём времени. TIMESTAMP без пояса оставьте для случаев, где география несущественна: «время начала рабочего дня» как абстрактное 09:00, плановое расписание.
Самая частая ошибка с временем — смешивать TIMESTAMP и TIMESTAMPTZ в одной системе, особенно хранить реальные события в TIMESTAMP без пояса. Тогда «когда произошло событие» становится неоднозначным: 14:30 в чьём поясе? При сравнении событий из разных источников или при показе пользователям в разных странах это приводит к ошибкам в несколько часов. Решите на старте: события — TIMESTAMPTZ, и придерживайтесь этого.
INTERVAL: длительность, а не момент
INTERVAL — отдельная категория. Все типы выше представляют точку на оси времени. INTERVAL представляет длительность — отрезок: «3 дня», «2 часа 30 минут», «1 год».
Устроен INTERVAL любопытно: внутри это не одно число, а три независимых поля — месяцы, дни и микросекунды. Почему три, а не пересчёт всего в микросекунды? Потому что «месяц» и «день» — не фиксированные величины. В месяце бывает 28, 29, 30 или 31 день; в сутках при переходе на летнее время бывает 23 или 25 часов. INTERVAL '1 month' нельзя заранее перевести в число дней — это зависит от того, к какой дате его прибавляют. Поэтому DuckDB хранит компоненты раздельно и пересчитывает их только в момент сложения с конкретной датой.
-- INTERVAL — результат вычитания двух дат или явный литерал
SELECT DATE '2026-03-01' - DATE '2026-01-15' AS gap;
-- результат: INTERVAL 45 days
-- Арифметика момент +/- длительность
SELECT TIMESTAMP '2026-01-15 10:00:00' + INTERVAL 3 HOUR AS later;
-- результат: 2026-01-15 13:00:00
-- '1 month' прибавляется по-разному к разным датам
SELECT
DATE '2026-01-31' + INTERVAL 1 MONTH AS from_jan31,
DATE '2026-02-15' + INTERVAL 1 MONTH AS from_feb15;
-- from_jan31 = 2026-02-28 (февраль короткий!), from_feb15 = 2026-03-15
Последний пример показывает, почему INTERVAL хранит месяцы отдельно: + 1 MONTH к 31 января даёт 28 февраля (последний день февраля), а к 15 февраля — 15 марта. Один и тот же INTERVAL, разный результат в днях — потому что пересчёт происходит относительно конкретной даты.
Арифметика времени складывается из трёх паттернов: момент - момент = длительность (INTERVAL), момент + длительность = момент, момент - длительность = момент. Складывать два момента (TIMESTAMP + TIMESTAMP) бессмысленно и запрещено — как нельзя сложить «3 часа дня» с «5 часами вечера».
DuckDB заимствовал у Postgres удобную функцию date_trunc: она «обрезает» момент до начала периода. date_trunc('month', ts) даёт первое число месяца, date_trunc('day', ts) — полночь. Это рабочая лошадка для группировки временных рядов: чтобы посчитать выручку по месяцам, группируйте по date_trunc('month', order_ts).
Попробуй сам
Запустите DuckDB CLI:
-- Подготовка
SELECT DATE '2026-01-15' AS d, TIMESTAMP '2026-01-15 14:30:00' AS ts;
Задания:
- Посчитайте, сколько дней прошло между
DATE '2026-01-01'иDATE '2026-12-31', вычитанием дат. Какого типа результат? - Установите
SET TimeZone = 'UTC', выведитеTIMESTAMPTZ '2026-06-01 12:00:00'. Затем установитеSET TimeZone = 'Asia/Tokyo'и выведите тот же литерал с суффиксом+00. Объясните разницу в цифрах. - Выведите
TIMESTAMP '2026-06-01 12:00:00'(без TZ) при двух разных зонах сессии. Убедитесь, что цифры НЕ меняются, и объясните почему. - Прибавьте
INTERVAL 1 MONTHкDATE '2026-01-31'и кDATE '2026-03-31'. Сравните результаты и объясните, почемуINTERVALхранит месяцы отдельным полем. - Через
date_trunc('month', ...)сгруппируйте набор timestamp-ов по месяцам.