Содержание урока · 10 разделов
- Scan по порядку: как pgx распаковывает колонки
- Указатель *string: nil как честный признак NULL
- pgtype.Text: поля String и Valid вместо nil
- Три приёмника для NULL-колонки
- COALESCE и IS NULL: победить NULL в SQL
- Чек-лист приёмника для nullable-колонки одним списком
- NULL в агрегатах: COUNT, AVG и NOT IN
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сценарий, который повторяется почти в каждом проекте: у таблицы profiles появляется колонка bio — биография пользователя, которую заполняют не все. SELECT тянет её вместе с остальными полями, Scan кладёт в обычную строку, всё работает — до первого пользователя без биографии. Его строка приносит NULL, Scan возвращает ошибку, обработчик отдаёт 500, и вы тратите час на поиск бага, которого в коде нет: база просто честно сообщила, что значения нет. Корень проблемы — разница между двумя «пустотами». NULL в Postgres означает «значения не существует»: колонка не заполнена и не имеет типа содержимого. Пустая строка означает «значение есть, и это строка длиной ноль». В Go для string второго состояния хватает, а первого нет — нулевое значение string и есть пустая строка. Поэтому у Scan три честных способа принять NULL-колонку: указатель *string, где NULL превращается в nil; структура pgtype.Text с полями String и Valid; и sql.NullString из database/sql, работающий и в pgx. Дальше — про каждый по очереди, про цену каждого выбора и про типичные ошибки, на которых строятся баги в проде.
Scan по порядку: как pgx распаковывает колонки#
Повторим механику из пятого урока с поправкой на NULL. Scan копирует колонки результата в приёмники строго по порядку SELECT и подбирает под каждый тип колонки план сканирования: для text-колонки в обычную string план тривиален — скопировать байты. Но у каждой колонки результата, кроме типа, есть ещё состояние: значение или NULL. Приёмники по этому признаку делятся на два лагеря. Nullable-aware — указатель на значение, pgtype.Text, sql.NullString — NULL принимают и превращают в своё представление пустоты. Не-nullable — plain string, int, float64 — NULL принять не могут и возвращают ошибку с внятным текстом: cannot scan NULL into *string — драйвер называет тип приёмника, который не справился. Важная деталь, на которой спотыкаются: ошибка вылетает не на Query, а в момент Scan конкретной строки. Сам SELECT выполнен успешно, сервер отдал данные, курсор открыт — а падает уже распаковка. В логах это выглядит так: запрос прошёл, а сервис упал позже, и связать одно с другим без понимания механики трудно. Проверяйте err именно после Scan, а не только после Query, — это правило уже звучало в пятом уроке, здесь у него появляется второе основание.
Ошибка, которую приносит NULL, и её исправление
GOvar title string
var bio string
err := pool.QueryRow(ctx,
"SELECT title, bio FROM profiles WHERE id = $1", 42).
Scan(&title, &bio)
// bio = NULL -> err: cannot scan NULL into *string
// Исправление: приёмник-указатель
var bioPtr *string
err = pool.QueryRow(ctx,
"SELECT title, bio FROM profiles WHERE id = $1", 42).
Scan(&title, &bioPtr)
if bioPtr == nil {
fmt.Println("биографии нет")
} else {
fmt.Println(*bioPtr)
}Первый Scan падает на строке пользователя без биографии: колонка NULL, а string не умеет её принять. Во втором варианте приёмник — указатель на string: для NULL pgx оставит nil, для значения выделит свежую строку. Заметьте, что передаём мы &bioPtr — адрес указателя, а не сам указатель.
Указатель *string: nil как честный признак NULL#
Под капотом pgx видит приёмник типа «указатель на указатель» — вы передали адрес переменной, в которой лежит *string, — и включает специальный план: для NULL он оставляет указатель nil, для значения аллоцирует строку и сканирует в неё. Это самый лаконичный путь: объявление поля и обработка пустоты не требуют никаких дополнительных типов. У nil есть и практические бонусы на границах приложения: json.Marshal превращает nil-указатель в null, а пустую строку — в пустые кавычки, поэтому API, который отдаёт клиенту null для отсутствующей биографии, получается почти бесплатно. Обратная сторона — nil-дисциплина по всему коду. Каждое использование требует проверки: bio != nil, иначе разыменование приносит панику; строковые методы вызываются только после проверки; в шаблонах и отчётах появляется ветка «поле есть — поля нет». Работает и обратная вставка: nil-указатель в аргументах INSERT уезжает на сервер ровно как NULL, а указатель на строку — как её значение, так что запись тоже обходится без преобразований. Если указатели вспоминаются с трудом — вернитесь к материалу про ссылки и разыменование в базовом курсе: без уверенной работы с nil в этом разделе делать нечего.
pgtype.Text: поля String и Valid вместо nil#
Второй путь — pgtype.Text: структура с двумя полями, String и Valid. NULL превращается в Valid: false и пустую String; обычное значение — в Valid: true и саму строку. Разыменований нет, паник нет, есть явная ветка if v.Valid — код с ней читается однозначно, даже если читатель ни разу не видел pgtype. Тот же паттерн реализует sql.NullString из database/sql: в нативном pgx он тоже работает, потому что реализует интерфейс sql.Scanner, который pgx уважает наравне со своими типами. Почему я почти всегда беру pgtype.Text, а не sql.NullString? Он родной для этого драйвера: единый стиль со всеми типами десятого урока — UUID, NUMERIC, timestamptz, — одна и та же конвенция Valid во всём слое данных, и никакого моста через интерфейс database/sql. Для sql.NullString у меня осталось одно применение: код, который живёт в двух мирах — pgx и *sql.DB из девятого урока. Внутри же нативного кода смешивать две библиотеки нулевых типов — лишняя когнитивная нагрузка без выгоды. Объявление с pgtype.Text чуть многословнее указателя, зато полностью избавляет слой данных от nil-проверок — и это, как правило, выигрывает у экономии на объявлении.
Два приёмника на одной колонке
GOrows, err := pool.Query(ctx,
"SELECT id, bio, bio FROM profiles ORDER BY id LIMIT 5")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var (
id int64
bioPtr *string
bio pgtype.Text
)
if err = rows.Scan(&id, &bioPtr, &bio); err != nil {
log.Fatal(err)
}
fmt.Printf("%d указатель=%v текст=%q valid=%v\n",
id, bioPtr, bio.String, bio.Valid)
}Колонку bio здесь тянут дважды — и в указатель, и в pgtype.Text: Scan раскладывает каждую копию по своему приёмнику. Для строки без биографии указатель напечатается как <nil>, а Text покажет пустые кавычки и valid=false. Для заполненной оба варианта вернут одно и то же содержимое.
Что напечатает фрагмент?
Прочитайте код и выберите, что он выведет:
var bio *string
err := pool.QueryRow(ctx,
"SELECT bio FROM profiles WHERE id = $1", 42).
Scan(&bio)
fmt.Println(err, bio == nil)Три приёмника для NULL-колонки#
| Приёмник | NULL превращается | Сильная сторона | Когда выбирать |
|---|---|---|---|
| *string | nil-указатель | лаконично, json.Marshal даёт null | API-слой и структуры, уезжающие наружу |
| pgtype.Text | Valid: false | без nil-проверок, единый стиль pgtype | слой данных и внутренние структуры |
| sql.NullString | Valid: false | работает и в database/sql | код, живущий между pgx и *sql.DB |
Типичная ошибка после перехода на указатели: проверять bio == "" как признак отсутствия биографии. Теперь у вас два независимых состояния пустоты: форму отправили с пустым полем — в базе строка длиной ноль, поле не заполняли вообще — NULL. Отчёт «сколько пользователей без биографии» через bio == "" покажет одно число, через IS NULL — другое, и оба будут правы. Моё рабочее правило: решите заранее, существует ли для вашей колонки пустая строка как отдельное состояние. Если нет — запрещайте её на входе (COALESCE из формы, NOT NULL с DEFAULT), и указатель скажет только «есть или нет». Если существует — храните оба различия явно и тестируйте оба сценария.
COALESCE и IS NULL: победить NULL в SQL#
Часть NULL-проблем честнее решать до Go. COALESCE(bio, '') подставляет пустую строку вместо NULL прямо в выборке — приёмник остаётся обычной string, и nil-проверки в коде не нужны вовсе. Это оправдано, когда нулевость для бизнес-логики не значима: показать биографию или заглушку, посчитать отображаемое имя. Фильтрация — через IS NULL и IS NOT NULL: WHERE bio IS NULL вернёт ровно строки без значения. Ловушка, которую обязан знать каждый: сравнение bio = NULL никогда не истинно — даже для строк с NULL, потому что NULL в сравнении даёт не «да» и не «нет», а UNKNOWN, и строка просто не попадает в результат. Это не баг драйвера, а трёхзначная логика SQL, о которой стандарт договорился задолго до Go. Сортировка тоже помнит про NULL: по умолчанию при ORDER BY bio DESC нулевые значения оказываются первыми — лента с анонимными профилями наверху обычно не то, чего хочет продукт; лечится явно — ORDER BY bio DESC NULLS LAST. Граница между «решаем в SQL» и «решаем в Go» простая: если различие NULL и пустоты значимо для поведения — решаем в Go с приёмниками из этого урока; если значим только вывод — пусть SQL нормализует сам.
Проверка: ошибка сканирования
Scan вернул ошибку «cannot scan NULL into *string». Что произошло?
Чек-лист приёмника для nullable-колонки одним списком#
- Определите, различаете ли вы NULL и пустую строку — от этого зависит весь выбор
- Слой данных и внутренние структуры: pgtype.Text с проверкой Valid
- API-ответы, где клиенту нужен null: указатель *string
- Код, который работает и с *sql.DB: sql.NullString из database/sql
- Чистый вывод без ветвлений: COALESCE в SELECT, обычный string в Go
- Сортировка nullable-колонки: явно NULLS LAST или NULLS FIRST, без дефолта
- После цикла по rows — rows.Err(), ошибка сканирования одной строки не должна глушить остальные
NULL в агрегатах: COUNT, AVG и NOT IN#
NULL меняет ответы не только при сканировании строк, но и внутри запросов — и это вторая половина темы. COUNT(*) считает строки, COUNT(bio) — только строки, где биография есть: таблица из пяти пользователей с двумя NULL покажет 5 и 3, и оба числа правильные, просто они отвечают на разные вопросы. AVG и SUM игнорируют NULL, поэтому средний возраст по неполным данным считается по заполненным строкам — возможно, то, что нужно, а возможно, и тихое искажение отчёта. Отдельная мина — SUM по пустому набору: он возвращает не ноль, а NULL, и после Scan в обычный int вы получите уже знакомую ошибку сканирования; стандартное лечение — COALESCE(SUM(views), 0). Самая коварная конструкция — NOT IN с подзапросом: если подзапрос вернул хоть один NULL, WHERE id NOT IN (...) не вернёт ничего, потому что сравнение с NULL даёт UNKNOWN для каждой строки. Разбирают такие баги редко и дорого — проще не выпускать: в запросах с NOT IN добавляйте WHERE ... IS NOT NULL во внутренний выбор или переходите на NOT EXISTS. Все перечисленные сюрпризы живут в SQL и воспроизводятся без всякого Go — их стоит один раз увидеть на тестовой таблице.
Что напечатает второй фрагмент?
Прочитайте код и выберите, что он выведет:
rows, err := pool.Query(ctx,
"SELECT COUNT(*), COUNT(bio) FROM profiles")
// в таблице 5 строк, у двух bio = NULL
rows.Next()
var total, withBio int64
rows.Scan(&total, &withBio)
fmt.Println(total, withBio)Проверка: сравнение с NULL
Что вернёт SELECT ... WHERE bio = NULL?
Соберите NULL-безопасное чтение
Восстановите код: приёмник pgtype, вызов QueryRow и проверка Valid.
Соберите NULL-безопасный цикл
Расставьте строки выборки с указателем в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: NULL и приёмники
Практика этого урока живёт только на настоящей базе: в песочнице сайта исполняется тематическая программа на чистом Go без сети, а pgx-код вы запускаете локально — Go, контейнер Postgres из первого урока и три-четыре вставки в profiles: с биографией, с пустой строкой и без поля вообще. Прогоните оба приёмника на всех трёх строках и сравните вывод — пять минут эксперимента закрепляют материал надёжнее перечитывания. Дальше по курсу: в седьмом уроке ручные переменные схлопнутся в структуры через CollectRows и RowToStructByName — там NULL-приёмники из этого урока станут полями структур; в десятом — полная экскурсия по pgtype: UUID, NUMERIC, timestamptz и их поля Valid. Домашнее задание ниже закрывает пустоты со всех сторон: вставка, выборка, фильтр и агрегат — четыре запроса, в которых NULL ведёт себя по-разному.
CollectRows из следующего урока не отменяет этого материала — он его использует: поля структуры сканируются теми же планами, и nullable-поле структуры обязано быть указателем или pgtype-типом, иначе структура упадёт на первом NULL. Понимание приёмников здесь — страховка от загадочных ошибок там.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- NULL — отсутствие значения, а не пустая строка; у Scan есть nullable-aware и не-nullable приёмники.
- Ошибка cannot scan NULL into *string вылетает в момент Scan, а не на Query.
- *string превращает NULL в nil — идеально для API, где клиент ждёт null, но требует nil-проверок.
- pgtype.Text хранит String и Valid — родной нулевой тип pgx для слоя данных.
- sql.NullString работает и в pgx, но нужен в основном для кода, живущего между pgx и database/sql.
- COALESCE и IS NULL решают NULL-вопросы в SQL; bio = NULL всегда даёт UNKNOWN и пустой результат.
- COUNT(bio) пропускает NULL, SUM по пустому набору возвращает NULL, NOT IN с NULL в подзапросе возвращает пустоту.
Домашнее задание
Создайте таблицу profiles (id bigserial primary key, title text not null, bio text) и вставьте три строки: с биографией, с пустой строкой и с NULL. Напишите выборку обоими приёмниками — *string и pgtype.Text — и напечатайте три разных представления пустоты. Добавьте отчёт: сколько всего профилей, сколько с биографией, сколько без — через COUNT и IS NULL. Затем уберите *string из кода, заменив всё на COALESCE в SQL, и решите честно: какой вариант читается лучше для вашей логики.
Личные заметкиПусто
Вопросы и ответы по уроку
Что выбрать: *string или pgtype.Text?
Рабочая конвенция: pgtype.Text внутри слоя данных — там, где важны отсутствие nil-паник и единый стиль pgtype, и указатели на границе API — там, где клиент ждёт null в JSON. Конвертация между ними — одна строка: у Text есть поле String, которое копируется в указатель после проверки Valid, так что выбирать глобально навсегда не нужно. Что действительно важно — не смешивать оба стиля в одной структуре без причины: половина nullable-полей в указателях и половина в обёртках читается хуже, чем любое из решений целиком.
Почему Query не вернул ошибку, а Scan вернул?
Query отвечает за выполнение: сервер разобрал и выполнил SQL, курсор открыт. Scan — этап распаковки: он сталкивается с фактическим содержимым строки и обнаруживает, что NULL некуда класть. Проверяйте err после обоих вызовов — это два разных класса ошибок.
Можно ли сделать колонку non-nullable и забыть про всё это?
Да, и это лучший вариант там, где это честно: NOT NULL с DEFAULT прижимает пустоты к одному значению, и обычный string в Go остаётся безопасным. NULL-приёмники нужны для колонок, у которых отсутствие значения — реальное состояние данных, а не ошибка. Честный тест при проектировании: если вы не можете назвать, что означает NULL для этой колонки в терминах продукта, скорее всего, колонке нужен NOT NULL и дефолт.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup