Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 10 из 25 ~20 мин +20 XP

Типы Postgres: UUID, TIMESTAMPTZ, NUMERIC

Типы Postgres богаче базовых типов Go, и мост между ними в pgx называется pgtype: UUID приезжает как 16 байт, timestamptz — как time.Time, а NUMERIC для денег обгоняет float64 в точности так же, как numeric обгоняет его в базе. Разберём соответствия колонок и приёмников, поле Valid как общий знаменатель NULL и три типа, на которых чаще всего теряют копейки, секунды и идентификаторы.

Содержание урока · 10 разделов

Три истории, из которых вырастает этот урок. Первая: команда хранила идентификаторы объектов строками и однажды получила дубль из-за регистра — UUID с заглавными буквами и без дифисов не равен своей же копии, пока оба не превратились в байты. Вторая: отчёт по заказам разошёлся с бухгалтерией на две копейки — сумма считалась в float64, и 19.99 в бинарном представлении оказалось 19.990000000000002. Третья: метка времени создалась на сервере в UTC, прочиталась в локальной таймзоне ноутбука и сохранилась обратно на час в будущее — без единого предупреждения. У всего этого общий корень: Postgres хранит типы, которых нет в Go, и наивное приведение через строку или float теряет либо точность, либо смысл. pgtype — библиотека типов внутри модуля pgx — закрывает все три случая: для UUID есть честные байты, для денег — NUMERIC без потери разряда, для времени — инстанс с таймзоной. Урок построен вокруг трёх главных типов и таблицы соответствий, к которой вы будете возвращаться всю разработку.

pgtype: мост между типами Postgres и Go#

Пакет pgtype — это набор обёрток и кодеков: для каждой колонки Postgres есть понимание, какие байты по протоколу во что превращать в обе стороны. Обёртки устроены однотипно: значение плюс поле Valid из шестого урока — UUID{Bytes, Valid}, Timestamptz{Time, Valid}, Numeric{Int, Exp, Valid}, Text{String, Valid}. Простые случаи в обёртках не нуждаются: text сканируется в string, bigint — в int64, bool — в bool, date и timestamptz — в time.Time, и это самый частый путь в прикладном коде. Обёртка нужна тогда, когда у колонки есть второе состояние или точность, которую базовый тип Go не выдержит: NULL, бесконечность, произвольный масштаб числа. Все pgtype-типы реализуют и sql.Scanner, и driver.Valuer, поэтому работают и через адаптер stdlib из девятого урока — там, правда, без адаптера NewMap().SQLScanner не обойтись. Отдельная суперспособность pgtype — кодеки для сложных типов: массивы, JSONB, диапазоны, составные типы, — но это темы одиннадцатого и двенадцатого уроков; здесь держимся тройки UUID, TIMESTAMPTZ, NUMERIC и правил выбора между простым типом Go и обёрткой.

Колонка Postgres → приёмник в Go#

КолонкаПростой приёмникОбёртка pgtypeКогда обёртка обязательна
uuidstringpgtype.UUIDсравнения по байтам, NULL в колонке
timestamptztime.Timepgtype.TimestamptzNULL, infinity, своя таймзона чтения
timestamptime.Timepgtype.TimestampNULL; тип без таймзоны — осторожно
numericstring или float64pgtype.Numericденьги, точный масштаб, NaN
text / varcharstringpgtype.TextNULL в колонке
bigint / integerint64 / int32pgtype.Int8 / Int4NULL или диапазонные проверки
datetime.Timepgtype.DateNULL, бесконечные даты
booleanboolpgtype.BoolNULL в колонке

UUID: 16 байт, а не строка#

