---
title: "Exec и Query: первые запросы"
description: "Первые запросы pgx: Exec для INSERT и DDL с CommandTag и RowsAffected, QueryRow и ErrNoRows, Query с циклом Next и Scan, ловушки rows.Err и rows.Close."
course: "PostgreSQL в Go: драйвер pgx с нуля до продакшена"
url: "https://golangmaster.ru/courses/pgx/lessons/05-prostye-zaprosy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Exec и Query: первые запросы

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

Exec и Query — два рабочих глагола, на которых держится большинство SQL в Go-сервисе, и первые запросы в pgx делаются именно ими: Exec отправляет команды без возврата строк, Query приносит результат, а QueryRow — его частный случай для одной строки. Разберём три вызова на живых INSERT и SELECT, научимся читать CommandTag, обрабатывать ErrNoRows и закрывать курсоры так, чтобы соединения не утекали.

Первый запрос всегда проще, чем кажется: у pgx всего три точки входа для отправки SQL. Exec — для команд, у которых нет возвращаемых строк: INSERT, UPDATE, DELETE, CREATE TABLE. Query — для SELECT, где результат — набор строк, который вы разбираете курсором. QueryRow — удобная обёртка над Query для случая ровно одной ожидаемой строки. Разница между ними не в стиле, а в контракте: Exec возвращает счётчик затронутых строк и ничего больше, Query — курсор, который надо пройти и закрыть, QueryRow — одну строку и ошибку ErrNoRows, если она не нашлась. Выбор правильного вызова — половина аккуратного кода: INSERT через Query сработает, но вернёт курсор ради нуля строк, а SELECT через Exec просто выполнится и выбросит результат, как будто запроса не было. Дальше — по одному вызову за раз, от простого к сложному, на одной таблице notes из четвёртого урока. Имена вызовов намеренно совпадают с database/sql — до него мы доберёмся в девятом уроке, а привычки Scan и Next переносятся оттуда без перепрививки. Приятная разница: лишняя колонка или лишний приёмник у pgx ловятся сразу ошибкой рассинхрона — драйвер не будет молча проглатывать данные.

### Exec: INSERT и команды без результата

Сигнатура: Exec(ctx, sql, args...) возвращает pgconn.CommandTag и ошибку. CommandTag — отчёт сервера о том, что произошло: главное поле — RowsAffected, число затронутых строк. INSERT одной строки даст единицу, UPDATE по пяти строкам — пятёрку, DELETE по несуществующему идентификатору — ноль, и это не ошибка, а честный ответ базы. Значение RowsAffected — не украшение: это способ узнать, сколько строк реально изменилось, не делая лишний SELECT COUNT перед или после. DDL — тоже Exec: CREATE TABLE и CREATE INDEX идут тем же вызовом, потому что схемы возвращают строк не больше, чем данные. У CommandTag есть и текстовое лицо — метод String(): для INSERT одной строки это что-то вроде INSERT 0 1, где ноль — наследие устаревшего поля oid, а единица — затронутые строки; отчёт UPDATE выглядит как UPDATE 5. В логах и тестах такой текст удобнее числа: сразу видно, какая команда и сколько строк за ней стоит. Плейсхолдеры $1 и $2 в аргументах защищают от SQL-инъекций — тема настолько важная, что восьмой урок полностью посвящён аргументам запроса; здесь достаточно правила: значения едут через аргументы, никогда через склейку строк. И про типы: pgx не чинит несовместимости молча — аргумент уезжает на сервер в родном для своего типа виде, а несовпадение ловится ошибкой кодирования или сканирования, а не сюрпризом в данных. Текст у такой ошибки дружелюбный: pgx называет и колонку, и тип, который не смог закодировать — читайте её целиком, прежде чем менять структуру.

### Два Exec: вставка и удаление

```go
package main

import (
        "context"
        "fmt"
        "log"

        "github.com/jackc/pgx/v5"
)

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

        conn, err := pgx.Connect(ctx,
                "postgres://app:secret@localhost:5432/app?sslmode=disable")
        if err != nil {
                log.Fatal(err)
        }
        defer conn.Close(ctx)

        _, err = conn.Exec(ctx,
                "INSERT INTO notes (title, body) VALUES ($1, $2)",
                "Первая заметка", "Тело первой заметки")
        if err != nil {
                log.Fatal(err)
        }

        tag, err := conn.Exec(ctx, "DELETE FROM notes WHERE title = $1", "черновик")
        if err != nil {
                log.Fatal(err)
        }

        fmt.Println("удалено строк:", tag.RowsAffected())
}
```

Два Exec рядом: первый вставляет заметку с плейсхолдерами $1 и $2 — значения едут отдельными аргументами, а не внутри SQL; второй удаляет черновики и печатает RowsAffected. Первое возвращаемое значение в первом вызове выброшено подчёркиванием: CommandTag нужен, только когда вы спрашиваете у базы, сколько строк затронуто.

### QueryRow: одна строка и ErrNoRows

Когда по смыслу ожидается ровно одна строка — профиль пользователя по идентификатору, текущий курс валюты, итог счётчика — короче всего QueryRow: row := conn.QueryRow(ctx, sql, args...), затем row.Scan(&переменные). Scan распаковывает колонки в переменные Go по порядку. Отдельный контракт: если SELECT не нашёл ни строки, Scan вернёт pgx.ErrNoRows. Это не сбой — штатный ответ на вопрос «такого нет», и код обрабатывает его явно: if errors.Is(err, pgx.ErrNoRows) — отдать 404, создать запись или вернуть дефолт. Ошибки сканирования приходят, когда типы колонок и приёмников не совпали: скан текста в int упадёт с внятным текстом, а не паникой. Тонкость, о которую бьются новички: QueryRow возвращает объект сразу, без ошибки — ошибка живёт в Scan и доставается в момент вызова, поэтому проверить её нужно там, а не в строке QueryRow. ErrNoRows живёт и под именем pgconn.ErrNoRows — это одно и то же значение с двумя алиасами, так что чужой код может проверять любой из них, а errors.Is различит без ошибок. И помните: Scan распаковывает колонки строго по порядку SELECT, а не по именам — переставили поля в запросе, переставьте и приёмники.

### Одна строка и три ветки разбора

```go
var title string
var body string

err := conn.QueryRow(ctx,
        "SELECT title, body FROM notes WHERE id = $1", 1).
        Scan(&title, &body)

switch {
case errors.Is(err, pgx.ErrNoRows):
        fmt.Println("заметки нет")
case err != nil:
        log.Fatal(err)
default:
        fmt.Println(title, "-", body)
}
```

Классический разбор одной строки: Scan возвращает ошибку, которая может быть ErrNoRows, сетевым сбоем или ошибкой типов — switch раскладывает их по-разному. errors.Is, а не сравнение через ==: обёртки поверх ошибки должны оставаться различимыми. Этот же паттерн дальше переедет в сканирование структур — шестой и седьмой уроки о нём.

### INSERT ... RETURNING: получить id вставленной строки

Самый частый вопрос после первого INSERT: как узнать сгенерированный идентификатор? Ответ Postgres — конструкция RETURNING: INSERT INTO notes (title) VALUES ($1) RETURNING id возвращает строки, а значит для него нужен не Exec, а QueryRow. row := conn.QueryRow(ctx, insertSQL, title); затем row.Scan(&id) — и в руках у вас свежий идентификатор, без лишнего SELECT и без гонок с параллельными вставками. Exec здесь потерял бы результат: он для команд без возвращаемых строк. RETURNING умеет отдавать несколько колонок и работает с UPDATE и DELETE тоже — так за одну поездку по сети забирают и изменённые данные. Умеет он и вычисленные значения: UPDATE ... RETURNING updated_at вернёт метку времени, поставленную сервером по своим часам, — для аудит-логов это разница между «примерно когда» и «точно когда». С клиентской точки зрения разница в выборе вызова тривиальна, но она расставляет границу между двумя контрактами: есть строки — QueryRow или Query, строк нет — Exec. Запомните это правило, и половина путаницы с pgx исчезнет. И да, RETURNING — не только про id: им удобно забирать обновлённые счётчики или версии строк, чтобы не ходить повторным SELECT за данными, которые сервер уже держит в руках.

