Лаборатория кодера

Справочник · Данные и форматы

Временные зоны: как хранить и показывать время без ошибок

UTC, смещение и зона — разные вещи. Разбираем ISO 8601, базу часовых поясов, переход на летнее время и работу со временем в Linux, Python, JavaScript и PostgreSQL.

Обновлено · 6 мин чтения

Время кажется простым, пока приложение живёт в одном городе. Как только появляются пользователи из Калининграда и Владивостока, сервер в другой стране и отчёты «за вчера», начинаются ошибки на час, на день и на целый часовой пояс. Почти все они лечатся несколькими правилами.

Три понятия, которые путают

ПонятиеПримерЧто это
момент времени2026-11-03T09:00:00Zточка на общей шкале, одинаковая для всех
смещение+03:00на сколько местное время отличается от UTC прямо сейчас
часовой поясEurope/Moscowправила региона: смещения и их история, включая переходы на летнее время

Смещение и зона — не одно и то же. +01:00 зимой — это и Берлин, и Париж, и Лагос, но летом Берлин живёт по +02:00, а Лагос остаётся на +01:00. Поэтому для будущих событий нужно хранить зону, а не смещение.

Unix-время — число секунд с 1 января 1970 года 00:00 UTC. Оно не зависит от поясов вовсе: в момент, когда вы читаете эту строку, оно одинаково в Москве и Нью-Йорке. Перевести его в дату и обратно можно в конвертере unix-времени.

Формат ISO 8601

text
2026-11-03                       дата
2026-11-03T14:05:00Z             момент в UTC, Z означает нулевое смещение
2026-11-03T17:05:00+03:00        тот же момент с московским смещением
2026-11-03T14:05:00.123Z         с миллисекундами
2026-W45                         неделя года
P1DT2H30M                        длительность: 1 день, 2 часа, 30 минут

Даты в таком виде сортируются как строки, не зависят от локали и однозначно читаются любым языком программирования. Форматы вроде 03.11.2026 или 11/03/2026 оставьте для отображения людям: второй в США значит 3 ноября, а в Европе — 11 марта.

База часовых поясов

Правила поясов меняются по решению правительств, иногда с уведомлением за несколько недель. Их собирает база IANA tz (её ещё называют tzdata или Olson). Зоны в ней называются по региону и крупному городу: Europe/Moscow, Asia/Yekaterinburg, America/New_York.

ЗонаСмещение сейчасПримечание
Europe/Kaliningrad+02:00
Europe/Moscow+03:00без перехода на летнее время с 26 октября 2014 года
Europe/Samara+04:00
Asia/Yekaterinburg+05:00
Asia/Novosibirsk+07:00
Asia/Vladivostok+10:00
Asia/Kamchatka+12:00

Обновления tzdata приходят с пакетами системы (tzdata в Linux), а у Java, Python-пакетов вроде tzdata и браузеров — своими путями. Если правила в стране поменялись, обновить нужно везде, где время переводится в местное.

Летнее время и его ловушки

Во многих странах дважды в год часы переводят. Например, в Берлине в последнее воскресенье марта в 02:00 по местному времени часы сразу переводят на 03:00, а в последнее воскресенье октября в 03:00 их возвращают на 02:00, и этот час проходит дважды.

Из этого следует:

  • Несуществующее время. 2026-03-29 02:30 в Берлине не было. Библиотеки по-разному обрабатывают такой ввод: сдвигают вперёд или выдают ошибку.
  • Двойное время. 2026-10-25 02:30 в Берлине было дважды. Без смещения неясно, какой из моментов имеется в виду.
  • Сутки не всегда 24 часа. Прибавить «1 день» и «24 часа» — разные операции в дни перехода.
  • Расписания. Задача cron на 02:30 по местному времени в день перехода может не выполниться или выполниться дважды. Поэтому серверные расписания лучше задавать в UTC, о cron подробнее — в отдельной статье.

Правила хранения

  1. Моменты, которые уже произошли (создание заказа, вход в систему, лог) — храните в UTC. В базе — тип с часовым поясом или unix-время.
  2. Будущие события, привязанные к месту («встреча в 10:00 по Москве через полгода»), — храните местное время и имя зоны. Если правила пояса поменяются, момент пересчитается правильно.
  3. Даты без времени (день рождения, дата документа) — храните как дату, без зоны и без полуночи. Иначе день рождения «уедет» на день назад у пользователя западнее сервера.
  4. Переводите время в местное только при показе пользователю, по его зоне.
  5. Логи и серверы — в UTC. Так проще сопоставлять события из разных мест.

Linux и командная строка

bash
timedatectl                              # зона системы, синхронизация часов
timedatectl list-timezones | grep Asia
sudo timedatectl set-timezone UTC

date -u +%Y-%m-%dT%H:%M:%SZ              # текущее время в UTC в ISO 8601
date +%s                                  # unix-время
date -d @1767225600                       # unix-время в дату (GNU date)
TZ=Asia/Vladivostok date                  # который час во Владивостоке
date -u -d 'TZ="Europe/Moscow" 2026-11-03 10:00'   # московское время в UTC

На macOS команда date другая: вместо -d @1767225600 пишут -r 1767225600.

Python

python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

now = datetime.now(timezone.utc)                       # всегда с зоной
local = now.astimezone(ZoneInfo("Asia/Yekaterinburg"))
meeting = datetime(2026, 12, 1, 10, 0, tzinfo=ZoneInfo("Europe/Moscow"))
meeting.astimezone(timezone.utc).isoformat()            # '2026-12-01T07:00:00+00:00'
datetime.fromisoformat("2026-11-03T14:05:00Z")          # Python 3.11+ понимает Z

JavaScript

Объект Date хранит момент в миллисекундах UTC, а показывает его в зоне браузера. Ловушки:

КодЧто получится
new Date("2026-11-03")полночь UTC: в Москве это 03:00, а в Нью-Йорке ещё 2 ноября
new Date("2026-11-03T00:00")полночь по местному времени
new Date(2026, 10, 3)3 ноября: месяцы считаются с нуля
Date.now()миллисекунды, а не секунды, как в unix-времени

Для показа есть встроенный Intl, а последняя строка примера возвращает зону пользователя:

js
new Intl.DateTimeFormat("ru-RU", {
  dateStyle: "long", timeStyle: "short", timeZone: "Asia/Vladivostok"
}).format(new Date());

Intl.DateTimeFormat().resolvedOptions().timeZone;

Новый стандартный API Temporal разделяет моменты, даты, время и зоны на отдельные типы и решает большинство этих проблем. Он появляется в браузерах постепенно, до тех пор выручают библиотеки date-fns-tz и Luxon.

PostgreSQL

sql
SHOW timezone;
SET timezone = 'UTC';

-- timestamptz хранит момент в UTC и переводит при выводе
SELECT now(), now() AT TIME ZONE 'Asia/Novosibirsk';

-- начало суток по Москве, переведённое в момент времени
SELECT date_trunc('day', now() AT TIME ZONE 'Europe/Moscow') AT TIME ZONE 'Europe/Moscow';

Тип timestamptz (с зоной) почти всегда правильнее, чем timestamp без зоны. Название обманчивое: имя зоны он не хранит, он хранит момент в UTC.