---
title: "Scan: строки, NULL и указатели"
description: "Scan в pgx: как сканировать NULL-колонки Postgres в Go — указатель *string, pgtype.Text с полем Valid и ловушка пустых значений. Ошибки и исправления."
course: "PostgreSQL в Go: драйвер pgx с нуля до продакшена"
url: "https://golangmaster.ru/courses/pgx/lessons/06-skanerovanie-strok/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «PostgreSQL в Go: драйвер pgx с нуля до продакшена», урок 6 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/pgx/lessons/06-skanerovanie-strok/*

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

Сценарий, который повторяется почти в каждом проекте: у таблицы 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, а указатель на строку — как её значение, так что запись тоже обходится без преобразований. Если указатели вспоминаются с трудом — вернитесь к материалу про ссылки и разыменование в [базовом курсе](/courses/go-basics/): без уверенной работы с 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. Для заполненной оба варианта вернут одно и то же содержимое.

### Что напечатает фрагмент?

```go
var bio *string

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

fmt.Println(err, bio == nil)
```

**Что выведет программа?**

- ✅ <nil> true — ошибки нет, указатель равен nil
- cannot scan NULL into *string true
- <nil> false — pgx кладёт пустую строку
- паника на разыменовании nil

NULL в приёмник-указатель сканируется штатно: pgx оставляет переменную nil и возвращает nil-ошибку. Сравнение bio == nil даёт true. Разыменования здесь нет, поэтому и паники нет — она случилась бы только при попытке напечатать *bio без проверки.

### Три приёмника для NULL-колонки

| Приёмник | NULL превращается | Сильная сторона | Когда выбирать |
| --- | --- | --- | --- |
| *string | nil-указатель | лаконично, json.Marshal даёт null | API-слой и структуры, уезжающие наружу |
| pgtype.Text | Valid: false | без nil-проверок, единый стиль pgtype | слой данных и внутренние структуры |
| sql.NullString | Valid: false | работает и в database/sql | код, живущий между pgx и *sql.DB |

> **Живое место: пустая строка — это не NULL.** Типичная ошибка после перехода на указатели: проверять 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». Что произошло?

- Соединение с базой разорвалось посреди запроса
- ✅ В колонке оказался NULL, а приёмник обычной строки его не принимает
- Запрос вернул больше одной строки
- В SQL опечатка — сервер не смог распарсить запрос

Текст ошибки называет приёмник (*string) и причину: NULL в колонке при не-nullable приёмнике. Лечится сменой приёмника на указатель или pgtype.Text — либо COALESCE на стороне SQL. Соединение тут ни при чём: SELECT успел выполниться успешно.

### Чек-лист приёмника для 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 — их стоит один раз увидеть на тестовой таблице.

### Что напечатает второй фрагмент?

```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)
```

**Что выведет программа?**

- ✅ 5 3 — COUNT(*) считает строки, COUNT(bio) пропускает NULL
- 5 5 — NULL считается как пустая строка
- ошибка сканирования из-за NULL в биографиях
- 3 3 — обе функции игнорируют NULL

COUNT(*) не смотрит на содержимое и вернёт пять строк; COUNT(bio) считает только не-NULL значения — три. Агрегатные функции никогда не приносят NULL в COUNT, поэтому сканирование в int64 проходит без сюрпризов.

### Проверка: сравнение с NULL

**Вопрос:** Что вернёт SELECT ... WHERE bio = NULL?

- Все строки с пустой биографией
- Ошибку синтаксиса — так сравнивать нельзя
- ✅ Пустой результат — сравнение с NULL даёт UNKNOWN
- Все строки таблицы

Синтаксически запрос корректен, но сравнение любого значения с NULL даёт UNKNOWN, и строка не проходит фильтр. Для поиска отсутствующих значений нужен IS NULL — специальный оператор трёхзначной логики SQL.

### Соберите NULL-безопасное чтение

Восстановите код: приёмник pgtype, вызов QueryRow и проверка Valid.

```go
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("биографии нет")
}
```

Подсказка: Нулевой тип для текстовых колонок — pgtype.Text, одна строка читается QueryRow, а факт наличия значения хранит Valid.

Правильные ответы в пропусках: `Text`, `QueryRow`, `Valid`

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

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

Правильный порядок строк:

1. `rows, err := pool.Query(ctx, "SELECT id, bio FROM profiles")`
2. `if err != nil { log.Fatal(err) }`
3. `defer rows.Close()`
4. `for rows.Next() {`
5. `  var id int64`
6. `  var bio *string`
7. `  rows.Scan(&id, &bio)`
8. `  if bio != nil { fmt.Println(id, *bio) }`

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

- **NULL в Postgres** — Отсутствие значения, а не пустая строка. Сравнение с NULL даёт UNKNOWN — для проверки нужен IS NULL.
- ***string как приёмник** — NULL превращается в nil, значение — в свежую строку. json.Marshal отдаёт nil как null. Требует nil-проверок в коде.
- **pgtype.Text** — Нулевой тип pgx с полями String и Valid. NULL — Valid: false. Родной стиль для слоя данных.
- **cannot scan NULL into *string** — Ошибка Scan для не-nullable приёмника. Вылетает в момент сканирования строки, а не на Query.
- **COALESCE(bio, '')** — Подстановка значения вместо NULL на стороне SQL — приёмник в Go остаётся обычной string.
- **COUNT(*) против COUNT(bio)** — Первая считает строки, вторая — только не-NULL значения колонки. SUM по пустому набору возвращает NULL, а не ноль.

Практика этого урока живёт только на настоящей базе: в песочнице сайта исполняется тематическая программа на чистом Go без сети, а pgx-код вы запускаете локально — Go, контейнер Postgres из первого урока и три-четыре вставки в profiles: с биографией, с пустой строкой и без поля вообще. Прогоните оба приёмника на всех трёх строках и сравните вывод — пять минут эксперимента закрепляют материал надёжнее перечитывания. Дальше по курсу: в [седьмом уроке](/courses/pgx/lessons/07-struct-scan/) ручные переменные схлопнутся в структуры через CollectRows и RowToStructByName — там NULL-приёмники из этого урока станут полями структур; в [десятом](/courses/pgx/lessons/10-tipy-dannyh-postgres/) — полная экскурсия по pgtype: UUID, NUMERIC, timestamptz и их поля Valid. Домашнее задание ниже закрывает пустоты со всех сторон: вставка, выборка, фильтр и агрегат — четыре запроса, в которых NULL ведёт себя по-разному.

> **Зачем эти тонкости перед структурами.** CollectRows из следующего урока не отменяет этого материала — он его использует: поля структуры сканируются теми же планами, и nullable-поле структуры обязано быть указателем или pgtype-типом, иначе структура упадёт на первом NULL. Понимание приёмников здесь — страховка от загадочных ошибок там.

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

- 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 и дефолт.
