Наивный datetime в Python-боте: почему напоминания приходят на 3 часа позже

· ·

Наивный datetime в Python-боте: почему напоминания приходят на 3 часа позже

PrimeCoder · 2026 · формат: tech

Разбор новинки простыми словами: что это, кому нужно и брать ли.

Показывает, что хранение времени в UTC и zoneinfo вместо pytz со смещением — обязательный минимум для любого бота-напоминалки, иначе сдвиг на 3 часа происходит молча, без ошибок в логах.

Связанные услуги: каталог, AI Boost Team, стать клиентом.

Напоминание пришло на три часа позже, и в логах тишина

Бот-напоминалка для небольшого автосервиса: клиент записывается, за час до визита уходит сообщение, мастер утром получает список записей на день. На ноутбуке всё шло по минутам. На VPS в Docker те же напоминания начали приходить ровно на три часа позже. Заказчик сформулировал мягко: сообщение приходит, когда клиент уже уехал домой.

Три часа для Москвы — не совпадение, это UTC+3. В контейнере живёт UTC. Дальше включается главное свойство этой ошибки: она не падает. Ни исключения, ни красной строки в логе, ни алерта. Сдвиг просто есть, и обнаруживается он по реакции клиента, а не по мониторингу.

Механика: два наивных времени сравниваются молча

Время визита приходило строкой вида «26.10.2026 18:00» и превращалось в наивный datetime. Это «18:00, а по каким часам — неизвестно». Момент отправки считался как минус час. Проверка в цикле сравнивала его с datetime.now(), который тоже наивный и берёт время системы.

На московской машине оба значения жили в одной системе координат, поэтому совпадали по смыслу. В контейнере now() начал отдавать UTC, а дедлайн остался «17:00 неизвестно где». Сравнение прошло без ошибок и сработало, когда в UTC наступило 17:00. По Москве это 20:00. Напоминание об утренней записи приехало вечером.

Полезная асимметрия: наивную дату с «осведомлённой» (aware) Python смешать не даст — будет TypeError. Исключение всплывает сразу. Пока весь проект работает на aware-датах, любая просочившаяся наивная ловится ошибкой, а не тихим трёхчасовым сдвигом. Это и есть аргумент за дисциплину типов времени: вы покупаете не красоту кода, а отказ от целого класса невидимых багов.

Почему utcnow() проблему не закрывает

Самый частый «фикс», который приносят на доработку: заменить now() на utcnow(). Время на сервере становится одинаковым, иллюзия порядка возникает. Объект остаётся наивным — просто с цифрами UTC. Пользовательские 18:00 всё равно некуда перевести: пояса у них нет. Плюс с Python 3.12 метод помечен устаревшим, а рабочий вариант — datetime.now(timezone.utc), который сразу возвращает aware-время.

pytz, +02:30 и почему ответы пятилетней давности опасны

В проектах постарше пояса делают через pytz. Там сидит ловушка: если передать пояс в tzinfo= напрямую, для Москвы получите смещение +02:30. Объект без привязки к конкретной дате берёт первую запись из базы поясов.

Плюс два тридцать. Это LMT, местное среднее время Москвы, которое использовалось до введения поясов.

Работает это через msk.localize(...) — тогда на выходе честные +03:00. С Python 3.9 в стандартной библиотеке есть zoneinfo, и он таких фокусов не делает: передавайте в tzinfo= спокойно. Новый код разумно писать только на zoneinfo, а pytz трогать там, где он уже живёт и переписывать всё ради переписывания смысла нет. Код в разборе проверен на Python 3.14.

Кому нужно и как применить: модель хранения

Любой бот с расписанием, записью, сменами или ежедневными сводками упирается в одни и те же четыре правила. Проверено на практике: для автосервиса с клиентами в разных городах это минимум.

  • В базе — unix timestamp в UTC, целым числом. Нечего парсить, индексируется нормально, драйвер не подсовывает строку вместо даты.
  • Пояс пользователя — строкой Europe/Moscow, а не смещением +3. Смещение у пояса может меняться, название — нет.
  • Конвертация — только на входе (разбор того, что ввёл человек) и на выходе (форматирование текста). Внутри всё живёт в UTC.
  • Telegram пояс не сообщает. Спрашивайте при старте («Вы в Москве? Да / Выбрать другой город») или ставьте пояс заказчика по умолчанию, если бизнес в одном городе.

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

Ежедневные сводки и перевод часов

Ежедневный повтор нельзя считать как «прошлое срабатывание + 24 часа». В ночь на 25 октября Европа переводит часы на зимнее время, и повтор, приклеенный к фиксированному UTC-моменту, съезжает: сводка, которая уходила в 07:00 UTC, начинает уходить в 08:00 UTC. Для пользователя это значит, что мастер увидит список записей на час позже обычного — и именно в тот день, когда ему нужен запас.

Считать надо от календарной даты в поясе пользователя: взять его локальную дату, прибавить сутки как дату, а не как 24 часа, и пересобрать момент срабатывания в UTC. Тогда переход на зимнее время проходит незаметно, а не тянет за собой час разницы.

Где подвох остаётся

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

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

Что сделать на этой неделе

  1. Прогнать поиск по коду: now(), utcnow(), strptime без tzinfo, любые tzinfo=pytz_zone. Каждое попадание — кандидат на сдвиг.
  2. Перевести точку входа в UTC-aware: datetime.now(timezone.utc) везде, где раньше стоял now() или utcnow().
  3. Проверить схему хранения: если время лежит строкой или наивной датой — переложить в unix timestamp и завести отдельное поле с названием пояса.
  4. Добавить tzdata в зависимости и обновить локальную базу поясов.
  5. Переписать расчёт ежедневных повторов от календарной даты пользователя, а не прибавлением суток к прошлому срабатыванию.
  6. Сделать тест на двух поясах (Europe/Moscow и Asia/Yekaterinburg) — он ловит сдвиг до того, как его увидит клиент.

Брать или подождать

Брать. Это не про миграцию на новый фреймворк: правки в модели данных и в разборе ввода, которые чинятся за десять минут, если знать, куда смотреть. Поиск бага в описанном случае занял полдня — и то повезло, что заказчик написал на следующий день, а не через месяц.

Подождать можно в одном случае: бот внутренний, все пользователи в одном поясе, ежедневных сводок нет. Тогда трёхчасовой сдвиг вам просто негде показать. Но как только появится второй город, сводка по утрам или переезд на VPS в UTC — возвращайтесь к списку выше. Цена отложенного решения — не падение бота, а тихий сдвиг, который вы прочитаете в отзывах клиентов.

FAQ

Локально всё работает. Может, обойдётся?

Обойдётся ровно до переезда в контейнер или на VPS с UTC. Симптом появится не сразу и не ошибкой, а жалобой.

Можно хранить смещение +3 числом?

Можно, пока пояс не сменился и пока нет перевода часов. Название пояса переживает и то, и другое.

Нужен ли tzdata, если сервер на Linux?

Часто база уже есть в системе. Для Windows и Alpine-образов пакет обязателен, и обновлять его стоит регулярно — иначе получите устаревшие смещения вроде казахстанских.

pytz ещё живой?

Живой, в старых проектах. Новый код на zoneinfo, старый — через localize(). Смешивать два подхода в одном модуле не надо.

Дальше

Если у вас в проекте есть бот с расписанием, проверьте одну вещь прямо сейчас: что возвращает datetime.now() на проде и в контейнере. Разные цифры — это и есть те самые три часа, которые пока никто не заметил.

Читайте также