pgtype.UUID — это структура с массивом Bytes [16]byte и флагом Valid. Колонка uuid в Postgres хранит те же 16 байт, и приёмник-обёртка делает сравнение и запись тривиальными: никаких разговоров о дифисах и регистре — байты либо равны, либо нет. Строка тоже работает: Scan в обычный string вернёт UUID в стандартной форме с дифисами и строчными буквами — у pgtype.UUID для этого есть и метод String(), появившийся в 5.7, — а INSERT со строкой-значением сервер сам превратит в байты: для прототипов этого хватает. Единственная ловушка строковой формы — регистр: сервер хранит и отдаёт байты, но до превращения в них две строки с заглавными и строчными буквами в Go не равны — та самая история из начала урока. Два практических источника UUID. Серверный: колонка с DEFAULT gen_random_uuid(), встроенным в Postgres с тринадцатой версии, — идентификатор рождается в базе вместе со строкой и приезжает через RETURNING. Клиентский: пакет github.com/google/uuid генерирует uuid.New() до вставки, что удобно для распределённых систем — объект знает свой id ещё до записи. Конвертация между библиотеками — одна строка в обе стороны: uuid.UUID — это тот же [16]byte, поэтому pgtype.UUID{Bytes: gID, Valid: true} едет в базу, а pgtype.UUID обратно — через uuid.UUID(pgID.Bytes). Копирование массива здесь по значению, никаких аллокаций, — и это главный аргумент против хранения UUID строками везде, кроме границы API, где строка удобна пользователю.

UUID в обе стороны: генерация и чтение

GO
package main

import (
        "context"
        "fmt"
        "log"

        "github.com/google/uuid"
        "github.com/jackc/pgx/v5/pgtype"
        "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
        ctx := context.Background()

        pool, err := pgxpool.New(ctx,
                "postgres://app:secret@localhost:5432/app")
        if err != nil {
                log.Fatal(err)
        }
        defer pool.Close()

        gID := uuid.New()

        var pgID pgtype.UUID
        err = pool.QueryRow(ctx,
                "INSERT INTO documents (id, title) VALUES ($1, $2) RETURNING id",
                pgtype.UUID{Bytes: gID, Valid: true}, "паспорт").
                Scan(&pgID)
        if err != nil {
                log.Fatal(err)
        }

        fmt.Println(uuid.UUID(pgID.Bytes))
}

Клиент генерирует uuid.New() и вкладывает 16 байт в pgtype.UUID с Valid: true; RETURNING возвращает колонку uuid, которая сканируется в pgtype.UUID, а обратная конвертация uuid.UUID(pgID.Bytes) возвращает тип google-пакета. Типы совместимы напрямую — оба держат массив [16]byte, и вставка клиентского идентификатора не требует единого SELECT после INSERT.

Проверка: устройство pgtype.UUID

Что хранит внутри себя pgtype.UUID?

Timestamptz: time.Time с честной таймзоной#

Колонка timestamptz хранит момент времени — микросекунды относительно UTC, — а при чтении сервер предъявляет его в таймзоне сессии. В Go простым приёмником служит time.Time: конечные значения сканируются напрямую, и это ещё один случай, когда обёртка не нужна. Чего time.Time не выдерживает, так это трёх исключений. Первое — NULL: без обёртки это знакомая ошибка сканирования. Второе — значения infinity и -infinity, легальные в Postgres: у бессрочной подписки expired_at может быть infinity, и попытка сканировать его в time.Time принесёт ошибку cannot scan Infinity into *time.Time — обёртка pgtype.Timestamptz возвращает такое значение через поле InfinityModifier. Третье — контроль таймзоны чтения: поле ScanLocation у кодека (появилось в 5.6) умеет предъявлять момент в заданной локации, что спасает отчёты, которые должны считаться в одной зоне независимо от сервера. Отдельная предосторожность — колонка timestamp без таймзоны: она хранит «стенные часы» без привязки к точке на оси времени, и после чтения в time.Time нулевая таймзона Go честно сигнализирует, что контекста нет; с ней нельзя считать интервалы через вычитание, если записи писались из разных зон. Правило по умолчанию простое: new таблицы — timestamptz, приёмник — time.Time, обёртка — по исключениям из этого абзаца.

Что вернёт Scan?

Прочитайте код и выберите, что он выведет:

var expired time.Time

err := pool.QueryRow(ctx,
        "SELECT 'infinity'::timestamptz").Scan(&expired)

fmt.Println(err)

NUMERIC: деньги против float64#

NUMERIC — единственный правильный дом для денег: numeric(12,2) хранит ровно двенадцать значащих цифр с двумя после точки, без бинарных сюрпризов. Float64 — двоичная дробь, и десятичные значения вроде 0.1 в ней не помещаются; складывая копейки, вы накапливаете погрешность, которая однажды проявится в отчёте. В Go выбор приёмника для numeric — развилка из трёх путей. Строка: Scan в string возвращает число в текстовой форме базы — точность сохраняется, но арифметику придётся делать самому. Обёртка pgtype.Numeric: Int *big.Int, порядок Exp, флаги NaN, Valid и InfinityModifier — полная точность и готовность к экзотике вроде бесконечности, а Float64Value() отдаёт Float8, когда нужен вывод в график. Обычный float64: работает и для агрегатов, где точность копеек не критична. Моё правило: суммы, балансы и тарифы — Numeric или целые копейки в bigint; метрики, коэффициенты и проценты — float64 без угрызений. Кстати о целых копейках: альтернативная школа вообще не использует numeric, храня суммы в bigint — спор об этом решается бюджетом проекта, но ни один из двух честных вариантов не называется float64.

