Даты в Python: datetime, timedelta и ловушки часовых поясов
Наивный datetime без tzinfo, вычитание времени вокруг перехода на летнее время и сравнение aware с naive — три ошибки, которые ломают отчёты и графики задач. Разбираем, где именно и как чинить.

Задача звучит просто: посчитать, сколько времени прошло между двумя событиями, и показать пользователю в его часовом поясе. На деле в этом месте регулярно ломаются биллинги, расписания задач и аналитика: разница выходит на час больше или меньше, чем ожидалось, а сравнение двух дат падает с TypeError. Причина почти всегда одна — путаница между naive и aware datetime и неверная арифметика вокруг переходов на летнее время. Разбираем на Python 3.11+, где часовые пояса живут в стандартной библиотеке zoneinfo.
naive против aware — откуда растёт большинство багов
Объект datetime бывает двух видов. Naive — без информации о часовом поясе, атрибут tzinfo равен None. Aware — с привязкой к поясу. Python не даёт сравнивать или вычитать один из другого:
from datetime import datetime, timezone
naive = datetime(2024, 3, 31, 2, 30)
aware = datetime(2024, 3, 31, 2, 30, tzinfo=timezone.utc)
naive - aware
# TypeError: can't subtract offset-naive and offset-aware datetimes
Проблема в том, что datetime.now() без аргумента возвращает naive-объект в локальном времени машины. На ноутбуке разработчика это одно время, на сервере в UTC — другое, а в контейнере вообще может быть UTC независимо от того, где физически стоит сервер. Отчёт, собранный из таких дат, будет плавать в зависимости от того, где его запустили.
Правило, которое снимает 90% проблем: внутри системы храните и считайте всё в UTC и в aware-объектах. В локальный пояс переводите только на самой границе — при выводе пользователю.
Как получать текущее время правильно
from datetime import datetime, timezone
# Плохо: naive, зависит от настроек машины
bad = datetime.now()
# Хорошо: aware, всегда UTC
now_utc = datetime.now(timezone.utc)
print(now_utc.isoformat())
# 2024-06-01T12:00:00+00:00
Метод datetime.utcnow() использовать не стоит: он возвращает naive-объект со значением в UTC, но без tzinfo. Это худший из миров — время правильное, но система считает его локальным. В Python 3.12 utcnow() помечен как deprecated, замена — именно datetime.now(timezone.utc).
zoneinfo: часовые пояса без сторонних библиотек
До Python 3.9 для именованных поясов ставили pytz. Начиная с 3.9 в стандартной библиотеке есть zoneinfo, который читает системную базу IANA. На Windows системной базы нет — там ставят пакет tzdata (pip install tzdata), и zoneinfo подхватит его автоматически.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
now_utc = datetime.now(timezone.utc)
# Перевод в московское время только для вывода
msk = now_utc.astimezone(ZoneInfo("Europe/Moscow"))
print(msk.isoformat())
# 2024-06-01T15:00:00+03:00
Обратите внимание: astimezone не меняет момент времени, он меняет только представление. Оба объекта указывают на одну и ту же точку на оси времени, разница только в отображаемых часах и смещении.
Почему не стоит писать смещение руками
Соблазн есть: «Москва это +3, прибавлю три часа». Так делать нельзя для поясов с переходом на летнее время. Для Москвы сейчас перехода нет, но для многих европейских и американских зон он есть, и фиксированный timedelta(hours=...) даст ошибку на час дважды в год. Именованный пояс через ZoneInfo знает историю переходов и подставляет верное смещение для конкретной даты.
timedelta и арифметика вокруг перехода на летнее время
Здесь прячется самая коварная ошибка. timedelta — это чистая длительность в днях, секундах и микросекундах, без привязки к календарю. Прибавление суток к aware-объекту в зоне с переходом не гарантирует «то же время на следующий день».
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
tz = ZoneInfo("Europe/Berlin")
# Вечер перед переходом на летнее время (в ночь на 31 марта 2024)
before = datetime(2024, 3, 30, 12, 0, tzinfo=tz)
after = before + timedelta(days=1)
print(before.isoformat()) # 2024-03-30T12:00:00+01:00
print(after.isoformat()) # 2024-03-31T12:00:00+02:00
# Прошло реальных часов:
delta = after.astimezone(ZoneInfo("UTC")) - before.astimezone(ZoneInfo("UTC"))
print(delta) # 23:00:00
Календарно прошли сутки, а физически — 23 часа, потому что в эту ночь стрелки перевели вперёд. Если вы считаете, сколько человек проработал, или начисляете плату по часам, эта разница — реальные деньги. Вывод: для длительности считайте в UTC, для «того же времени завтра» — прибавляйте timedelta к naive-представлению или пересобирайте дату вручную.
Несуществующее и удвоенное время
Дважды в год в зонах с переходом появляются проблемные моменты. Весной час пропадает: время вроде 02:30 в ночь перехода просто не существует. Осенью час повторяется дважды: 02:30 случается один раз до перевода стрелок и один раз после. За второй случай отвечает атрибут fold:
from datetime import datetime
from zoneinfo import ZoneInfo
tz = ZoneInfo("Europe/Berlin")
# Осенний переход 2024: 02:30 существует дважды
first = datetime(2024, 10, 27, 2, 30, tzinfo=tz, fold=0)
second = datetime(2024, 10, 27, 2, 30, tzinfo=tz, fold=1)
print(first.utcoffset()) # 2:00:00 (летнее)
print(second.utcoffset()) # 1:00:00 (зимнее)
По умолчанию fold=0. Если вы парсите пользовательский ввод в проблемном интервале, вы не сможете однозначно определить момент — это ограничение реальности, а не Python. Проектируйте так, чтобы источник времени был в UTC, и таких коллизий не будет.
Сравнение подходов к хранению времени
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| naive в UTC | Простые типы, компактно в БД | Легко перепутать с локальным, нет защиты типами |
| aware в UTC | Однозначность, безопасная арифметика | Нужно помнить про tzinfo при создании |
| aware в локальном поясе | Удобно показывать без конверсии | Ломается на переходах DST, тяжело сравнивать |
| Unix timestamp (int) | Никаких поясов, легко сортировать | Нечитаемо, теряется исходный пояс события |
Рабочая связка для большинства сервисов: хранить aware в UTC (или timestamp), а ZoneInfo применять только на выводе.
Парсинг и форматирование без сюрпризов
Строку ISO 8601 с указанием пояса разбирает datetime.fromisoformat. В Python 3.11 он научился понимать суффикс Z и разные форматы смещения:
from datetime import datetime
# В 3.11+ работает и с суффиксом Z
dt = datetime.fromisoformat("2024-06-01T12:00:00Z")
print(dt.tzinfo) # UTC
Если вы на 3.10 или ниже, Z вызовет ошибку — там пишите смещение как +00:00 или используйте strptime с явным форматом. Для произвольных форматов остаётся strptime, но помните: результат strptime без директивы %z будет naive.
- Не смешивайте naive и aware в одном коде — либо всё привязано к поясу, либо ничего.
- Внутри системы — UTC. Локальный пояс — только на границе вывода.
- Длительность считайте вычитанием aware-объектов, приведённых к UTC.
- Именованные пояса через
ZoneInfo, а не фиксированныйtimedelta. - На Windows не забудьте
pip install tzdata.
Что настроить дальше
Проверьте, как datetime.now() вызывается в вашем коде: grep -rn "datetime.now()" . без аргумента почти всегда указывает на потенциальный баг. Настройте linter (например, правило flake8-datetimez) на запрет naive-конструкторов. В базе данных используйте тип с временной зоной: в PostgreSQL это timestamptz, который хранит момент в UTC и не теряет информацию. И заведите тест, который прогоняет вашу логику расписаний через дату перехода на летнее время — именно там всплывает большинство ошибок, которые не видны в обычные дни.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Python docs: zoneinfo — IANA time zone support, Python docs: datetime — Basic date and time types
Частые вопросы
У меня Python 3.8, zoneinfo не импортируется. Что делать?
Почему datetime.utcnow() считается устаревшим?
Как правильно посчитать количество часов между двумя событиями?
А если пользователь ввёл время в час, которого не существует из-за перехода на летнее время?
На Windows zoneinfo не находит пояса, ключевая ошибка ZoneInfoNotFoundError. Почему?
Стоит ли хранить время в базе как строку или как число?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.