Learning Platform
Урок 05.05 · 18 мин
Начальный
datetimezoneinfoTimezoneUTCISO 8601Watermark
SQL: TIMESTAMP, TIMESTAMPTZ и часовые пояса в PostgreSQL Watermarks в стриминге: event time vs processing time

Почему время — это сложно

Если есть один тип данных, на котором junior DE ломает голову и production-pipeline — это даты и время. Простой вопрос «во сколько произошло событие» имеет минимум три ответа: по часам пользователя, по часам сервера, по UTC. Если в разных шагах ETL используются разные ответы — получаются классические баги «отчёт пустой за 3 часа в марте» (переход на летнее время) или «всё съехало на сутки в декабре» (наивный datetime сравнили со строкой в timezone-aware режиме).

Правило, которое спасает 95% случаев: в системах данных всё время — в UTC, конвертация в локальное только при отображении. Сейчас разберём, как этого добиваться технически.

Четыре класса в datetime

В stdlib-модуле datetime есть четыре главных типа:

from datetime import datetime, date, time, timedelta

d = date(2026, 5, 13)              # только дата: год, месяц, день
t = time(14, 30, 0)                # только время суток: час, минута, секунда
dt = datetime(2026, 5, 13, 14, 30) # дата + время вместе
delta = timedelta(days=7, hours=3) # интервал

Для DE на 90% задач хватит datetime (момент времени) и timedelta (длительность). Самые частые операции:

dt + timedelta(days=7)       # datetime — на неделю позже
dt - other_dt                # timedelta — разница между двумя моментами
delta.total_seconds()        # 627360.0 — длина в секундах

timedelta хорошо сложить, вычесть из datetime, спросить total_seconds. Но месяца в timedelta нет — длина месяца не определена в общем виде. Если нужно «через месяц» — используйте dateutil.relativedelta (third-party) или считайте через date.replace(month=...).

Naive vs aware: главное различие

У datetime есть два режима существования:

  • Naive datetime — без информации о часовом поясе. Просто число «13 мая 2026, 14:30». Что это — Москва, Берлин, UTC? Неизвестно.
  • Aware datetime — содержит ссылку на часовой пояс. «13 мая 2026, 14:30 UTC» или «13 мая 2026, 14:30 Europe/Moscow».
from datetime import datetime, UTC
from zoneinfo import ZoneInfo

naive = datetime(2026, 5, 13, 14, 30)
print(naive.tzinfo)            # None — naive

aware_utc = datetime(2026, 5, 13, 14, 30, tzinfo=UTC)
print(aware_utc.tzinfo)        # datetime.timezone.utc

aware_msk = datetime(2026, 5, 13, 14, 30, tzinfo=ZoneInfo("Europe/Moscow"))
print(aware_msk)               # 2026-05-13 14:30:00+03:00

Сравнивать naive и aware нельзя — Python кинет TypeError. Поэтому выбирайте один режим на проект. Для DE-задач — всегда aware с UTC.

Жизнь timestamp в data pipeline

Источник может прислать что угодно — конвертируйте в aware UTC сразу на входе, храните и считайте всё в UTC, конвертируйте обратно только в отчёте.

входной источникстрока ISO, timestamp с TZ или naiveAPI может прислать всё что угодно — будьте к этому готовы
нормализацияparse → aware UTC
хранениеaware UTC в БД (TIMESTAMPTZ)Postgres TIMESTAMPTZ хранит в UTC, при выборке отдаёт в session timezone
вычислениявсё в UTC: фильтры, агрегации, оконные функции
отображениеконверсия в локальную TZ только для пользователя

datetime.now(): правильно и неправильно

До Python 3.12 был ходовой datetime.utcnow() — он возвращал текущее время в UTC, но как naive datetime. Это был источник багов: получаете «UTC-время», но без tzinfo, и при сравнении с aware значениями ловите TypeError. Поэтому в Python 3.12 utcnow()

пометили как deprecated
, в одной из ближайших версий уберут.

Современный способ:

from datetime import datetime, UTC

