Напоминание пришло на три часа позже. Часовые пояса в Python-боте без сюрпризов

—

от автора

В ночь на 25 октября в Европе перевели часы на зимнее время. Хороший повод рассказать про баг, который я однажды отлавливал полдня, хотя чинится он за десять минут.

Делал бота-напоминалку для небольшого автосервиса. Клиент записывается, бот за час до визита присылает напоминание, мастер утром получает список записей. Локально всё работало. Я записывался на 18:00, в 17:00 приходило сообщение. Залил на VPS в Docker, отдал заказчику. На следующий день он пишет: напоминания приходят, когда клиент уже уехал домой. Ровно на три часа позже.

Три часа для Москвы число говорящее. Это смещение UTC+3, и в контейнере как раз UTC.

Как это выглядело в коде

Упрощённо логика была такая:

from datetime import datetime, timedeltavisit = datetime.strptime("26.10.2026 18:00", "%d.%m.%Y %H:%M")remind_at = visit - timedelta(hours=1)# в цикле проверкиif datetime.now() >= remind_at:    send_reminder()

strptime возвращает наивный datetime, без часового пояса. Это просто «18:00», и Python не знает, по каким часам. datetime.now() тоже наивный и берёт время системы. У меня на ноутбуке система в Москве, оба значения совпадали по смыслу. В контейнере now() стал отдавать UTC, а remind_at так и остался «17:00 неизвестно где». Сравнение прошло без ошибок, просто сработало, когда в UTC наступило 17:00. По Москве это 20:00.

Хуже всего тут то, что ничего не падает. Наивные даты между собой сравниваются спокойно, а смешать наивную и «осведомлённую» Python не даст:

from datetime import datetime, timezonenaive = datetime(2026, 10, 25, 18, 0)aware = datetime.now(timezone.utc)naive < aware# TypeError: can't compare offset-naive and offset-aware datetimes

Как ни странно, эта ошибка полезна. Если весь проект работает только с aware-датами, любое место, где проскочила наивная, сразу всплывёт исключением, а не тихим сдвигом на три часа.

Первый «фикс», который я видел у других

Самое частое, что я встречаю в чужих ботах, когда приходят на доработку: заменить now() на utcnow(). Время на сервере теперь одинаковое везде, и кажется, что проблема решена. Но utcnow() возвращает тоже наивный объект, только с цифрами UTC. Пользовательские 18:00 всё ещё нужно как-то перевести в UTC, и без часового пояса это невозможно. К тому же с Python 3.12 метод помечен устаревшим:

DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduledfor removal in a future version. Use timezone-aware objects to representdatetimes in UTC: datetime.datetime.now(datetime.UTC).

Вместо него datetime.now(timezone.utc). Он сразу возвращает время с поясом.

Грабли с pytz, которые живы до сих пор

В старых проектах и в ответах пятилетней давности часовые пояса делают через pytz. И там есть ловушка, на которую я сам наступал, когда только начинал:

from datetime import datetimeimport pytzmsk = pytz.timezone("Europe/Moscow")dt = datetime(2026, 10, 25, 18, 0, tzinfo=msk)print(dt)# 2026-10-25 18:00:00+02:30

Плюс два тридцать. Это LMT, местное среднее время Москвы, которое использовалось до введения поясов. Объект pytz без привязки к конкретной дате берёт первую запись из базы, а там как раз оно. Правильно в pytz вот так:

dt = msk.localize(datetime(2026, 10, 25, 18, 0))print(dt)# 2026-10-25 18:00:00+03:00

С Python 3.9 в стандартной библиотеке есть zoneinfo, и такой проблемы у него нет. Его можно спокойно передавать в tzinfo=:

from zoneinfo import ZoneInfodt = datetime(2026, 10, 25, 18, 0, tzinfo=ZoneInfo("Europe/Moscow"))print(dt)# 2026-10-25 18:00:00+03:00

Новый код я пишу только на zoneinfo. pytz трогаю, если он уже есть в проекте и переписывать всё нет смысла.

zoneinfo берёт базу поясов из системы. На Windows и в образах на Alpine её может не быть, тогда ZoneInfo("Europe/Moscow") бросит ZoneInfoNotFoundError. Лечится пакетом tzdata из PyPI, я его просто всегда добавляю в requirements.txt. И его стоит обновлять: Казахстан, например, в марте 2024 года перешёл на единый пояс UTC+5, и со старой базой Asia/Almaty будет показывать +6.

Как я храню время теперь

Правило простое: в базе всегда UTC, в часы пользователя переводим только на входе и на выходе. Часовой пояс храню отдельно у пользователя, строкой вида Europe/Moscow, а не смещением +3. Смещение у пояса может меняться, а название нет.

Telegram пояс пользователя боту не сообщает. Поэтому либо спрашиваю при старте («Вы в Москве? Да / Выбрать другой город»), либо ставлю по умолчанию пояс заказчика, если весь бизнес в одном городе.

Вот рабочий кусок с SQLite. Время храню как unix timestamp, так нечего парсить и сравнивать строки:

