Временные зоны в Go: как не потерять час, день или данные

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: почему время — это не просто тик-так

Если вы хоть раз писали на Go приложение, которое работает с датами и пользователями из разных стран, вы наверняка сталкивались с головной болью под названием «временные зоны». Казалось бы, что сложного? Взял time.Now() — и дело в шляпе. Но ровно до тех пор, пока ваш сервер не оказывается в UTC, а клиент — в MSK+3, и ваше «сегодня» волшебным образом превращается в «вчера».

В этой статье мы разберём, как грамотно работать с временными зонами в Go, на реальных примерах, без воды и с готовыми кусками кода. Вы узнаете, как не потерять данные, не сойти с ума при отладке и сделать ваше приложение устойчивым к часовым поясам. Поехали!

База: что скрывается за time.Time

В Go тип time.Time — это не просто число миллисекунд. Это структура, которая хранит два ключевых компонента:

  • Абсолютное время — количество наносекунд с 1 января 1970 года (unix timestamp).
  • Location — указатель на временную зону (часовой пояс).

Именно Location определяет, как будет отображаться время: как 14:00 MSK или как 11:00 UTC. Самое опасное заблуждение новичков — думать, что time.Now() возвращает «локальное время». На самом деле он возвращает текущее время с локальной зоной сервера. Перенесите код на другой сервер — и всё «поплывёт».

Как создавать время с конкретной зоной

Самый простой способ — использовать time.LoadLocation. Он загружает зону из системной базы данных IANA (обычно в /usr/share/zoneinfo).

package main

import (
    "fmt"
    "time"
)

func main() {
    loc, err := time.LoadLocation("Europe/Moscow")
    if err != nil {
        panic(err)
    }

    // Создаём время в конкретной зоне
    t := time.Date(2025, 3, 15, 14, 30, 0, 0, loc)
    fmt.Println(t) // 2025-03-15 14:30:00 +0300 MSK
}

Обратите внимание: time.Date принимает Location последним аргументом. Если передать time.UTC или time.Local, поведение будет другим.

А что если зоны нет в системе?

Иногда (например, в минимальных Docker-образах) база zoneinfo отсутствует. Решение — использовать time.FixedZone для создания зоны с фиксированным смещением:

loc := time.FixedZone("MSK", 3*60*60) // +3 часа от UTC
t := time.Now().In(loc)
fmt.Println(t)

Минус: FixedZone не знает про летнее время и исторические изменения. Для большинства современных приложений это приемлемо, но если вам нужна историческая точность (например, для финансовых операций за 2014 год), используйте LoadLocation.

Главное правило серверной разработки: храним в UTC

Золотое правило работы с временными зонами в Go (и в любом другом языке) — всегда хранить время в UTC, а конвертировать только на уровне отображения. Почему?

  • Сервер может переехать в другой часовой пояс.
  • Пользователи могут быть из разных зон.
  • Сравнение и сортировка UTC-времени — тривиальны.
  • Логи легче анализировать.

Вот как это выглядит на практике:

// Получаем время пользователя (например, из формы)
userTime := "2025-03-15 14:30"
userLoc, _ := time.LoadLocation("Asia/Tokyo")

// Парсим как локальное время пользователя
t, _ := time.ParseInLocation("2006-01-02 15:04", userTime, userLoc)

// Конвертируем в UTC для сохранения
utcTime := t.UTC()
fmt.Println(utcTime) // 2025-03-15 05:30:00 +0000 UTC

Типичная ошибка: сброс Location при сериализации

Когда вы сохраняете time.Time в базу данных (например, PostgreSQL через database/sql), драйвер может «сбросить» Location до UTC или Local. Чтобы избежать сюрпризов, всегда конвертируйте время в UTC перед записью:

db.Exec("INSERT INTO events (created_at) VALUES ($1)", time.Now().UTC())

При чтении — конвертируйте обратно в зону пользователя. Для этого храните предпочтения пользователя (например, "America/New_York") в его профиле.

Работа с PostgreSQL: timestamp vs timestamptz

В PostgreSQL есть два типа для даты-времени: TIMESTAMP (без зоны) и TIMESTAMPTZ (с зоной). На первый взгляд, TIMESTAMPTZ кажется логичным выбором. Но есть нюанс: TIMESTAMPTZ на самом деле хранит время в UTC, а зона используется только при вводе/выводе. Если ваше приложение уже работает в UTC, оба типа эквивалентны.

Рекомендация: используйте TIMESTAMPTZ — так вы явно указываете, что храните момент во времени, а не календарную дату. В Go это будет time.Time с любым Location.

// Пример записи
event := Event{
    Name:      "Встреча",
    StartedAt: time.Date(2025, 3, 20, 10, 0, 0, 0, time.UTC),
}
db.Create(&event)

// Пример чтения с конвертацией в зону пользователя
var event Event
db.First(&event, 1)
userLoc, _ := time.LoadLocation("Europe/London")
localTime := event.StartedAt.In(userLoc)
fmt.Println("У вас:", localTime.Format("15:04"))

sql.NullTime — не забываем про NULL

Если поле может быть NULL, используйте sql.NullTime:

type Event struct {
    ID        int
    StartedAt sql.NullTime
}

// Проверка перед использованием
if event.StartedAt.Valid {
    fmt.Println(event.StartedAt.Time.In(userLoc))
}

Парсинг строк с временными зонами

Часто вы получаете время из внешних API в формате RFC3339 (ISO8601). Go умеет парсить его «из коробки»:

str := "2025-03-15T14:30:00+03:00"
t, err := time.Parse(time.RFC3339, str)
if err != nil {
    panic(err)
}
fmt.Println(t.Location()) // +03:00
fmt.Println(t.UTC())      // 2025-03-15 11:30:00 +0000 UTC

Если строка содержит только дату и время без зоны (например, "2025-03-15 14:30"), используйте time.ParseInLocation с явным указанием зоны:

<                

Блоги

Рекомендаций по книгам