Часовые пояса: timezone и offset
Naive-дата не знает своего пояса, aware — знает: tzinfo хранит смещение от UTC. Один момент в двух поясах — разные цифры на часах и одно и то же время.
Редакция Питоники
До сих пор мы печатали даты без смещений: 2026-10-21 14:30 — и молчали о том, где эти 14:30 наступают. Между Москвой и Лондоном разница в три часа, поэтому 14:30 без пояса — не время, а загадка. Разбираемся, как datetime хранит пояс и что меняется, когда он известен.
Naive и aware
from datetime import datetime, timezone
naive = datetime(2026, 10, 21, 11, 30)
aware = datetime(2026, 10, 21, 11, 30, tzinfo=timezone.utc)
print(naive)
print(aware)
print(aware.utcoffset())
2026-10-21 11:30:00 2026-10-21 11:30:00+00:00 0:00:00
Объект без пояса называется naive — наивный: цифры у него есть, но он не знает, какому поясу они принадлежат. Объект с tzinfo — aware: он знает своё смещение от UTC и печатает его плюсом — +00:00 у timezone.utc. Метод utcoffset() возвращает смещение объектом timedelta: для UTC это ноль часов. Все даты из предыдущих уроков были naive — теперь понятно, чего им не хватало.
Своё смещение: timezone(timedelta(...))
from datetime import datetime, timedelta, timezone
msk = timezone(timedelta(hours=3))
d = datetime(2026, 10, 21, 14, 30, tzinfo=msk)
print(d)
print(d.utcoffset())
2026-10-21 14:30:00+03:00 3:00:00
Класс timezone принимает фиксированное смещение объектом timedelta — три часа для Москвы. В repr смещение видно честно: datetime.timezone(datetime.timedelta(seconds=10800)) — три часа, переведённые в 10 800 секунд. Для фиксированных смещений больше ничего не нужно: база зон zoneinfo пригодится для исторических правил и переходов на летнее время, а здесь достаточно честного +03:00.
Один момент — двое часов
from datetime import datetime, timedelta, timezone
moment = datetime(2026, 10, 21, 11, 30, tzinfo=timezone.utc)
msk = moment.astimezone(timezone(timedelta(hours=3)))
print(moment)
print(msk)
print(moment == msk)
2026-10-21 11:30:00+00:00 2026-10-21 14:30:00+03:00 True
Метод astimezone переводит тот же момент в другой пояс: 11:30 по UTC — это 14:30 по московскому. Часы показывают разное, но это одно и то же время: сравнение aware-дат сравнивает моменты, а не цифры. Поэтому moment == msk вернуло True. Тот же принцип работал и в unix-времени из прошлого урока: число одно — часов много.
from datetime import datetime, timedelta, timezone
utc = datetime(2026, 10, 21, 11, 30, tzinfo=timezone.utc)
msk = datetime(2026, 10, 21, 14, 30, tzinfo=timezone(timedelta(hours=3)))
print(utc - msk)
print(msk - utc)
0:00:00 0:00:00
Откуда берётся смещение +03:00
Запись +03:00 в конце даты — это не украшение и не подпись. Это разница между местным временем и UTC в часах и минутах, и она говорит о двух вещах сразу: в какой зоне живут часы и насколько далеко эта зона от Гринвича. Москва зимой и летом 2026 года показывает +03:00, Лондон зимой показывает +00:00, а летом перескакивает на +01:00. Число в записи меняется, а страна та же — значит, это не навсегда прибитая характеристика.
from datetime import datetime, timedelta, timezone
msk = timezone(timedelta(hours=3))
d = datetime(2026, 10, 21, 14, 30, tzinfo=msk)
print(d.utcoffset())
print(d.utcoffset().total_seconds())
print(d.tzname())
3:00:00 10800.0 UTC+03:00
Метод utcoffset() отдаёт смещение объектом timedelta — в уроке это 3:00:00, то есть 10 800 секунд. Безымянный timezone(timedelta(hours=3)) при этом не остаётся анонимным: tzname() для него возвращает строку UTC+03:00, которую Python собирает из самого смещения. Если нужен осмысленный ярлык в логах или письмах, задайте имя вторым аргументом: timezone(timedelta(hours=3), 'MSK') даст в repr datetime.timezone(datetime.timedelta(seconds=10800), 'MSK') и напечатает MSK вместо UTC+03:00.
from datetime import datetime, timedelta, timezone
msk = timezone(timedelta(hours=3), 'MSK')
d = datetime(2026, 10, 21, 14, 30, tzinfo=msk)
print(d)
print(d.tzname())
2026-10-21 14:30:00+03:00 MSK
Один момент — три записи
Момент, который в Москве 14:30, в UTC записывается как 11:30, а в Нью-Йорке — как 07:30 или 08:30 в зависимости от сезона. Три записи — один момент, и все три равны друг другу при сравнении. Это и есть смысл aware-дат: цифры меняются, суть остаётся.
from datetime import datetime, timedelta, timezone
moment = datetime(2026, 10, 21, 14, 30, tzinfo=timezone(timedelta(hours=3)))
utc = moment.astimezone(timezone.utc)
ny = moment.astimezone(timezone(timedelta(hours=-4)))
print(moment)
print(utc)
print(ny)
print(moment == utc == ny)
2026-10-21 14:30:00+03:00 2026-10-21 11:30:00+00:00 2026-10-21 07:30:00-04:00 True
astimezone не меняет момент, а меняет его запись: 14:30+03:00 превратилось в 11:30+00:00 — это тот же самый момент, пересчитанный по-другому. Цепочка moment == utc == ny вернула True: Python сравнивает aware-даты по абсолютному моменту, а не по напечатанным цифрам. Отсюда практическое следствие: если две записи в базе выглядят по-разному, но равны при сравнении, это нормальные данные, а не дубль.
from datetime import datetime, timedelta, timezone
utc = datetime(2026, 10, 21, 11, 30, tzinfo=timezone.utc)
wrong = utc.replace(tzinfo=timezone(timedelta(hours=3)))
right = utc.astimezone(timezone(timedelta(hours=3)))
print(wrong)
print(right)
print(wrong == utc)
print(right == utc)
2026-10-21 11:30:00+03:00 2026-10-21 14:30:00+03:00 False True
Два объекта с одинаковыми цифрами на часах, но разными моментами: 11:30+03:00 — это 08:30 UTC, а 14:30+03:00 — это 11:30 UTC. Один из них тот момент, который был, другой — на два с половиной часа раньше. Разница между этими двумя ошибками стоила нам сдвига графика отчёта на полдня: в базе лежали локальные часы ночной смены, а приводили мы их к UTC как будто они уже были UTC.
Сравнение и сортировка
С aware-датами работают все обычные приёмы из урока про сравнение дат: меньше, больше, равно, сортировка списка. Разница в том, что сравнение теперь идёт по абсолютному моменту, а не по напечатанным цифрам, — и это единственное, что нужно знать, чтобы не запутаться.
from datetime import datetime, timedelta, timezone
msk = timezone(timedelta(hours=3))
utc = timezone.utc
events = [
datetime(2026, 10, 21, 14, 30, tzinfo=msk),
datetime(2026, 10, 21, 10, 30, tzinfo=utc),
datetime(2026, 10, 21, 12, 30, tzinfo=utc),
]
for d in sorted(events):
print(d)
2026-10-21 10:30:00+00:00 2026-10-21 14:30:00+03:00 2026-10-21 12:30:00+00:00
Напечатанные часы идут 10:30, 14:30, 12:30 — по цифрам это выглядит как ошибка. Но 14:30+03:00 это те же 11:30 UTC, то есть между 10:30 и 12:30. Сортировка посмотрела на момент, а не на цифры, и выстроила события правильно. Если бы мы сравнивали эти даты как строки, порядок был бы неверным — ровно тот случай, о котором пойдёт речь в уроке про логи.
Практика: где хранить и где переводить
Мы договорились о трёх вещах, и они снимают почти все проблемы. В базе лежит UTC — приведённый к нему момент с поясом, а не локальное время машины, которая писала строку. В логах пишется UTC с явной отметкой, и разбирается он обратно через fromisoformat из урока про ISO — там формат уже отформатирован, разбирать нечего. А перевод в местное время происходит в самом конце, на границе интерфейса: показать пользователю его собственное «сейчас».
from datetime import datetime, timedelta, timezone
stored = datetime(2026, 10, 21, 11, 30, tzinfo=timezone.utc)
for offset in (0, 3, -4):
zone = timezone(timedelta(hours=offset))
print(stored.astimezone(zone).strftime('%d.%m.%Y %H:%M %z'))
21.10.2026 11:30 +0000 21.10.2026 14:30 +0300 21.10.2026 07:30 -0400
Одно и то же сохранённое значение, три разных показания — и все три верны, потому что показывают разным людям разное местное время. Код %z в формате добавляет смещение текстом: +0000, +0300, -0400. Кто-то из разработчиков обязательно спросит, а зачем хранить UTC, если пользователь всё равно видит локальное время: потому что UTC — единственное значение, которое не переврачивается при переезде сервера, при смене часового пояса и при объединении данных из разных источников.
| Операция | Naive | Aware |
|---|---|---|
| print(d) | 2026-10-21 11:30:00 | 2026-10-21 11:30:00+00:00 |
| сравнение с другой aware-датой | TypeError | сравниваются моменты |
| вычитание | считает «часы на часах» | считает реальную разницу |
| timestamp() | зависит от пояса машины | детерминирован |
| astimezone() | считает локальным временем машины | переводит тот же момент |
Таблица показывает главное: naive-дата не ломается только до первого сравнения с aware или первого timestamp(). До этого она выглядит рабочей, а потом внезапно начинает зависеть от машины. Если разобрать все пять строк, видно, что за каждой строкой стоит один и тот же вопрос — знает ли объект свой пояс. Как только знает, всё остальное работает само.
Что дальше
С поясами разобрались: naive против aware, astimezone, offset и имена зон. Дальше — модуль calendar: он работает с календарём как таковым, без часовых поясов вообще, зато умеет нарисовать месяц целиком и ответить, каким днём недели была дата. А если захочется перейти от дат к таблицам и строкам выгрузки — урок про даты в Pandas.
Сначала предскажи ответ в голове — это главный навык программиста.
from datetime import datetime, timezone
d = datetime(2026, 10, 21, 9, 0, tzinfo=timezone.utc)
print(d)
from datetime import datetime, timedelta, timezone
utc = datetime(2026, 10, 21, 8, 0, tzinfo=timezone.utc)
msk = utc.astimezone(timezone(timedelta(hours=3)))
print(msk)
from datetime import datetime, timedelta, timezone
a = datetime(2026, 10, 21, 14, 0, tzinfo=timezone(timedelta(hours=3)))
b = datetime(2026, 10, 21, 14, 0, tzinfo=timezone.utc)
print(b - a)
from datetime import datetime, timedelta, timezone
moment = datetime(2026, 10, 21, 14, 30, tzinfo=timezone(timedelta(hours=3)))
ny = moment.astimezone(timezone(timedelta(hours=-4)))
print(ny)
from datetime import datetime, timedelta, timezone
utc = datetime(2026, 10, 21, 11, 30, tzinfo=timezone.utc)
wrong = utc.replace(tzinfo=timezone(timedelta(hours=3)))
print(wrong == utc)
print(wrong.astimezone(timezone.utc))
1. Чем aware-datetime отличается от naive?
2. Как задать пояс UTC+3?
3. 11:30 UTC и 14:30 MSK — один момент. Что вернёт оператор == между ними?
4. Что вернёт вычитание двух aware-дат из разных поясов?
5. Что произойдёт при вычитании naive из aware?
6. Чем astimezone отличается от replace(tzinfo=...)?
7. Почему фиксированный timezone(timedelta(hours=3)) не подходит для Лондона?
8. Что вернёт d.tzname() для timezone(timedelta(hours=3)) без имени?
9. Момент 14:30+03:00 и момент 12:30+00:00. Что раньше?
Возьмите aware-дату berlin = datetime(2026, 6, 15, 12, 0, tzinfo=timezone(timedelta(hours=2))). Выведите её. Переведите момент в UTC через astimezone и выведите результат. Выведите разницу berlin - utc.
Чем отличаются naive и aware даты в Python?
Naive-дата создана без tzinfo и не знает своего пояса — print показывает просто 2026-10-21 11:30:00. Aware-дата хранит смещение и печатает его плюсом: 2026-10-21 11:30:00+00:00. Для всего, что переживёт один компьютер — логов, API, баз, — используйте aware-даты.
Как задать московское время в Python?
Фиксированным смещением: msk = timezone(timedelta(hours=3)), затем datetime(2026, 10, 21, 14, 30, tzinfo=msk) — вы получите aware-дату с отметкой +03:00. Для фиксированных смещений база зон zoneinfo не нужна: достаточно timezone и timedelta.
Что делает astimezone?
Переводит тот же момент времени в другой пояс: 11:30+00:00 превращается в 14:30+03:00. Момент не меняется — меняются цифры на часах, поэтому исходная и переведённая даты равны при сравнении ==. В unix-времени это то же самое число секунд.
Урок 13: timestampПочему нельзя сравнивать naive и aware даты?
Наивная дата не знает пояса, поэтому Python не может свести её с aware-датой к одному моменту и выбрасывает TypeError: can't compare offset-naive and offset-aware datetimes (и аналогичный при вычитании). Правило одно: все даты в проекте — aware, а пояс задаётся timezone.utc или timezone(timedelta(hours=...)).
Как привести дату к московскому времени в Python?
Сначала сделайте дату aware, потом переведите её: msk = timezone(timedelta(hours=3)), local = moment.astimezone(msk). astimezone сохраняет момент и меняет только запись — 11:30+00:00 станет 14:30+03:00. Не используйте для этого replace(tzinfo=...): он сдвигает сам момент, и 11:30+00:00 превратится в 08:30 UTC.
Урок 14: часовые поясаЧто такое zoneinfo и зачем он, если есть timezone?
timezone(timedelta(hours=3)) — это фиксированное смещение, вечное UTC+3 без переходов. zoneinfo из стандартной библиотеки знает настоящие зоны по ключам вида Europe/Moscow и учитывает переход на летнее время и смену правил в стране. Плата — база данных часовых поясов: в браузерной песочнице её может не быть, и тогда приходится обходиться фиксированным смещением.
Как узнать смещение часового пояса у datetime?
Методом d.utcoffset() — он возвращает timedelta, для +03:00 это 3:00:00, а total_seconds() даёт 10800.0. У naive-дат без пояса utcoffset() возвращает None, и это удобный способ проверить, aware ли объект, не ловя исключение.
Почему в базе лучше хранить UTC, а не локальное время?
Потому что UTC — единственное значение, которое не переврачивается: при переезде сервера в другой часовой пояс, при смене настроек машины и при объединении данных из разных источников оно остаётся тем же самым моментом. Локальное время приходится переводить обратно, причём не зная, в каком поясе оно было записано. В базе держим UTC, а перевод в местное делаем на границе интерфейса через astimezone.
Понравился урок? Сошлитесь на него
«Часы показывают разное, но это одно и то же время: сравнение aware-дат сравнивает моменты, а не цифры.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что дальше
Осталось в разделе: 20 из 20. Прогресс хранится в вашем браузере.
Что читать дальше
Datetime · Урок 13
timestamp: unix-время и эпоха 1970
Unix-время — число секунд с полуночи 1 января 1970 года по UTC. Считаем timestamp у aware-дат, переводим число обратно через fromtimestamp — и всегда указываем пояс.
Datetime · Урок 15
calendar: месяц и год в текст
Модуль calendar рисует месяц одной строкой, говорит, каким днём недели была дата, проверяет год на високосность и сообщает размер месяца — без единого объекта datetime.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
openpyxl · Урок 9
Даты в Excel через openpyxl: запись, формат и арифметика
Пишем в ячейку настоящий datetime, показываем его по-русски маской DD.MM.YYYY и считаем дни между датами — а заодно узнаём, почему дату из файла нельзя сравнивать со строкой.
openpyxl datetimetimedelta python дни
Pandas · Урок 9
Даты и временные ряды в Pandas: resample и rolling
Строки «01.07.2024» превращаются в настоящие даты, выручка агрегируется по неделям и месяцам через resample, а шум сглаживает rolling — строим тренд продаж магазина.
set_index datetime pandas
Datetime · Урок 6
timedelta: разница между моментами
«Срок подписки истекает через 7 дней», «событие было 3 часа назад» — за этим стоит timedelta. Вычитаем моменты, читаем дни и секунды, прибавляем разницу к датам.
timedelta pythonpython timedelta примеры