NUMERIC тремя приёмниками

GO
var (
        amount   pgtype.Numeric
        asText   string
        asFloat  float64
)

err := pool.QueryRow(ctx,
        "SELECT amount FROM payments WHERE id = $1", 42).
        Scan(&amount)

pool.QueryRow(ctx,
        "SELECT amount::text FROM payments WHERE id = $1", 42).Scan(&asText)
pool.QueryRow(ctx,
        "SELECT amount::float8 FROM payments WHERE id = $1", 42).Scan(&asFloat)

f, _ := amount.Float64Value()
fmt.Println(amount.Valid, asText, asFloat, f.Float64)

Один столбец numeric читается тремя путями: pgtype.Numeric держит big.Int с порядком, приведение ::text сохраняет текстовую форму базы, а ::float8 даёт приближение — три приёмника под разные задачи. Float64Value() конвертирует обёртку в Float8: удобно для графиков и отчётных JSON, но не для бухгалтерии. Сравните вывод asText и asFloat на сумме 19.99 — расхождение видно глазом.

Проверка: сумма заказа

Колонка суммы заказа numeric(12,2). Какой приёмник вы выберете для списания денег?

Осталось связать типы с NULL — темой шестого урока. У каждой обёртки pgtype поле Valid играет одну и ту же роль: false означает NULL, и это работает в обе стороны — при вставке pgtype.Text с Valid: false уедет на сервер настоящим NULL, а не пустой строкой, что решает и обратную задачу: записать отсутствие значения из Go. Если обёртки хочется, а моделировать NULL не хочется, посмотрите на подпакет pgtype/zeronull: его типы — четыре семейства: целые, float8, текст и timestamp — превращают NULL в нулевое значение Go и обратно; удобно для моделей, где пустота и ноль договорились совпадать; решение спорное, но осознанное. В обратную сторону работает и правило простых приёмников: NULL в plain time.Time или string приносит ошибку сканирования, так что колонки с NULL обязаны попадать либо в обёртки, либо в указатели — таблица соответствий в начале урока держит это в колонке «когда обёртка обязательна».

Правила соответствия типов одним списком#

  • text, bigint, boolean — простые типы Go; обёртка только для NULL
  • Любая колонка с NULL: обёртка pgtype или указатель — plain приёмник упадёт
  • UUID — pgtype.UUID в данных, строка только на границе API
  • Время — timestamptz и time.Time; timestamp без зоны — осознанное исключение
  • infinity в timestamptz — только pgtype.Timestamptz с InfinityModifier
  • Деньги — pgtype.Numeric или bigint в копейках; float64 — только метрики
  • Вставка NULL из Go: обёртка с Valid: false, а не нулевое значение
  • Сомнения — сверяйтесь с таблицей соответствий в начале урока

Соберите работу с UUID

Восстановите обёртку, конвертацию и проверку Valid.

var id pgtype. err := pool.QueryRow(ctx, "SELECT id FROM documents WHERE title = $1", "паспорт").Scan(&id) if err != nil { log.Fatal(err) } if !id. { log.Fatal("идентификатор NULL") } gID := uuid.UUID(id.)

Соберите вставку с UUID

Расставьте строки генерации и вставки UUID в правильном порядке.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: типы Postgres в Go

Термин

pgtype.UUID

нажмите, чтобы перевернуть
Определение

Обёртка с Bytes [16]byte и Valid. Строка — представление на границе API; сравнения и хранение — в байтах.

1 / 6

Что живёт в отдельных уроках: JSONB и массивы#