> **Забытый rows.Err() — данные исчезают молча.** Сетевой сбой посреди выборки выглядит как обычное окончание: rows.Next() вернёт false, цикл завершится, и код запишет в ответ пустой список — без единой ошибки. Реальную причину хранит rows.Err(), и без его проверки появляется «иногда пустой отчёт», который невозможно воспроизвести. Правило без исключений: после каждого цикла по rows — проверка rows.Err(); после каждого раннего выхода — rows.Close(). Проверка дешёвая — один вызов и одна строка кода, а ловит класс ошибок, который иначе расследуется по логам сервера на следующий день.

### Query: курсор, Next, Scan и Close

Query возвращает pgx.Rows — курсор по результату выборки. Канонический цикл: for rows.Next() { err := rows.Scan(&a, &b); ... }, а после цикла — rows.Err(). Next тянет следующую строку с сервера и возвращает false на конце выборки или при ошибке; Scan распаковывает текущую строку в переменные; Err докладывает, была ли ошибка по дороге. Про Close честно: в нативном pgx курсор закрывается сам при полном проходе, но при раннем выходе — return внутри цикла — закрытие остаётся за вами, поэтому defer rows.Close() сразу после Query — дешёвая страховка, особенно с пулом: незакрытый курсор держит коннект занятым. Ещё одна тонкость одиночного Conn: двух открытых курсоров он не терпит — пока первый не пройден, второй Query на том же соединении будет ждать. Конкурентные запросы делят соединение через пул из четвёртого урока, а не напрямую через один Conn. Механика выборки предсказуемая: на Query сервер отправляет описание колонок, и после каждого Next pgx тянет очередную строку протокола — буфера «на всю выборку» нет, миллионная таблица не съест память приложения, но и быстрее сети не будет. Весь результат сразу собирается в срез обобщениями — это тема седьмого урока.

### Канонический цикл выборки

```go
rows, err := conn.Query(ctx,
        "SELECT title, created_at FROM notes ORDER BY created_at DESC LIMIT 10")
if err != nil {
        log.Fatal(err)
}
defer rows.Close()

for rows.Next() {
        var title string
        var createdAt time.Time

        if err = rows.Scan(&title, &createdAt); err != nil {
                log.Fatal(err)
        }

        fmt.Println(createdAt.Format("2006-01-02"), title)
}

if err = rows.Err(); err != nil {
        log.Fatal(err)
}
```

Полный канон выборки: Query с сортировкой и лимитом, defer rows.Close() на случай раннего выхода, цикл Next-Scan с переменными на каждую итерацию, финальная проверка rows.Err(). time.Time сканируется из колонки с датой напрямую — базовые типы Postgres мапятся без конвертаций. Именно этот цикл в седьмом уроке схлопнется в одну строку CollectRows.

### Три вызова pgx для первых запросов

| Вызов | Возвращает | Для чего | Типичная ошибка новичка |
| --- | --- | --- | --- |
| Exec | pgconn.CommandTag (RowsAffected) | INSERT, UPDATE, DELETE, DDL | ждать строки от команды без результата |
| QueryRow | Row и Scan, ErrNoRows | ровно одна строка: профиль, курс, итог | игнорировать ErrNoRows и отдавать нулевые значения |
| Query | pgx.Rows — курсор | списки: лента, отчёт, выборка | забыть rows.Close() и rows.Err() |

> **Срез структур — одной строкой.** Ручной цикл — фундамент, но писать его на каждый SELECT утомительно: pgx v5 с обобщениями умеет собирать []Note одним вызовом CollectRows(rows, pgx.RowToStructByName[Note]). Тому посвящён [седьмой урок](/courses/pgx/lessons/07-struct-scan/) — пока закрепите ручной вариант: он понадобится для нестандартных случаев и чтения чужого кода. Написанный руками цикл ещё и честно показывает цену: сколько переменных и проверок живёт под капотом каждого сканирования.

### Несколько команд одним Exec