now_utc = datetime.now(UTC)        # aware datetime в UTC — то что надо

Альтернативы и когда они применимы:

datetime.now()           # naive локальное время — не используйте в pipeline
datetime.utcnow()        # DEPRECATED, не используйте
datetime.now(UTC)        # aware UTC — стандарт для DE
datetime.now(ZoneInfo("Europe/Moscow"))  # aware конкретная TZ — для логов/отчётов

Запомните: единственная форма, которую вы пишете в коде ETL — datetime.now(UTC). Всё остальное — либо deprecated, либо нужно только в коде отображения.

zoneinfo: stdlib вместо pytz

Долгое время в Python не было встроенного источника информации о часовых поясах, и индустрия пользовалась third-party библиотекой pytz. У неё странный API и тонкие баги в районе DST. В Python 3.9 в stdlib добавили модуль zoneinfo, который читает напрямую

IANA tz database
вашей операционной системы. С тех пор pytz не нужен — используйте только zoneinfo.

from zoneinfo import ZoneInfo

msk = ZoneInfo("Europe/Moscow")
ny = ZoneInfo("America/New_York")
utc = ZoneInfo("UTC")

# конверсия
dt_msk = datetime(2026, 5, 13, 14, 30, tzinfo=msk)
dt_ny = dt_msk.astimezone(ny)              # тот же момент в Нью-Йорке
dt_utc = dt_msk.astimezone(UTC)            # тот же момент в UTC

astimezone() — это конверсия одного и того же момента в разное представление. Это не «изменить timezone». Например, 2026-05-13 14:30 MSK и 2026-05-13 11:30 UTC — это один и тот же момент времени, просто записанный в разных часовых поясах.

WARNING

На Windows zoneinfo не работает из коробки — там нет IANA tz database. Решение: установите пакет tzdata (uv add tzdata). Этот же пакет нужен в Docker-образах на базе alpine.

ISO 8601: стандарт обмена

В data engineering есть единственный правильный текстовый формат для дат —

ISO 8601
:

2026-05-13T14:30:00+00:00
2026-05-13T14:30:00Z         (Z = +00:00 = UTC)

Преимущества стандарта: однозначность, сортировка строкой совпадает с хронологической, поддерживается всеми системами от Postgres до AWS до JSON Schema. Все остальные форматы — 13/05/2026 14:30, May 13 2026 и т.п. — для отображения людям, не для обмена данными.

Парсинг и форматирование:

# parse: строка ISO 8601 → datetime
dt = datetime.fromisoformat("2026-05-13T14:30:00+00:00")

# format: datetime → строка ISO 8601
dt.isoformat()           # '2026-05-13T14:30:00+00:00'

fromisoformat() в Python 3.11+ принимает любой валидный ISO 8601 формат, включая ‘Z’ в конце вместо ‘+00:00’. До 3.11 был более ограничен — пишите Python 3.13 и не вспоминайте.

Если строка нестандартная (May 13, 2026 14:30), нужен либо strptime с явным шаблоном, либо third-party dateutil:

# strptime — strict format string
dt = datetime.strptime("13/05/2026", "%d/%m/%Y")

# dateutil — гибкий парсер для "грязных" форматов
from dateutil import parser
dt = parser.parse("May 13, 2026 14:30")

dateutil.parser хорошо для разовых случаев, но в production-pipeline лучше явный strptime — он быстрее и предсказуемее, и при изменении формата вы сразу увидите ошибку, а не «магически» правильный результат.

Дисциплина UTC для DE

Соберём правила в один список — это buying-guide, который спасёт вам много часов.

  1. Любой момент времени в коде — это aware datetime в UTC. Точка.
  2. datetime.now(UTC) вместо datetime.now() и datetime.utcnow(). Без исключений.
  3. В Postgres используйте TIMESTAMPTZ, не TIMESTAMP. Первый хранит UTC и конвертирует в session_timezone при выборке, второй — слепо хранит то, что прислали (naive).
  4. API-ответы от внешних сервисов нормализуйте к UTC сразу после парсинга. Не передавайте «неизвестно какой timezone» дальше по pipeline.
  5. При сравнении дат всегда aware с aware — никаких naive в production-коде.
  6. Локальное время появляется только при выводе пользователю (отчёт, дашборд, email). Конверсия — .astimezone(local_tz), не вычитание timedelta.
  7. DST (переход на летнее время) — это проблема zoneinfo, не ваша. Если работаете везде в UTC — DST не существует для вашего кода.

