Время в распределённой системе на Python: UTC, таймзоны, наивные объекты
Наивный datetime без таймзоны — источник смещений на несколько часов и дублей в отчётах. Разбираем, где именно ломается работа со временем в Python и как строить конвейер от UTC к локали.

Сервис принимает событие в 02:30 ночи 27 октября, а в отчёте оно оказывается в 01:30. Причина не в бизнес-логике: один узел записал datetime.now() без таймзоны, другой прочитал это значение как UTC, а третий — как московское время. В сумме получилось смещение и потерянный час на границе перехода на зимнее время. В распределённой системе такие ошибки не воспроизводятся локально и всплывают на проде под нагрузкой.
Разбираем, почему наивные объекты datetime в Python — мина под распределённой системой, где именно теряется информация и как выстроить конвейер: UTC внутри, таймзона только на краях. Примеры на Python 3.11+, где модуль zoneinfo входит в стандартную библиотеку.
Наивный datetime: чего в нём нет
Объект datetime в Python бывает двух видов. Aware-объект знает свою таймзону — у него заполнен атрибут tzinfo. Наивный (naive) объект хранит только числа: год, месяц, день, часы, минуты — и понятия не имеет, к какому поясу они относятся.
Проблема в том, что наивный объект молча притворяется рабочим. Он сравнивается, сериализуется, пишется в базу — и только на стыке двух систем выясняется, что одна считала его UTC, а другая локальным временем.
Наивный datetime — это не «время без таймзоны». Это число, о котором вы договорились в уме, но не записали договорённость в код. В распределённой системе таких договорённостей несколько, и они противоречат друг другу.
Классический источник наивных объектов:
datetime.now()без аргумента — возвращает локальное время машины, без tzinfo;datetime.utcnow()— возвращает время в UTC, но тоже без tzinfo. Это самая коварная функция: значение правильное, но объект наивный, и следующий узел не знает, что это UTC;datetime.strptime()при разборе строки без указания пояса;- чтение из СУБД в колонку без таймзоны (в PostgreSQL это
timestamp without time zone).
datetime.utcnow() объявлен устаревшим начиная с Python 3.12 — именно из-за того, что порождает наивные объекты. Актуальная замена — datetime.now(timezone.utc), которая возвращает aware-объект.
Правило: UTC внутри, таймзона на краях
Рабочая дисциплина для распределённой системы состоит из трёх зон ответственности.
- Вход. Как только время попадает в систему — из запроса, из очереди, из внешнего API — приводите его к aware-объекту в UTC. Если источник прислал локальное время с указанием пояса, конвертируйте сразу.
- Внутри. Все вычисления, хранение, передача между узлами, сравнение — только aware UTC. Никаких наивных объектов и никаких локальных поясов в шине сообщений и базе.
- Выход. Перевод в локальную таймзону пользователя — только в момент отображения: в шаблоне, в ответе API, в выгрузке отчёта. И только зная целевой пояс явно.
Так граница перехода на летнее/зимнее время перестаёт быть проблемой: UTC монотонен и переходов не знает, вся неоднозначность локализована в одной точке — при выводе.
Правильное «сейчас» и разбор входящей строки
from datetime import datetime, timezone
from zoneinfo import ZoneInfo # стандартная библиотека, Python 3.9+
# Текущий момент как aware-объект в UTC
now_utc = datetime.now(timezone.utc)
print(now_utc.isoformat())
# 2024-10-27T00:30:00.123456+00:00 — суффикс +00:00 подтверждает, что объект aware
# Разбор ISO-строки с явным смещением (Python 3.11+ понимает суффикс Z)
incoming = "2024-10-27T02:30:00Z"
event_ts = datetime.fromisoformat(incoming)
print(event_ts.tzinfo) # UTC
# Если строка пришла в локальном поясе клиента без смещения,
# пояс нужно знать заранее и приписать его явно
naive = datetime.fromisoformat("2024-10-27T02:30:00")
moscow = naive.replace(tzinfo=ZoneInfo("Europe/Moscow"))
event_utc = moscow.astimezone(timezone.utc)
print(event_utc.isoformat()) # 2024-10-26T23:30:00+00:00Обратите внимание на разницу двух методов. replace(tzinfo=...) не сдвигает часы — он лишь приписывает поясу «эти числа были в таком поясе». astimezone(...) пересчитывает часы в другой пояс. Перепутать их — типичная ошибка, дающая смещение ровно на величину пояса.
Отдача времени пользователю
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# В базе и в коде хранится aware UTC
stored_utc = datetime(2024, 10, 26, 23, 30, tzinfo=timezone.utc)
# На выходе конвертируем в пояс конкретного пользователя
user_tz = ZoneInfo("Europe/Moscow")
local = stored_utc.astimezone(user_tz)
print(local.strftime("%d.%m.%Y %H:%M %Z"))
# 27.10.2024 02:30 MSKГде ломается на переходе времени
Летнее/зимнее время — не абстракция. В зонах, где переход есть, один час либо не существует, либо существует дважды. zoneinfo обрабатывает это через параметр fold, но полагаться на автоматику опасно.
- Несуществующий час. При переводе стрелок вперёд локального времени вида 02:30 в этот день не бывает вовсе. Наивный объект с таким временем при конвертации даст неоднозначный результат.
- Повторяющийся час. При переводе назад 02:30 наступает дважды. Атрибут
fold=0означает первое вхождение,fold=1— второе. Если вы читаете локальное время из лога без fold, вы не отличите одно от другого.
Вывод практический: локальное время нельзя использовать как ключ, идентификатор или значение для арифметики. Россия с 2014 года переходы не практикует, но ваши пользователи и внешние API могут жить в зонах, где они есть, — America/New_York, Europe/Berlin и десятки других.
База данных и сериализация
Смещение чаще всего рождается на границе с хранилищем. Ключевые точки:
| Точка | Опасность | Как правильно |
|---|---|---|
PostgreSQL timestamp without time zone | Хранит числа без пояса, драйвер вернёт наивный объект | Используйте timestamp with time zone (timestamptz); драйвер вернёт aware в UTC |
| JSON-сериализация | str(datetime) без пояса теряет tzinfo | Отдавайте .isoformat() у aware-объекта — суффикс смещения сохранится |
| Очередь сообщений | Один продюсер шлёт UTC, другой — локальное | Контракт: только ISO-8601 с суффиксом смещения или Z |
| Unix timestamp (epoch) | Число секунд однозначно, но пояс отображения теряется | Годится для хранения момента; пояс восстанавливайте на выходе |
О PostgreSQL стоит развеять миф: timestamptz не хранит пояс внутри. Он хранит момент в UTC, а при чтении отдаёт его с учётом настройки сессии. Именно это и нужно — момент однозначен, а форматирование делается на краю.
Проверка кода на ревью
Конкретные формулировки замечаний, которые ловят проблему до прода:
- «Здесь
datetime.utcnow()— объект наивный, следующий узел не узнает, что это UTC. Замените наdatetime.now(timezone.utc).» - «
datetime.now()без аргумента вернёт локальное время машины. На разных инстансах пояс может отличаться — привяжите кtimezone.utc.» - «Здесь
replace(tzinfo=...)на значении, которое уже в другом поясе, — это не конвертация, а переприсваивание. Нуженastimezone.» - «Сравниваете aware и naive объекты — это
TypeErrorв рантайме. Приведите оба к UTC.» - «Форматируете время в шаблоне без указания пояса пользователя — покажете UTC вместо локального.»
Что делать дальше
Внедрите три вещи в проект. Первое — линтер-правило, запрещающее datetime.now() и datetime.utcnow() без аргумента; в flake8 это делает плагин flake8-datetimez. Второе — в тестах прогоняйте события через границу перехода времени в зоне с DST (например, America/New_York в марте и ноябре), а не только в UTC. Третье — проверьте схему базы: все колонки времени должны быть timestamptz, а не timestamp.
Инвалидация кешей по времени и генерация повторяющихся задач (cron в локальном поясе) — отдельные темы со своими граблями на переходах DST; здесь мы рассматривали только конвейер значений от входа до вывода.
Контент-конвейер: Из одной идеи — текст, изображение, озвучка и ролик. Один заказ вместо четырёх.
Спросить за 60 ₽Источники: Python docs: zoneinfo — IANA time zone support, Python docs: datetime — basic date and time types
Частые вопросы
У меня Python 3.9, там нет timezone.utc или zoneinfo?
Почему нельзя просто хранить всё в локальном времени, раз у меня один пояс?
Не сработало: получаю TypeError can't compare offset-naive and offset-aware datetimes
В чём разница между replace(tzinfo=...) и astimezone()?
PostgreSQL с timestamptz хранит таймзону внутри?
Как безопасно тестировать переходы на летнее/зимнее время?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.