Exec без аргументов умеет выполнять несколько SQL-команд одной поездкой: conn.Exec(ctx, "CREATE TABLE a (...); CREATE INDEX ...;") — pgx переключается на простой протокол и отдаёт стейтменты пакетом. Только не путайте пачку с транзакцией: если второй стейтмент упадёт, первый останется выполненным — атомарность приедет отдельно, тринадцатым уроком. Удобно для инициализации схемы в тестах и демо-скриптах. Как только появляются аргументы, правило меняется: один вызов — одна команда с плейсхолдерами, потому что расширенный протокол принимает по одному стейтменту за раз. Сотни однотипных INSERT идут не через цикл Exec, а через Batch и COPY FROM — восемнадцатый и девятнадцатый уроки покажут разницу в разы на реальных цифрах. Пока же запомните порядок: одна команда с аргументами — Exec, пачка однотипных — Batch, тонны строк — COPY. И напомню про формат курса: песочница сайта не умеет сеть и Postgres, поэтому эти программы исполняются локально — Go и команда docker run из второго урока; в песочнице урока откроется тематическая программа на чистом Go, а типизация аргументов и защита от инъекций — тема восьмого урока, до которого — никаких склеек строк в SQL.

### Проверка: Exec

**Вопрос:** Что возвращает Exec после успешного INSERT?

- Срез вставленных строк
- ✅ CommandTag, из которого достаётся RowsAffected
- Курсор rows, который надо закрыть
- Ничего — только error

Exec возвращает pgconn.CommandTag — отчёт сервера о выполнении: RowsAffected и другие поля. Для INSERT одной строки RowsAffected равен единице; для DDL значение можно игнорировать. Курсор возвращает Query, а не Exec.

### Проверка: пустой результат

**Вопрос:** SELECT по id не нашёл строку. Что вернёт Scan у QueryRow?

- nil — переменные останутся нулевыми
- ✅ pgx.ErrNoRows
- панику
- ошибку соединения

Пустой результат — штатная ситуация, и pgx превращает её в pgx.ErrNoRows: различайте её через errors.Is и отвечайте 404 или дефолтом. Игнорирование этой ошибки — источник загадочных нулей в API-ответах.

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

```go
tag, err := conn.Exec(ctx,
    "UPDATE notes SET archived = true WHERE author_id = $1", 7)
if err != nil {
    log.Fatal(err)
}

fmt.Println("строк:", tag.RowsAffected())
```

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

- строк: 1
- ✅ строк: 0 — и это не ошибка
- log.Fatal с ошибкой, потому что ни одна строка не обновилась
- паника: RowsAffected у пустого тега

UPDATE, не задевший ни одной строки, — успешный запрос: сервер честно отвечает, что строк затронуто ноль. err равен nil, RowsAffected равен нулю. Отсутствие строк — данные, а не сбой; ошибка приходит при проблемах соединения или SQL.

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

```go
rows, err := conn.Query(ctx,
    "SELECT title FROM notes WHERE id > 999")
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

count := 0
for rows.Next() {
    count++
}

fmt.Println(count, rows.Err())
```

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

- ✅ 0 <nil> — цикл не выполнится, ошибок нет
- 0 not found — курсор вернёт ошибку отсутствия строк
- программа упадёт на Scan
- 0 ErrNoRows — как у QueryRow

Query с пустым результатом возвращает живой, но пустой курсор: Next сразу false, цикл пропускается, Err равен nil. ErrNoRows существует только у QueryRow — пути одной строки. Два разных контракта: один ряд — QueryRow, список — Query.

### Соберите цикл выборки

Восстановите канонический цикл: курсор, закрытие, обход и финальная проверка.

```go
rows, err := conn.___(ctx, "SELECT title FROM notes")
if err != nil {
        log.Fatal(err)
}
defer rows.___()

for rows.___() {
        var title string
        if err = rows.Scan(&title); err != nil {
                log.Fatal(err)
        }
}

if err = rows.___(); err != nil {
        log.Fatal(err)
}
```

Подсказка: Курсор открывает Query, закрывает Close, шагает Next, а итоговую ошибку хранит Err.

Правильные ответы в пропусках: `Query`, `Close`, `Next`, `Err`

### Соберите выборку из строк

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

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

1. `rows, err := conn.Query(ctx, sql)`
2. `if err != nil { log.Fatal(err) }`
3. `defer rows.Close()`
4. `for rows.Next() {`
5. `  var title string`
6. `  rows.Scan(&title)`
7. `}`
8. `err = rows.Err()`

