Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Дата и время в Python: часовые пояса без багов

datetime в Python: UTC, zoneinfo и правила, которые убирают баги времени

Как работать с датами в Python: naive против aware, UTC в хранилище и локальная зона в выводе, zoneinfo, парсинг форматов, летнее время и типичные ошибки на границах систем.

Прямой ответ

Три правила устраняют большинство багов со временем в Python. Первое: в системе живёт UTC — храните, передавайте и сравнивайте в UTC (aware-объекты с tz=timezone.utc); локальная зона появляется только на границе показа пользователю. Второе: никаких naive-дат на границах — datetime без таймзоны («наивный») неоднозначен и при сравнении с aware бросает ошибку (или, что хуже, молча трактуется неверно в старых кодовых базах); парсинг извне — всегда с явным указанием зоны источника. Третье: таймзоны — по имени, а не смещением — ZoneInfo("Europe/Berlin") (IANA-база, знает про летнее время), а не timedelta(hours=3): смещение «+3» врёт дважды в год.

naive против aware: главный источник багов

datetime.now() без аргумента — наивный объект «локального времени машины»: на сервере в UTC и на ноутбуке в CEST это разные моменты, а код не знает какой. Правильные источники: datetime.now(timezone.utc) — текущий момент в UTC (основной); datetime.now(ZoneInfo("Europe/Moscow")) — «сейчас» в конкретной зоне (для расписаний и «местных» срезов). Перевод: dt.astimezone(ZoneInfo(...)) — в любую зону; .astimezone(timezone.utc) — обратно. Проверка «aware ли объект» — dt.tzinfo is not None; политика проекта — требовать aware на всех границах (API, БД, логи), а наивные даты считать багом (линтер/типизация помогают: mypy не отличает naive от aware, но код-ревью и ассерты — да).

Хранение и передача: контракт границ

В базе — TIMESTAMP WITH TIME ZONE (PostgreSQL хранит момент, не «локальное время») и UTC на записи; в JSON/API — ISO 8601 с явным смещением или Z (2026-10-20T14:03:00Z), сериализация — dt.isoformat(), парсинг — datetime.fromisoformat() (в 3.11+ понимает Z напрямую). Логи — UTC-время с явной меткой (или ISO с зоной) — сведение логов с нескольких машин в наивных локальных времени превращает разбор инцидента в головоломку. Файлы/имена — UTC тоже (20261020T140300Z), иначе ночные джобы плодят «дубли» дат. Отдельная дисциплина — дата без времени (date) для «календарных» сущностей (день рождения, отчётный период): у неё нет зоны по смыслу, и превращать её в datetime «на полночь UTC» — ошибка, которая всплывает у пользователей за полярным кругом.

Парсинг и летнее время: где ломаются вычисления

Парсинг — только с явным форматом: datetime.strptime(s, "%d.%m.%Y") (или fromisoformat для ISO) — «угадывающий» парсинг путает 01.02 (1 февраля или 2 января) молча; для дат из данных — pd.to_datetime(..., format=...) с той же строгостью (в очистке данных это обязательный шаг — см. паттерны очистки). Летнее время создаёт два класса ловушек: «несуществующий» час весной (02:30 Europe/Berlin 29 марта 2026 не существует — ZoneInfo сдвинет результат, и это надо знать) и «двусмысленный» час осенью (02:30 случается дважды — fold=0/1 различает проходы). Следствие для арифметики: «плюс одни сутки» — это + timedelta(days=1) по календарю, но не всегда 24 часа в реальном времени; для «ровно 24 часа» — арифметика в UTC, для «то же время завтра» — в локальной зоне.

Расписания и периодика: cron-мышление

Задачи «каждый день в 09:00 по местному времени пользователя» решаются в локальной зоне (cron-выражение вычисляется в ZoneInfo пользователя, а не в UTC — иначе зимой задача уедет на час): механика cron — в статье про cron и фоновые задачи. «Каждые 24 часа» — другая задача (интервальная, в UTC). Хранение расписаний — зона как данные (Europe/Berlin строкой из IANA), а не смещение: при переходе DST смещение изменится само. Репортинг «по дням» — день определяется в зоне отчёта, и запрос к БД строится от UTC-границ этого локального дня (типичный баг: WHERE date(created_at) = ... в UTC даёт сдвиг суток для московских пользователей).

Инструменты и проверки

Стандартной библиотеки достаточно: datetime + zoneinfo (3.9+, PEP 615) покрывают всё; сторонние Arrow/Pendulum — эргономичнее, но добавляют зависимость там, где стандарт решает (в новых проектах разумный дефолт — стандарт). Тестирование времени: свободные функции принимают «сейчас» параметром (или freezegun/time-machine в тестах фиксируют момент) — тесты, зависящие от реальной даты, падают по ночам и в високосные годы. Ассерты на границах: все datetime из API/БД — aware; все сравнения — между aware; сериализация — ISO с зоной (о сериализации в целом — статья про JSON/orjson/pickle).

FAQ

Почему не хранить локальное время пользователя? Потому что момент однозначен только с зоной: «2026-10-20 09:00» без зоны — не момент. Храните UTC + зону пользователя отдельно — и любой показ выводится корректно, включая переходы DST.

Что делать с legacy-кодом на naive-датах? Постепенно: границы (API/БД) переводятся на aware-UTC первыми, внутри — по модулям; переходный период — явная трактовка наивных дат («считаем UTC») одной функцией-шлюзом, а не размазанная по коду.

timestamp (float) или datetime? Для хранения/передачи момента — ISO-строка или datetime; epoch-секунды удобны в логах и метриках, но нечитаемы и теряют точность представления. В БД — нативный тип времени, не float.

Как получить «сегодня» правильно? datetime.now(tz).date() в зоне пользователя — календарное «сегодня» для него; datetime.now(timezone.utc).date() — для системных нужд. Просто date.today() использует локальную зону машины — на сервере это обычно не то, что нужно пользователю.

Источники: документация datetime и zoneinfo, PEP 615 — ссылки в шапке.