Даты и часовые пояса в коде: UTC, ISO 8601 и типичные ошибки
Практика4 минРедакция Innovialabs
Коротко
Храните момент времени в UTC, передавайте его строкой ISO 8601 со смещением, а в локальное время переводите только при показе пользователю по IANA-зоне вроде Asia/Tashkent.
Короткий ответ
Почти все баги с датами возникают из-за того, что код путает два разных понятия:
- момент времени (instant) — точка на временной шкале, одинаковая для всего мира: «заказ создан»;
- календарная дата или локальное время — то, что видит человек на часах в своём городе: «день рождения 5 марта», «встреча в 10:00 по Ташкенту».
Правило простое: моменты храните в UTC, передавайте в формате ISO 8601 с явным смещением, а в локальное время переводите только на последнем шаге — при показе пользователю. Календарные даты без времени храните как дату, а не как полночь в UTC.
ISO 8601: единый формат обмена
ISO 8601 — стандарт записи дат, который понимают все языки и базы данных:
| Значение | Пример |
|---|---|
| Дата | 2026-03-05 |
| Момент в UTC | 2026-03-05T07:30:00Z |
| Момент со смещением | 2026-03-05T12:30:00+05:00 |
Буква Z означает UTC. Строка без Z и без смещения (2026-03-05T12:30:00) — это локальное время неизвестно какой зоны. В API такие строки лучше не принимать вовсе.
Смещение — не часовой пояс
+05:00 — это смещение, а Asia/Tashkent — IANA-зона: она хранит историю правил, включая переходы на летнее время (DST) и изменения законов. В Ташкенте сейчас нет перехода на летнее время, но у ваших пользователей в Европе или США он есть. Если вы сохранили только смещение, вы не сможете правильно вычислить «тот же час через неделю» после перевода часов.
Поэтому для повторяющихся событий и расписаний храните локальное время плюс имя зоны, а для фактов, которые уже произошли, — момент в UTC.
Типичные ошибки
- Сдвиг на один день. Дату рождения сохранили как
2026-03-05T00:00:00Z, а пользователь в зоне с отрицательным смещением видит 4 марта. Решение: тип «дата» без времени. - Парсинг строки в JavaScript.
new Date("2026-03-05")разбирается как полночь UTC, аnew Date("2026-03-05T00:00")— как полночь в зоне браузера. Разница в один символ меняет результат. - Месяцы с нуля. В
Dateиз JS январь — это0. - Арифметика «плюс 24 часа» вместо «плюс 1 день». В день перевода часов сутки длятся 23 или 25 часов.
- Время сервера. Сервер в другой зоне, чем разработчик, — и отчёт «за сегодня» начинается не в полночь. Держите сервер и базу в UTC.
- Сравнение строк с разными смещениями.
12:30+05:00и07:30Z— один и тот же момент, но как строки они не равны.
Как это делать в коде
Показ в нужной зоне в JavaScript — встроенный Intl:
const d = new Date("2026-03-05T07:30:00Z");
new Intl.DateTimeFormat("ru-RU", {
timeZone: "Asia/Tashkent",
dateStyle: "medium",
timeStyle: "short",
}).format(d); // 5 мар. 2026 г., 12:30
Для арифметики и форматирования удобны date-fns и пакет date-fns-tz (formatInTimeZone, fromZonedTime). В Python есть стандартный модуль zoneinfo:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
now = datetime.now(timezone.utc) # момент в UTC
local = now.astimezone(ZoneInfo("Asia/Tashkent"))
print(local.isoformat())
Избегайте «наивных» datetime без tzinfo: их нельзя корректно сравнивать с осознанными (aware) значениями.
Чек-лист для API и баз данных
- В базе моменты храните в типе с зоной (в PostgreSQL —
timestamptz), а сессию подключения держите в UTC. - Календарные даты — в типе
date, а неtimestamp. - API принимает и отдаёт ISO 8601 с
Zили смещением; строки без смещения отклоняйте. - Для расписаний храните локальное время и IANA-имя зоны отдельными полями.
- Зону пользователя берите из профиля или браузера (
Intl.DateTimeFormat().resolvedOptions().timeZone), а не угадывайте по IP. - Границы периодов («за день», «за месяц») вычисляйте в зоне пользователя, затем переводите в UTC для запроса.
- Пишите тесты на даты рядом с переходом на летнее время и на конец месяца.
- Не храните даты как Unix-время в секундах в одном месте и в миллисекундах в другом — договоритесь об одном варианте.