Почему время — это сложно
Если есть один тип данных, на котором 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.
Источник может прислать что угодно — конвертируйте в aware UTC сразу на входе, храните и считайте всё в UTC, конвертируйте обратно только в отчёте.
datetime.now(): правильно и неправильно
До Python 3.12 был ходовой datetime.utcnow() — он возвращал текущее время в UTC, но как naive datetime. Это был источник багов: получаете «UTC-время», но без tzinfo, и при сравнении с aware значениями ловите TypeError. Поэтому в Python 3.12 utcnow()
Современный способ:
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, который читает напрямую
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 — это один и тот же момент времени, просто записанный в разных часовых поясах.
На Windows zoneinfo не работает из коробки — там нет IANA tz database. Решение: установите пакет tzdata (uv add tzdata). Этот же пакет нужен в Docker-образах на базе alpine.
ISO 8601: стандарт обмена
В data engineering есть единственный правильный текстовый формат для дат —
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, который спасёт вам много часов.
- Любой момент времени в коде — это aware datetime в UTC. Точка.
datetime.now(UTC)вместоdatetime.now()иdatetime.utcnow(). Без исключений.- В Postgres используйте
TIMESTAMPTZ, неTIMESTAMP. Первый хранит UTC и конвертирует в session_timezone при выборке, второй — слепо хранит то, что прислали (naive). - API-ответы от внешних сервисов нормализуйте к UTC сразу после парсинга. Не передавайте «неизвестно какой timezone» дальше по pipeline.
- При сравнении дат всегда aware с aware — никаких naive в production-коде.
- Локальное время появляется только при выводе пользователю (отчёт, дашборд, email). Конверсия —
.astimezone(local_tz), не вычитание timedelta. - DST (переход на летнее время) — это проблема
zoneinfo, не ваша. Если работаете везде в UTC — DST не существует для вашего кода.
DE-кейс: watermark для инкрементального ETL
Один из главных DE-паттернов —
Базовый шаблон:
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.