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

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

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

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

Scan: строки, NULL и указатели

Сканирование NULL в pgx — первое место, где ломается наивный Scan: колонка, пустая у половины строк, приносит ошибку «cannot scan NULL into *string» и валит запрос, который вчера работал. Разберём, как Scan в pgx превращает NULL из Postgres в nil-указатели и pgtype.Text с полем Valid, чем пустая строка отличается от NULL и где такие вопросы честнее решать на стороне SQL.

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

Сценарий, который повторяется почти в каждом проекте: у таблицы 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, и её исправление

GO
var 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-проверок — и это, как правило, выигрывает у экономии на объявлении.

Два приёмника на одной колонке

GO
rows, 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 превращаетсяСильная сторонаКогда выбирать
*stringnil-указательлаконично, json.Marshal даёт nullAPI-слой и структуры, уезжающие наружу
pgtype.TextValid: falseбез nil-проверок, единый стиль pgtypeслой данных и внутренние структуры
sql.NullStringValid: falseработает и в database/sqlкод, живущий между pgx и *sql.DB

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.

var bio pgtype. err := pool.(ctx, "SELECT bio FROM profiles WHERE id = $1", 7).Scan(&bio) if bio. { fmt.Println(bio.String) } else { fmt.Println("биографии нет") }

Соберите NULL-безопасный цикл

Расставьте строки выборки с указателем в правильном порядке.

Строки

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

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

Карточки: NULL и приёмники

Термин

NULL в Postgres

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

Отсутствие значения, а не пустая строка. Сравнение с NULL даёт UNKNOWN — для проверки нужен IS NULL.

1 / 6

Практика этого урока живёт только на настоящей базе: в песочнице сайта исполняется тематическая программа на чистом Go без сети, а pgx-код вы запускаете локально — Go, контейнер Postgres из первого урока и три-четыре вставки в profiles: с биографией, с пустой строкой и без поля вообще. Прогоните оба приёмника на всех трёх строках и сравните вывод — пять минут эксперимента закрепляют материал надёжнее перечитывания. Дальше по курсу: в седьмом уроке ручные переменные схлопнутся в структуры через CollectRows и RowToStructByName — там NULL-приёмники из этого урока станут полями структур; в десятом — полная экскурсия по pgtype: UUID, NUMERIC, timestamptz и их поля Valid. Домашнее задание ниже закрывает пустоты со всех сторон: вставка, выборка, фильтр и агрегат — четыре запроса, в которых 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, и решите честно: какой вариант читается лучше для вашей логики.

Личные заметкиПусто
Хранятся только в вашем браузере (pgx · Scan: строки, NULL и указатели)

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

Что выбрать: *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 и дефолт.

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

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

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

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

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

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

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