pgtype не заканчивается тройкой из заголовка, но остальное заслуживает отдельных экскурсий. JSONB — тема одиннадцатого урока: там структуры и карты Go сканируются прямо в jsonb-колонки, а запросы по JSON-полям разбираются на живых примерах. Массивы Postgres — тема двенадцатого: []string и числовые срезы без ручных конвертаций, когда массив честнее отдельной таблицы. Из мелочи, что пригодится сразу: enum сканируется в string — собственные константы Go с проверкой на границе делают его безопасным; bytea приезжает в []byte; inet умеет в netip-адреса. Составные типы и диапазоны лежат в том же pgtype — они ждут вас в итоговом проекте двадцать пятого урока, где типы из этого урока встретятся с транзакциями и Batch. Пока же закрепите главный навык: перед новой колонкой открыть таблицу соответствий и выбрать приёмник сознательно, а не по привычке от предыдущего проекта.

Как обычно, практика — локально: песочница сайта исполняет тематическую программу на чистом Go без сети, а для живых экспериментов с типами поднимите Postgres из первого урока и прогоните домашнее задание — оно специально построено так, чтобы все три типа побывали в руках дважды: на вставке и на чтении. По курсу дальше: в шестом уроке вы найдёте подробности про Valid и NULL-приёмники, если этот материал потребовал освежить базу; седьмой покажет, как те же обёртки становятся полями структур в CollectRows. Ориентир на будущее: после одиннадцатого и двенадцатого уроков таблица соответствий пополнится JSONB и массивами, и вопрос «какой тип Go для этой колонки» перестанет требовать справочника — ответы переедут в мышечную память.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • pgtype — мост между типами Postgres и Go: обёртки со значением и Valid, кодеки в обе стороны.
  • Простые случаи обходятся без обёрток: text → string, bigint → int64, timestamptz → time.Time.
  • pgtype.UUID держит [16]byte; строка — представление на границе; с google/uuid конвертация в одну строку.
  • timestamptz читается в time.Time; infinity и NULL требуют pgtype.Timestamptz с InfinityModifier.
  • timestamp без таймзоны — «стенные часы»: осторожно с интервалами между зонами.
  • Деньги — pgtype.Numeric или bigint в копейках; float64 накапливает бинарную погрешность.
  • Valid: false при вставке уезжает в базу настоящим NULL, а не пустой строкой.

Домашнее задание

Создайте таблицу documents (id uuid primary key default gen_random_uuid(), title text, amount numeric(12,2), created_at timestamptz default now(), archived_until timestamptz) и вставьте строки двумя путями: с серверным UUID и с uuid.New() через pgtype.UUID. Прочитайте amount всеми тремя приёмниками из урока и сравните вывод. Поставьте archived_until в infinity и докажите, что time.Time не справится, а pgtype.Timestamptz — да. В конце запишите в один комментарий проекта правила: какие приёмники обязательны для каких колонок — это станет чек-листом ревью.

Личные заметкиПусто
Хранятся только в вашем браузере (pgx · Типы Postgres: UUID, TIMESTAMPTZ, NUMERIC)

Вопросы и ответы по уроку

Хранить UUID строкой или байтами?

В данных — байтами через pgtype.UUID: сравнение и запись без конвертаций, 16 байт вместо 36 символов, никакого разговора о регистре и дифисах. Строка уместна на границе API и в логах. Арифметика для самопроверки: в таблице на миллион строк разница между строкой и байтами — это десятки мегабайт лишнего трафика на каждую полную выборку. В самой базе колонка uuid хранит байты независимо от того, как вы вставили значение.

Почему timestamptz, а не timestamp?

timestamptz хранит абсолютный момент — микросекунды от эпохи в UTC, — поэтому чтение в time.Time всегда честно, независимо от зоны сервера. timestamp хранит локальные «стенные часы» без контекста, и интервалы между записями из разных зон считаются неверно. Выбирайте timestamp осознанно — например, для расписаний в одной зоне.

Можно ли обойтись без pgtype вообще?

Частично: конечные timestamptz, тексты без NULL и целые колонки сканируются базовыми типами Go. Обёртки обязательны там, где есть NULL, infinity или точность выше float64. Я начинаю с простых типов и добавляю pgtype по факту появления NULL — таблица соответствий урока подсказывает каждый шаг.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

MongoDB25 уроков~12 чпрактика25 уроков уже доступны

MongoDB в Go: официальный драйвер mongo-driver v2

Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.

  • Подключаться к MongoDB и настраивать клиента
  • Моделировать документы структурами и bson-тегами
  • Делать CRUD, сложные фильтры и проекции
  • Строить агрегации $match/$group/$lookup