import sqlite3from datetime import datetime, timezonefrom zoneinfo import ZoneInfo, ZoneInfoNotFoundErrorDEFAULT_TZ = "Europe/Moscow"db = sqlite3.connect("bot.db")db.execute("""CREATE TABLE IF NOT EXISTS users (    id INTEGER PRIMARY KEY,    tz TEXT NOT NULL DEFAULT 'Europe/Moscow')""")db.execute("""CREATE TABLE IF NOT EXISTS reminders (    id INTEGER PRIMARY KEY,    user_id INTEGER NOT NULL,    text TEXT NOT NULL,    remind_at INTEGER NOT NULL  -- unix time, всегда UTC)""")def user_tz(user_id: int) -> ZoneInfo:    row = db.execute("SELECT tz FROM users WHERE id = ?", (user_id,)).fetchone()    try:        return ZoneInfo(row[0] if row else DEFAULT_TZ)    except ZoneInfoNotFoundError:        return ZoneInfo(DEFAULT_TZ)def add_reminder(user_id: int, when_text: str, text: str) -> datetime:    # пользователь ввёл время по своим часам    local = datetime.strptime(when_text, "%d.%m.%Y %H:%M").replace(tzinfo=user_tz(user_id))    db.execute(        "INSERT INTO reminders (user_id, text, remind_at) VALUES (?, ?, ?)",        (user_id, text, int(local.timestamp())),    )    db.commit()    return localdef due_reminders(now: datetime | None = None) -> list[tuple]:    now = now or datetime.now(timezone.utc)    return db.execute(        "SELECT id, user_id, text, remind_at FROM reminders WHERE remind_at <= ?",        (int(now.timestamp()),),    ).fetchall()def format_for_user(user_id: int, remind_at: int) -> str:    return datetime.fromtimestamp(remind_at, tz=user_tz(user_id)).strftime("%d.%m %H:%M")

Проверил на двух пользователях: один в Москве, второй в Екатеринбурге, оба поставили напоминание на 26.10 в 09:00. При проверке в 04:30 UTC сработало только екатеринбургское (там уже 9:30), при проверке в 06:00 UTC оба. И каждому в тексте показано «26.10 09:00», по его часам.

Кстати, .timestamp() у aware-объекта честно считает от UTC. А у наивного он молча возьмёт часовой пояс системы, то есть опять результат зависит от того, где запущен код. Ещё одна причина не пускать наивные даты дальше функции разбора ввода.

Почему не встроенные адаптеры sqlite3 для datetime: с Python 3.12 они тоже устаревшие, и при чтении всё равно приходит строка или наивный объект. Unix time в INTEGER проще и индексируется нормально. В PostgreSQL я, наоборот, беру timestamptz, и драйвер (psycopg, asyncpg) сам отдаёт aware-объекты.

Ежедневные напоминания и смена времени

В автосервисе всё было в Москве, где переводов часов нет с 2014 года. А вот в другом проекте у заказчика часть пользователей жила в Германии, и там бот каждый день в 9:00 присылал сводку. Наивная реализация «прибавь сутки к прошлому срабатыванию в UTC» после перевода часов начинает слать сводку в 8:00 по местному. Как раз в это воскресенье так и вышло бы:

from datetime import datetime, timedelta, timezonefrom zoneinfo import ZoneInfoberlin = ZoneInfo("Europe/Berlin")last = datetime(2026, 10, 24, 9, 0, tzinfo=berlin)utc_next = (last.astimezone(timezone.utc) + timedelta(days=1)).astimezone(berlin)print(utc_next)# 2026-10-25 08:00:00+01:00

Поэтому следующее срабатывание я считаю от календаря пользователя, а не от прошлого раза:

from datetime import datetime, time, timedelta, timezonefrom zoneinfo import ZoneInfodef next_daily(at: time, tz: ZoneInfo, now: datetime) -> datetime:    """Следующее «каждый день в at» по часам пользователя. Возвращает UTC."""    local_now = now.astimezone(tz)    candidate = datetime.combine(local_now.date(), at, tzinfo=tz)    if candidate <= local_now:        candidate = datetime.combine(local_now.date() + timedelta(days=1), at, tzinfo=tz)    return candidate.astimezone(timezone.utc)

Прогон через ночь перевода часов:

2026-10-25 08:00:00+00:00 -> 2026-10-25 09:00:00+01:002026-10-26 08:00:00+00:00 -> 2026-10-26 09:00:00+01:002026-10-27 08:00:00+00:00 -> 2026-10-27 09:00:00+01:00

В UTC срабатывание сдвинулось с 7:00 на 8:00, а пользователь как получал сводку в 9:00, так и получает.

Остаются два краевых случая. Время 02:30 в ночь осеннего перевода встречается дважды. zoneinfo по умолчанию берёт первое (летнее), а второе выбирается через fold=1:

b = ZoneInfo("Europe/Berlin")a = datetime(2026, 10, 25, 2, 30, tzinfo=b)print(a.utcoffset(), a.replace(fold=1).utcoffset())# 2:00:00 1:00:00

А 02:30 в ночь весеннего перевода вообще не существует. zoneinfo такой объект создаст без ошибки, и после перевода в UTC и обратно получится 03:30. Для напоминаний меня это устраивает, ставить их на половину третьего ночи всё равно никто не будет. Если в проекте это важно, такие значения надо проверять отдельно.

Короткий список

После того автосервиса у меня в любом проекте со временем одни и те же правила:

  1. Никаких datetime.now() и utcnow() без аргумента. Только datetime.now(timezone.utc).

  2. Наивный datetime живёт максимум до конца функции, которая разбирает ввод пользователя. Дальше он сразу получает tzinfo.

  3. В базе UTC: unix time в SQLite, timestamptz в PostgreSQL. Пояс пользователя отдельной колонкой, названием из базы IANA.

  4. zoneinfo вместо pytz, tzdata в зависимостях.

  5. Повторяющиеся события считаю от календаря пользователя, а не прибавлением суток в UTC.

  6. Перед сдачей запускаю бота в контейнере с UTC и ставлю тестовое напоминание на ближайшие пять минут.

В автосервисе, к слову, всё заметили только потому, что мастер увидел утренний список записей не в 8:00, а в 11:00, когда половина клиентов уже приехала.

ссылка на оригинал статьи https://habr.com/ru/articles/1099340/