DE-кейс: watermark для инкрементального ETL

Один из главных DE-паттернов —

watermark
. Идея: вместо того чтобы каждый раз пересчитывать весь CSV-файл/всю таблицу, мы помним «последнюю обработанную точку во времени» и при следующем запуске берём только то, что свежее неё.

Базовый шаблон:

from datetime import datetime, UTC
from pathlib import Path

WATERMARK_FILE = Path("/data/state/users_watermark.iso")

def read_watermark() -> datetime:
    if not WATERMARK_FILE.exists():
        return datetime(1970, 1, 1, tzinfo=UTC)   # эпоха, чтобы взять всё
    return datetime.fromisoformat(WATERMARK_FILE.read_text().strip())

def write_watermark(when: datetime) -> None:
    WATERMARK_FILE.parent.mkdir(parents=True, exist_ok=True)
    WATERMARK_FILE.write_text(when.isoformat())

def run_increment():
    last_seen = read_watermark()
    print(f"fetching users updated_at > {last_seen.isoformat()}")

    new_users = api.fetch_users(updated_after=last_seen)
    if not new_users:
        print("nothing new")
        return

    save_to_db(new_users)
    # сдвигаем watermark на момент самой свежей записи
    max_updated = max(u["updated_at"] for u in new_users)
    write_watermark(max_updated)

Несколько важных деталей. Первое: храним watermark в ISO 8601 строке, не в unix-timestamp и не в человеко-читаемом формате. Это portable и видно глазом, что там лежит. Второе: при пустом файле возвращаем эпоху (1970-01-01 UTC), а не None — это упрощает дальнейший код. Третье: watermark сдвигаем на максимум updated_at среди обработанных записей, не на datetime.now() — иначе можем пропустить записи, которые в БД появились с timestamp в прошлом (что бывает чаще, чем кажется).

В реальных pipeline watermark хранят не в файле, а в маленькой таблице БД, в Airflow XCom или в Redis. Но логика та же.

Подводные камни

Сравнение naive ↔ aware валится в TypeError. Запомните: либо все datetime в коде aware, либо все naive. Смешивать нельзя.

Парсинг строки без TZ. Если строка 2026-05-13T14:30:00 (без +00:00 и без Z), fromisoformat вернёт naive. Нужно явно дописать TZ: dt.replace(tzinfo=UTC). И заодно задуматься, действительно ли источник в UTC, или вы только что присвоили чужому naive-значению UTC по своей воле.

Часы летнего времени. Это не вопрос «знает ли Python о DST» — он знает через zoneinfo. Это вопрос «знаете ли вы, что в марте на 2 часа ночи в Москве не происходит вообще» (а в Лондоне — на 1 час ночи). Если делаете «считать почасовые отчёты в локальной TZ», два часа в году будут содержать сюрприз. Решение — считайте в UTC.

timedelta не имеет «месяцев». timedelta(months=1) это TypeError. Если вам нужно «через месяц», используйте from dateutil.relativedelta import relativedelta.

Что мы получили

  • В Python 3.13 для дат: datetime, date, time, timedelta — четыре основных типа.
  • Aware datetime в UTC — единственно правильный формат для DE-кода.
  • datetime.now(UTC) — современная замена datetime.utcnow() (последний deprecated).
  • zoneinfo (stdlib с 3.9) — единственный современный источник часовых поясов; pytz больше не нужен.
  • ISO 8601 (fromisoformat / isoformat) — стандарт обмена временем между системами.
  • DE-применение: watermark для инкрементального ETL, нормализация входов от API, хранение в TIMESTAMPTZ.

В следующем уроке — логирование. От print к logging-stdlib и структурным логам через structlog.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6