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

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

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

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

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

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

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

Первый запрос всегда проще, чем кажется: у 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 за данными, которые сервер уже держит в руках.

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 для первых запросов#

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

Несколько команд одним 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?

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

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

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

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

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

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

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

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

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

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

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

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

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

Строки

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

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

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

Термин

Exec

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

Выполняет SQL без возврата строк: INSERT, UPDATE, DELETE, DDL. Возвращает CommandTag и error.

1 / 6

Запросы пошли — дальше по курсу: в шестом уроке разберём сканирование строк, NULL и указатели; в восьмом — плейсхолдеры и SQL-инъекции; а когда запросов станет много, в тринадцатом соберём их в транзакции. Таблица notes из этого урока останется с нами до итогового проекта — она же полигон для RETURNING, Batch и COPY. Прежде чем двигаться дальше, прогоните домашнее задание локально: INSERT и SELECT — тот минимум, на котором держатся почти все репозитории Go-проектов, и уверенность в трёх вызовах окупится уже на шестом уроке, где строки станут NULL-дружелюбными.

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

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

Вывод

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

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

  • 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 и напечатайте сгенерированный идентификатор — он понадобится в каждом следующем уроке.

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

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

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; обрывы по контексту он тоже покрывает.

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

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

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

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

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

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

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