### Карточки: первые запросы

- **Exec** — Выполняет SQL без возврата строк: INSERT, UPDATE, DELETE, DDL. Возвращает CommandTag и error.
- **CommandTag** — Отчёт сервера о выполнении команды. Главное поле — RowsAffected, число затронутых строк.
- **QueryRow** — Ожидает ровно одну строку: Scan в переменные, отсутствие строк — pgx.ErrNoRows.
- **pgx.ErrNoRows** — Штатный ответ на пустой результат QueryRow. Проверяется через errors.Is — это данные, а не сбой.
- **rows.Next()** — Тянет следующую строку курсора; false — конец выборки или ошибка по дороге.
- **rows.Err()** — Хранит ошибку, оборвавшую выборку. Без её проверки сетевой сбой выглядит как пустой список.

Запросы пошли — дальше по курсу: в [шестом уроке](/courses/pgx/lessons/06-skanerovanie-strok/) разберём сканирование строк, NULL и указатели; в [восьмом](/courses/pgx/lessons/08-argumenty-zaprosa/) — плейсхолдеры и SQL-инъекции; а когда запросов станет много, в [тринадцатом](/courses/pgx/lessons/13-tranzakcii/) соберём их в транзакции. Таблица notes из этого урока останется с нами до итогового проекта — она же полигон для RETURNING, Batch и COPY. Прежде чем двигаться дальше, прогоните домашнее задание локально: INSERT и SELECT — тот минимум, на котором держатся почти все репозитории Go-проектов, и уверенность в трёх вызовах окупится уже на шестом уроке, где строки станут NULL-дружелюбными.

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

- Exec — для команд без строк: INSERT, UPDATE, DELETE и DDL; возвращает CommandTag с RowsAffected.
- QueryRow — ровно одна строка: Scan в переменные, отсутствие строк — pgx.ErrNoRows через errors.Is.
- Query — курсор: цикл Next-Scan, defer rows.Close() и обязательная rows.Err() после цикла.
- UPDATE с нулём затронутых строк — успех с RowsAffected 0, а не ошибка.
- В нативном pgx курсор закрывается сам при полном проходе; defer Close страхует ранние выходы.
- Exec без аргументов выполняет несколько стейтментов; с аргументами — один; пачки — Batch и COPY из уроков 18 и 19.
- Значения подставляются плейсхолдерами $1 и $2 — никогда склейкой строк.

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

Создайте таблицу notes Exec-ом из урока, вставьте три заметки и напечатайте RowsAffected каждого INSERT. Напишите выборку всех заметок циклом Query и добейтесь трёх веток: найденная строка через QueryRow, отсутствие через ErrNoRows и сетевая ошибка — последнюю сымитируйте, остановив контейнер Postgres перед запуском. Проверьте себя: уберите rows.Err() из цикла и сформулируйте, какой баг вы бы поймали в проде. Добавьте RETURNING id к одному из INSERT и напечатайте сгенерированный идентификатор — он понадобится в каждом следующем уроке.

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

### Exec или Query для INSERT с RETURNING id?

RETURNING возвращает строки, поэтому нужен QueryRow: row.Scan(&id) заберёт сгенерированный идентификатор за одну поездку по сети. Exec здесь потеряет результат — он для команд без возвращаемых строк. RETURNING с несколькими строками читается Query, с одной — QueryRow; строк больше, чем приёмников, — ловите ошибку рассинхрона.

### Чем QueryRow отличается от Query с LIMIT 1?

Контрактом: QueryRow забирает одну строку, сам закрывает курсор и возвращает ErrNoRows для пустоты. Query с LIMIT 1 требует ручного Next, Scan и Close и не различает «нет строк» от «нет ошибок» без вашей собственной логики. Плюс QueryRow не гоняет данные зря: единственная строка уходит курсором и закрывается сразу после Scan.

### Нужно ли вызывать rows.Close() в нативном pgx?

При полном проходе курсор закрывается сам, но в коде с ранними return и break defer rows.Close() обязателен: незакрытый курсор удерживает соединение пула занятым. Универсальное правило — defer сразу после Query; обрывы по контексту он тоже покрывает.
