Введение: почему время — это не просто тик-так
Если вы хоть раз писали на 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 с явным указанием зоны:
<