Почему база и приложение спорят о часовом поясе и кто из них прав
Запись создана в 15:00, а в интерфейсе показывает 12:00 или 18:00 — знакомая картина, если в стеке есть хотя бы два компонента, которые umeют хранить время. Дело почти никогда не в баге как таковом: сервер БД, ОС сервера и само приложение — это три независимых места, где может быть настроен свой часовой пояс, и ни один из них не обязан знать, что думают по этому поводу остальные. Разберёмся, откуда берётся это расхождение, что такое naive-время и почему единственный способ перестать гадать — хранить и передавать время в UTC, конвертируя в локальный пояс только в момент показа пользователю.
Содержание
- Три часовых пояса, которые редко совпадают друг с другом
- Naive и aware datetime: где рвётся связь с реальным моментом
- Как СУБД хранят время: разница между timestamp и timestamptz
- Механика смещения: как naive-значение "уезжает" на несколько часов
- Почему UTC — практический стандарт, а не просто привычка
- Как найти, где именно расходятся часовые пояса
- Практика: как привести стек к единой нейтральной зоне
Три часовых пояса, которые редко совпадают друг с другом
В типичном веб-стеке время проходит минимум через три независимые настройки, и каждая может отличаться от соседних:
- Часовой пояс ОС сервера БД — то, что показывает
timedatectlна машине с PostgreSQL или MySQL. Влияет на системные логи, но не обязательно на то, как СУБД трактует значения в колонках. - Часовой пояс сессии/инстанса СУБД — отдельная настройка внутри самой базы (
timezoneв PostgreSQL,time_zoneв MySQL), которая может отличаться от пояса ОС и даже меняться на лету для конкретного подключения. - Часовой пояс процесса приложения — переменная
TZв окружении, настройка фреймворка (TIME_ZONEв Django,process.env.TZв Node.js,time_zoneв конфиге Rails) или дефолт контейнера, в котором приложение запущено.
К этому иногда добавляется четвёртый участник — часовой пояс браузера пользователя, вообще никак не связанный с бэкендом. Проблема не в том, что какая-то из этих настроек «неправильная», а в том, что их часто считают синхронизированными по умолчанию, хотя каждая живёт своей жизнью. Свежий сервер у большинства облачных провайдеров поднимается с UTC, но образ, который разворачивали два года назад, мог быть в Europe/Moscow, а Docker-контейнер с приложением — в Etc/UTC по умолчанию своего базового образа. Три независимых источника истины — и это ещё до того, как речь зайдёт о том, как каждый хранит конкретное значение.
Отдельно стоит развести две вещи: часовой пояс — это про то, *как интерпретировать* число, а синхронизация часов (NTP) — про то, *правильно ли вообще идут* часы независимо от пояса. Разные механизмы, разные источники ошибок; про синхронизацию времени через NTP у нас есть отдельный разбор — как NTP синхронизирует время сервера. Здесь же — только про пояса.
Naive и aware datetime: где рвётся связь с реальным моментом
В большинстве языков программирования объект даты-времени существует в двух видах:
- Aware (осведомлённый) — значение, которое явно знает свой часовой пояс или смещение от UTC. Например,
2026-08-29T15:00:00+03:00— это конкретный, однозначный момент времени, который можно пересчитать в любой другой пояс без потери информации. - Naive (наивный) — значение без указания пояса, просто
2026-08-29T15:00:00. Само по себе оно не говорит, что это за момент: 15:00 по Москве, по Лондону или по UTC — из значения этого не видно.
Проблема в том, что naive-значение не перестаёт существовать после того, как его записали — его продолжают читать. Читающая сторона (другой сервис, другой поток того же приложения, отчётный скрипт) вынуждена как-то интерпретировать эти «голые» 15:00 и по умолчанию делает это в *своём* локальном поясе — том, что настроен в её ОС, рантайме или конфиге фреймворка. Если пишущая и читающая стороны заранее договорились о поясе — всё сходится. Если нет — значение молча интерпретируется не в том поясе, в котором задумывалось, и результат смещается от реального момента, при этом никакой ошибки не возникает: синтаксически всё корректно, просто смысл потерян.
Характерный пример на Python: datetime.now() возвращает naive-объект в локальном поясе процесса, а datetime.now(timezone.utc) — aware-объект в UTC. Метод datetime.utcnow(), которым раньше часто получали «время в UTC», на деле тоже возвращал naive-объект — числа соответствовали UTC, но без пометки об этом; начиная с Python 3.12 он официально помечен устаревшим именно по этой причине. В JavaScript похожая история: new Date('2026-08-29T15:00:00') без суффикса Z браузер и Node.js интерпретируют в локальном поясе окружения, а new Date('2026-08-29T15:00:00Z') — однозначно в UTC. Разница в одном символе Z, а результат может отличаться на несколько часов.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКак СУБД хранят время: разница между timestamp и timestamptz
Здесь у PostgreSQL и MySQL принципиально разное поведение, и это частый источник путаницы при миграции между ними.
PostgreSQL различает два типа колонок:
timestamp(он жеtimestamp without time zone) — хранит naive-значение как есть, без какой-либо привязки к поясу. Что записали, то и лежит.timestamptz(timestamp with time zone) — при записи PostgreSQL конвертирует входящее значение в UTC и хранит его именно так (сам пояс не запоминается), а при чтении конвертирует обратно в часовой пояс *текущей сессии* — тот, что задан параметромtimezoneдля конкретного подключения.
SHOW timezone;
SET timezone TO 'Europe/London';
SELECT now(); -- aware-значение, timestamptz
SELECT now()::timestamp; -- то же самое, но без информации о поясе
Важный нюанс: timestamptz не хранит «пояс записи» — он хранит момент в UTC и показывает его в поясе того, кто сейчас читает. Два клиента с разными timezone в сессии увидят одно и то же значение как разные локальные строки — это ожидаемое поведение, а не баг.
MySQL устроен иначе: тип TIMESTAMP хранится внутри как UTC и конвертируется в пояс сессии (time_zone) при чтении и записи — по смыслу ближе к timestamptz в PostgreSQL, хотя и с меньшим диапазоном дат. А тип DATETIME — полностью naive, хранит значение как есть, без какой-либо конвертации, вне зависимости от time_zone сессии.
SELECT @@global.time_zone, @@session.time_zone;
SET GLOBAL time_zone = '+00:00';
SET SESSION time_zone = '+00:00';
Смешение DATETIME в MySQL и naive timestamp в PostgreSQL с разными часовыми поясами приложения — классический рецепт получить в отчёте время, сдвинутое на величину смещения между поясами, причём сдвиг может незаметно поменяться в день перехода на летнее время, если пояс сессии выбран не UTC.
Механика смещения: как naive-значение "уезжает" на несколько часов
Смещение возникает не из-за поломки, а из-за цепочки корректных по отдельности решений, которые в сумме дают неверный результат. Условная последовательность:
- Приложение генерирует момент времени naive-методом (
datetime.now(),LocalDateTime.now()в Java и подобные) — получает число, соответствующее локальному поясу процесса, без метки об этом поясе. - Значение уходит в колонку без учёта пояса (
DATETIMEв MySQL,timestampв PostgreSQL) — база записывает его как есть, ничего не пересчитывая. - Другой сервис — фоновый воркер, отчётный скрипт, экспорт в BI-систему — читает то же значение и интерпретирует его в *своём* поясе процесса, который может отличаться от пояса, где значение было создано.
- Если пояса совпадают — расхождения нет. Если не совпадают — вычисленный «локальный» момент времени отличается от исходного на величину смещения между поясами, и это может быть как несколько часов, так и (на границах суток) целые сутки, если разница поясов достаточно велика.
Ключевая деталь: ошибка не проявляется как исключение. Тип и формат данных корректны, запрос выполняется без ошибок — просто число означает не тот момент, который имелся в виду. Заметить это можно только сравнением с независимым источником времени или логической проверкой (например, «эта транзакция не могла произойти раньше открытия смены», а по данным получается, что произошла).
Похожая механика — но на уровне не приложения, а планировщика задач — разбирается в статье про то, как systemd-таймер, живущий по UTC, увёл отчёты на несколько часов: та же логика смещения, только источник naive-времени — не БД, а конфигурация таймера.
Почему UTC — практический стандарт, а не просто привычка
UTC (Coordinated Universal Time) удобен для хранения ровно потому, что у него нет того, что делает локальные пояса неудобными для машинной обработки:
- Нет смещения относительно самого себя. UTC — это точка отсчёта; всем остальным поясам присваивается смещение *относительно* UTC (
+03:00,-05:00), а не наоборот. - Нет перехода на летнее/зимнее время. Многие регионы (хотя и не все — конкретный список стоит проверять для целевой юрисдикции) переводят стрелки, из-за чего у локального пояса раз в год образуется час, которого не было, и час, который был дважды. UTC этой проблемы лишён по определению.
- Единая точка сравнения для распределённой системы. Когда несколько серверов в разных регионах пишут в общую БД, сравнение «что случилось раньше» имеет смысл только если все пишут в одном поясе — иначе сортировка по времени будет неверной при пересечении границы суток разных поясов.
Практическое правило: хранить и передавать время между системами в UTC, конвертировать в локальный пояс пользователя только на последнем шаге — непосредственно перед отображением. Пользователь должен видеть своё локальное время, но конвертация происходит в самом конце цепочки (в шаблоне, в клиентском JS, в мобильном приложении), а не где-то посередине бэкенда, откуда значение дальше путешествует уже со скрытым предположением о поясе.
Из этого же правила следует более узкий, но практичный вывод для серверной инфраструктуры: если нет прямой причины держать ОС сервера в локальном поясе (например, легаси-система, которая жёстко на это завязана), для бэкенд-серверов разумно по умолчанию использовать UTC на уровне ОС — это снижает число мест, где вообще может произойти путаница. Как посмотреть и сменить пояс ОС на Linux-сервере, разобрано отдельно: часовые пояса на сервере и настройка timezone.
Как найти, где именно расходятся часовые пояса
Когда время в системе «не то», проверка — это последовательный обход всех точек, где пояс может быть задан явно или неявно:
1. Пояс ОС на каждом сервере:
timedatectl
Смотрите на строку Time zone отдельно на сервере приложения и отдельно на сервере БД — они не обязаны совпадать, и если совпадают только случайно, стоит зафиксировать это явной настройкой, а не полагаться на совпадение.
2. Пояс сессии в PostgreSQL:
SHOW timezone;
SELECT current_setting('TIMEZONE');
Значение может задаваться на уровне кластера (postgresql.conf, параметр timezone), на уровне базы (ALTER DATABASE mydb SET timezone TO 'UTC') или на уровне конкретного подключения (переменная сессии) — приоритет у более специфичной настройки.
3. Пояс сессии в MySQL:
SELECT @@global.time_zone, @@session.time_zone;
Значение SYSTEM означает, что MySQL берёт пояс из ОС хоста, на котором работает сервер БД, — что снова возвращает к пункту 1 и делает поведение зависимым от окружения, а не от явной настройки.
4. Переменная окружения и настройка фреймворка в приложении:
echo $TZ
# Django settings.py
USE_TZ = True
TIME_ZONE = "UTC"
// Node.js — проверить, что реально видит процесс
console.log(process.env.TZ, new Date().toString());
Стоит проверить и настройки ORM: часть из них (например, SQLAlchemy без явного DateTime(timezone=True)) по умолчанию работает с naive-объектами и доверяет вызывающему коду не путать пояса — это особенность контракта, о которой легко забыть.
5. Docker и оркестрация. Если приложение или БД запущены в контейнере, у контейнера свой независимый часовой пояс, который может не совпадать ни с хостом, ни с соседним контейнером:
services:
app:
environment:
- TZ=UTC
db:
environment:
- TZ=UTC
Без явного TZ в docker-compose.yml контейнер использует пояс, зашитый в базовый образ, — обычно UTC, но не всегда, особенно для образов, собранных не в вашей команде.
Практика: как привести стек к единой нейтральной зоне
Итоговая схема, которая закрывает большинство описанных выше расхождений:
- ОС всех серверов (БД и приложение) — UTC. Проверить и при необходимости сменить:
timedatectl set-timezone UTC(требует root, применяется сразу, без перезагрузки). - СУБД настроена на UTC явно, а не унаследованно от ОС. В PostgreSQL — параметр
timezone = 'UTC'вpostgresql.confплюсALTER DATABASE mydb SET timezone TO 'UTC';. В MySQL —default-time-zone='+00:00'в конфиге сервера (для персистентности важно закрепить именно в конфиг-файле, а не только командойSET GLOBALна лету). - Тип колонки выбран осознанно. Для событий, которые сравнивают и сортируют между поясами, —
timestamptzв PostgreSQL илиTIMESTAMP(неDATETIME) в MySQL. Naive-тип оставлять только там, где момент принципиально не привязан к точке на глобусе (например, «повторять в 09:00 по местному времени пользователя» — это осознанная naive-семантика, а не забытая конвертация). - Приложение генерирует aware-объекты, а не naive. В Python —
datetime.now(timezone.utc)вместоdatetime.now(). В Django —USE_TZ = TrueиTIME_ZONE = "UTC". В Node.js — хранить и передавать время в ISO 8601 с явнымZ(toISOString()), а не в локальном представлении. - Конвертация в локальный пояс пользователя — только на границе показа. На фронтенде —
Intl.DateTimeFormatсtimeZoneиз профиля пользователя или браузера, а не на бэкенде «про запас». - Контейнеры получают
TZ=UTCявно, а не полагаются на дефолт образа.
Отдельно: если после перевода стека на UTC часы всё равно расходятся на секунды или минуты — это уже не про пояс, а про синхронизацию часов как таковую. Диагностика для этого случая другая, она разобрана в материале про то, почему часы виртуальной машины «убегают» вперёд или отстают — там речь о дрейфе гипервизора, а не о путанице поясов.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли везде поставить Europe/Moscow вместо UTC и не думать о конвертации?
Технически можно, но переход на летнее/зимнее время (там, где он есть) и расширение на другие регионы вернут ту же проблему с другой точкой отсчёта. UTC не привязан к региону и не переводит стрелки, поэтому как база для хранения он проще — не потому, что «правильнее» абсолютно.
Если PostgreSQL и MySQL по-разному хранят timestamp, это баг одной из СУБД?
Нет, это два осознанных, но разных решения, задокументированных в обеих системах. Проблема возникает не из-за самого различия, а из-за того, что о нём забывают при миграции данных или совместной работе с обеими СУБД.
Даёт ли timestamptz в PostgreSQL гарантию, что все клиенты увидят одно и то же значение?
Он гарантирует, что хранится один и тот же момент в UTC. А отображаемая строка при чтении зависит от параметра timezone сессии — это фича, а не баг, но именно она чаще всего удивляет тех, кто не читал документацию внимательно.
Стоит ли переводить работающую базу с naive-времени на aware, если проблем пока не заметно?
Если приложение и все читающие его сервисы гарантированно в одном поясе — срочности нет. Но при планах на второй дата-центр или интеграцию со сторонним сервисом миграцию лучше делать заранее, а не постфактум.
Нужно ли для этого что-то особенное от хостинга сервера?
Нет, пояс ОС и СУБД — конфигурация на вашей стороне, она не зависит от провайдера. Стоит лишь убедиться, что образ ОС по умолчанию действительно в UTC, как заявлено, а не в локальном поясе дата-центра.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →