Когда я начинал работать с PostgreSQL из Go, выбор казался простым: database/sql + lib/pq, как написано в каждом туториале. Через полгода я обнаружил sqlx и переписал часть запросов — ручное сканирование в 15 полей утомляло. Ещё через год перешёл на pgx — и понял, что с самого начала упускал производительность и удобные фичи вроде COPY и batch-запросов.
В этой статье разберу три подхода к PostgreSQL из Go: покажу код, объясню разницу в API и приведу бенчмарки с реальными цифрами.
Три подхода к PostgreSQL из Go
| Характеристика | lib/pq + database/sql | lib/pq + sqlx | pgx |
|---|---|---|---|
| Тип | Драйвер + стандартный интерфейс | Расширение database/sql | Нативный драйвер |
| Первый релиз | 2012 | 2013 | 2014 (v5 — 2022) |
| Статус | Поддержка (без активной разработки) | Поддержка | Активная разработка |
| PostgreSQL-специфичные фичи | Нет | Нет | Да (COPY, LISTEN/NOTIFY, batch) |
| Зависимость от database/sql | Да | Да | Нет (но может работать через database/sql) |
lib/pq — первый Go-драйвер для PostgreSQL. С 2020 года в режиме поддержки: баги исправляют, новые фичи не добавляют. До коммита 31 декабря 2025 года README проекта рекомендовал рассматривать pgx как альтернативу. Хоть проект и вернулся в фазу разработки, рекомендации использовать pgx всё так же в силе как наиболее развитая и полная реализация.
sqlx — обёртка над database/sql, которая добавляет автоматическое сканирование строк в структуры. Сам по себе не драйвер — работает с любым database/sql-совместимым драйвером.
pgx — нативный PostgreSQL-драйвер, написанный с нуля. Не зависит от database/sql, общается с PostgreSQL по бинарному протоколу напрямую.
lib/pq + database/sql
Подключение и пул
database/sql управляет пулом соединений самостоятельно. Вызов sql.Open не устанавливает соединение — он только регистрирует драйвер. Реальное подключение произойдёт при первом запросе.
import (
"database/sql"
_ "github.com/lib/pq" // Регистрируем драйвер через init()
)
db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/mydb?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Настройка пула
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
// Проверяем, что соединение работает
if err := db.PingContext(ctx); err != nil {
log.Fatal(err)
}
CRUD-операции
Главная особенность database/sql — ручное сканирование каждого поля:
type User struct {
ID int
Name string
Email string
Age int
}
// INSERT с RETURNING
var id int
err := db.QueryRowContext(ctx,
"INSERT INTO users (name, email, age) VALUES ($1, $2, $3) RETURNING id",
"Иван", "ivan@example.com", 30,
).Scan(&id)
// SELECT одной строки — перечисляем все поля в Scan
var u User
err = db.QueryRowContext(ctx,
"SELECT id, name, email, age FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Name, &u.Email, &u.Age)
// SELECT нескольких строк
rows, err := db.QueryContext(ctx, "SELECT id, name, email, age FROM users LIMIT 100")
if err != nil {
return err
}
defer rows.Close() // Не забываем закрыть!
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age); err != nil {
return err
}
users = append(users, u)
}
// Проверяем ошибки итерации
if err := rows.Err(); err != nil {
return err
}
Транзакции
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// Rollback безопасен после Commit — он просто ничего не сделает
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID)
if err != nil {
return err
}
return tx.Commit()
Плюсы и минусы
| Плюсы | Минусы |
|---|---|
| Стандартная библиотека, нет зависимостей | Ручной Scan для каждого поля |
| Легко заменить драйвер | Нет PostgreSQL-специфичных фич |
| Много документации и примеров | Текстовый протокол (медленнее) |
| Встроенный пул соединений | Больше аллокаций из-за интерфейсных обёрток |
sqlx: расширение database/sql
Подключение
sqlx оборачивает *sql.DB, поэтому вся настройка пула идентична:
import (
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
db, err := sqlx.Open("postgres", "postgres://user:pass@localhost:5432/mydb?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
Сканирование в структуры
sqlx умеет автоматически сканировать строки в структуры по тегу db:
type User struct {
ID int `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
Age int `db:"age"`
}
// SELECT одной строки — без перечисления полей
var u User
err := sqlx.GetContext(ctx, db, &u,
"SELECT id, name, email, age FROM users WHERE id = $1", 1)
// SELECT нескольких строк — одна строка вместо цикла
var users []User
err = sqlx.SelectContext(ctx, db, &users,
"SELECT id, name, email, age FROM users LIMIT 100")
Сравните с database/sql: вместо ручного rows.Next() + rows.Scan(&u.ID, &u.Name, ...) — один вызов Select. При 15-20 полях в структуре разница ощутима.
Named-запросы
sqlx поддерживает именованные параметры — удобно для INSERT с большим количеством полей:
u := User{Name: "Иван", Email: "ivan@example.com", Age: 30}
_, err := db.NamedExecContext(ctx,
"INSERT INTO users (name, email, age) VALUES (:name, :email, :age)", u)
Плюсы и минусы
| Плюсы | Минусы |
|---|---|
| Автоматическое сканирование в структуры | Дополнительная зависимость |
| Named-запросы | Добавляет аллокации из-за рефлексии |
| Совместим с database/sql | Нет PostgreSQL-специфичных фич |
| Минимальный порог входа | Обёртка, а не самостоятельный драйвер |
pgx: нативный драйвер
Подключение через pgxpool
pgx работает с PostgreSQL напрямую, без прослойки database/sql. Пул соединений — через pgxpool:
import "github.com/jackc/pgx/v5/pgxpool"
pool, err := pgxpool.New(ctx, "postgres://user:pass@localhost:5432/mydb?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer pool.Close()
Конфигурация пула через строку подключения или программно:
config, err := pgxpool.ParseConfig(connString)
if err != nil {
log.Fatal(err)
}
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = 5 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, config)
Базовые операции
// INSERT
var id int
err := pool.QueryRow(ctx,
"INSERT INTO users (name, email, age) VALUES ($1, $2, $3) RETURNING id",
"Иван", "ivan@example.com", 30,
).Scan(&id)
// SELECT одной строки
var u User
err = pool.QueryRow(ctx,
"SELECT id, name, email, age FROM users WHERE id = $1", 1,
).Scan(&u.ID, &u.Name, &u.Email, &u.Age)
// SELECT нескольких строк
rows, err := pool.Query(ctx, "SELECT id, name, email, age FROM users LIMIT 100")
if err != nil {
return err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age); err != nil {
return err
}
users = append(users, u)
}
pgx v5 также поддерживает pgx.CollectRows для сканирования в структуры, но работает это через позиционное или именованное маппирование — количество и порядок столбцов в запросе должны совпадать с полями структуры.
Batch-запросы
Batch отправляет несколько запросов в одном сетевом round-trip:
batch := &pgx.Batch{}
for _, u := range users {
batch.Queue(
"INSERT INTO users (name, email, age) VALUES ($1, $2, $3)",
u.Name, u.Email, u.Age,
)
}
br := pool.SendBatch(ctx, batch)
defer br.Close()
for range users {
_, err := br.Exec()
if err != nil {
return err
}
}
В моей практике batch-запросы pgx на 100 вставках работали в 50-55 раз быстрее, чем 100 отдельных INSERT’ов через database/sql. Разница объясняется просто: вместо 100 сетевых round-trip’ов — один.
COPY протокол
Для массовой загрузки данных PostgreSQL поддерживает протокол COPY — потоковую передачу данных без накладных расходов на разбор отдельных SQL-запросов. pgx — единственный Go-драйвер с его поддержкой:
rows := [][]interface{}{
{"Иван", "ivan@example.com", 30},
{"Мария", "maria@example.com", 25},
// ... тысячи строк
}
copyCount, err := pool.CopyFrom(ctx,
pgx.Identifier{"users"},
[]string{"name", "email", "age"},
pgx.CopyFromRows(rows),
)
На 100 строках COPY работает примерно так же, как batch-запросы. Но с ростом объёма — тысячи и десятки тысяч строк — COPY значительно обгоняет любой другой подход, потому что передаёт данные потоком без разбора отдельных SQL-выражений.
LISTEN/NOTIFY
pgx поддерживает механизм оповещений PostgreSQL. Это позволяет подписаться на события в базе и реагировать на них без polling:
conn, err := pool.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
_, err = conn.Exec(ctx, "LISTEN events")
if err != nil {
return err
}
for {
notification, err := conn.Conn().WaitForNotification(ctx)
if err != nil {
return err
}
fmt.Printf("Канал: %s, данные: %s\n", notification.Channel, notification.Payload)
}
Отправка уведомления из другого соединения:
_, err := pool.Exec(ctx, "NOTIFY events, 'user_created:42'")
Мы использовали это для инвалидации кэшей — сервис подписывался на канал и сбрасывал записи при обновлении в базе. Работает и для real-time обновлений, если не хочется тянуть отдельный брокер.
Плюсы и минусы
| Плюсы | Минусы |
|---|---|
| Бинарный протокол (меньше аллокаций, быстрее) | Привязка к PostgreSQL |
| COPY, batch, LISTEN/NOTIFY | Другой API — не database/sql |
| Активная разработка | Миграция с database/sql требует правок |
| Лучшая поддержка типов PostgreSQL | Документация менее структурирована |
Сравнение API: одна задача — три решения
INSERT с RETURNING
// database/sql
var id int
err := db.QueryRowContext(ctx,
"INSERT INTO users (name, email, age) VALUES ($1, $2, $3) RETURNING id",
name, email, age,
).Scan(&id)
// sqlx
var id int
err := db.QueryRowxContext(ctx,
"INSERT INTO users (name, email, age) VALUES ($1, $2, $3) RETURNING id",
name, email, age,
).Scan(&id)
// pgx
var id int
err := pool.QueryRow(ctx,
"INSERT INTO users (name, email, age) VALUES ($1, $2, $3) RETURNING id",
name, email, age,
).Scan(&id)
API почти одинаков. Разница — внутри: pgx использует бинарный протокол и делает меньше аллокаций.
SELECT в структуру
// database/sql — перечисляем все поля
var u User
err := db.QueryRowContext(ctx, "SELECT id, name, email, age FROM users WHERE id = $1", 1).
Scan(&u.ID, &u.Name, &u.Email, &u.Age)
// sqlx — одна строка
var u User
err := sqlx.GetContext(ctx, db, &u, "SELECT id, name, email, age FROM users WHERE id = $1", 1)
// pgx — аналогично database/sql
var u User
err := pool.QueryRow(ctx, "SELECT id, name, email, age FROM users WHERE id = $1", 1).
Scan(&u.ID, &u.Name, &u.Email, &u.Age)
sqlx выигрывает в удобстве при чтении. pgx предлагает CollectRows для множественных строк, но синтаксис менее интуитивен, чем sqlx.Select.
Массовая вставка 100 строк
// database/sql — 100 отдельных INSERT в транзакции
tx, _ := db.BeginTx(ctx, nil)
for _, u := range users {
tx.ExecContext(ctx, "INSERT INTO users (name, email, age) VALUES ($1, $2, $3)",
u.Name, u.Email, u.Age)
}
tx.Commit()
// sqlx — аналогично, но можно через NamedExec
tx, _ := db.BeginTxx(ctx, nil)
for _, u := range users {
tx.NamedExecContext(ctx, "INSERT INTO users (name, email, age) VALUES (:name, :email, :age)", u)
}
tx.Commit()
// pgx — batch в одном round-trip
batch := &pgx.Batch{}
for _, u := range users {
batch.Queue("INSERT INTO users (name, email, age) VALUES ($1, $2, $3)",
u.Name, u.Email, u.Age)
}
br := pool.SendBatch(ctx, batch)
// обработка результатов...
br.Close()
Бенчмарки
Методология
Подробнее о бенчмарках в Go: Бенчмарки и оптимизация в Go.
Условия тестирования:
- PostgreSQL 16 в Docker с tmpfs (данные в RAM для стабильности)
- Go 1.25, AMD Ryzen 5 PRO 4650U, Linux
- Каждый бенчмарк запускался 5 раз (
-count=5) - Результаты обработаны через
benchstat
Результаты
Single INSERT + RETURNING
| Драйвер | ns/op | B/op | allocs/op |
|---|---|---|---|
| database/sql | 1 091 000 | 1 043 | 27 |
| sqlx | 1 226 000 | 1 091 | 28 |
| pgx | 587 000 | 510 | 7 |
pgx быстрее database/sql почти вдвое. Обратите внимание на аллокации: 7 против 27. Это не магия — просто pgx не гоняет данные через interface{}.
SELECT по PK (одна строка)
| Драйвер | ns/op | B/op | allocs/op |
|---|---|---|---|
| database/sql | 1 153 000 | 1 176 | 31 |
| sqlx | 1 162 000 | 1 461 | 36 |
| pgx | 585 000 | 694 | 9 |
Картина та же: pgx быстрее вдвое. sqlx чуть медленнее чистого database/sql из-за рефлексии при сканировании, но на фоне сетевых задержек это не ощущается.
Batch INSERT (100 строк в транзакции)
| Драйвер | ms/op | KB/op | allocs/op |
|---|---|---|---|
| database/sql | 112 | 93 | 2 435 |
| sqlx | 107 | 93 | 2 435 |
| pgx (транзакция) | 50 | 25 | 715 |
pgx вдвое быстрее при пакетной вставке через транзакции, и при этом тратит в 3.5 раза меньше памяти. Но настоящая разница — в batch API и COPY.
pgx Batch vs pgx COPY (100 строк)
| Метод | ms/op | KB/op | allocs/op |
|---|---|---|---|
| pgx Batch | 2.0 | 103 | 1 425 |
| pgx COPY | 1.1 | 33 | 537 |
Batch-запросы pgx не используют транзакцию — они отправляют все 100 INSERT’ов в одном сетевом пакете, и PostgreSQL выполняет их последовательно. COPY ещё быстрее и экономнее по памяти — протокол передаёт данные потоком без разбора отдельных SQL-выражений.
Для сравнения: 100 INSERT’ов через database/sql в транзакции — 112 мс, pgx Batch — 2 мс, pgx COPY — 1.1 мс. Разница в 100 раз.
SELECT 100 строк
| Драйвер | µs/op | KB/op | allocs/op |
|---|---|---|---|
| database/sql | 936 | 31 | 526 |
| sqlx | 1 006 | 34 | 630 |
| pgx | 777 | 34 | 412 |
При чтении множества строк разница между драйверами сжимается. pgx быстрее на 17%, но здесь основное время уходит на сетевой обмен и сканирование, а не на протокол. sqlx опять чуть медленнее из-за рефлексии — 630 аллокаций против 526 у чистого database/sql.
Почему pgx быстрее
Главная причина — бинарный протокол. pgx общается с PostgreSQL в бинарном формате, а database/sql через lib/pq использует текстовый. Все числа и даты передаются как строки и парсятся на стороне Go. На одном int64 разница незаметна, но когда вы читаете 100 строк по 10 колонок — набегает.
Вторая причина — отсутствие прослойки database/sql. Интерфейс database/sql использует interface{} для параметров и результатов. Каждый параметр упаковывается в интерфейс (boxing), что вызывает аллокацию на куче. pgx работает с типами напрямую, отсюда 7 аллокаций вместо 27 на простом INSERT.
pgxpool также управляет соединениями эффективнее generic-пула database/sql, потому что знает о специфике PostgreSQL-протокола.
pgx через database/sql
Если у вас существующий проект на database/sql и миграция на нативный API pgx — слишком большая задача, можно использовать pgx как drop-in замену lib/pq:
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // Вместо lib/pq
)
db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/mydb")
Вы получите бинарный протокол и активно поддерживаемый драйвер, сохранив привычный API database/sql. Это хороший промежуточный шаг перед полной миграцией на нативный pgx.
Как выбрать
Если начинаете новый проект и база — PostgreSQL, берите pgx. Нет причин выбирать что-то другое, если не планируете менять СУБД.
Если уже работаете на database/sql и устали от ручного Scan на 15 полей — добавьте sqlx. Минимальные правки в коде, сразу получаете Get и Select.
Если проект поддерживает несколько СУБД (PostgreSQL + MySQL + SQLite) — database/sql. Единый интерфейс, меняется только драйвер.
Если нужны COPY, LISTEN/NOTIFY или batch — это только pgx, в database/sql таких фич нет.
И есть промежуточный вариант: замена lib/pq на pgx/stdlib — одна строка импорта, без рефакторинга.
Типичные ошибки
Не закрывать rows
// Утечка соединения!
rows, err := db.QueryContext(ctx, "SELECT ...")
// Если забыть rows.Close(), соединение не вернётся в пул
// Правильно
rows, err := db.QueryContext(ctx, "SELECT ...")
if err != nil {
return err
}
defer rows.Close()
Это относится и к database/sql, и к pgx. Незакрытые rows удерживают соединение из пула.
Не настраивать пул
// По умолчанию database/sql не ограничивает количество соединений
db.SetMaxOpenConns(0) // Без ограничений — опасно!
// Рекомендация
db.SetMaxOpenConns(25) // По количеству ядер × 2-3
db.SetMaxIdleConns(10) // Половина от MaxOpenConns
db.SetConnMaxLifetime(5 * time.Minute) // Ротация для балансировщиков
Без ограничений при всплеске нагрузки Go откроет сотни соединений и уронит PostgreSQL (по умолчанию max_connections = 100).
SQL-инъекции
// Уязвимо! Никогда так не делайте
query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", userInput)
// Безопасно — параметризованный запрос
db.QueryContext(ctx, "SELECT * FROM users WHERE name = $1", userInput)
Параметризованные запросы работают одинаково во всех трёх подходах. Это базовая вещь, но я до сих пор встречаю fmt.Sprintf в production-коде.
Игнорировать context
// Запрос повиснет без таймаута
db.Query("SELECT pg_sleep(300)")
// С context запрос отменится через 5 секунд
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
db.QueryContext(ctx, "SELECT pg_sleep(300)")
Мы однажды потеряли все соединения из пула из-за одного запроса без context — он повис на 5 минут, остальные запросы встали в очередь. С тех пор context передаём везде.
Путать pgx.ErrNoRows и sql.ErrNoRows
// database/sql
if errors.Is(err, sql.ErrNoRows) { ... }
// pgx
if errors.Is(err, pgx.ErrNoRows) { ... }
// pgx через database/sql (stdlib) — используем sql.ErrNoRows
if errors.Is(err, sql.ErrNoRows) { ... }
При миграции с database/sql на pgx замените проверки ошибок. pgx.ErrNoRows и sql.ErrNoRows — разные значения.
Заключение
Оглядываясь назад, я бы сразу начинал с pgx. Бинарный протокол даёт двукратное ускорение на простых запросах, а batch и COPY на массовых вставках быстрее database/sql в 100 раз. Это не теоретические цифры — мы их измерили выше.
Если у вас рабочий проект на database/sql + lib/pq, не нужно переписывать всё разом. Замените lib/pq на pgx/stdlib (одна строка импорта) и получите бинарный протокол. Потом переводите горячие пути на нативный pgx API по мере необходимости.
sqlx по-прежнему полезен, когда вам нужен struct scanning без смены API. Он работает с любым database/sql-драйвером, включая pgx/stdlib, так что комбинация sqlx + pgx/stdlib вполне рабочая.
Связанные статьи:
FAQ
Можно ли использовать pgx и sqlx вместе?
Да. Подключите pgx через pgx/v5/stdlib как database/sql-драйвер, а сверху используйте sqlx для struct scanning:
import (
"github.com/jmoiron/sqlx"
_ "github.com/jackc/pgx/v5/stdlib"
)
db, err := sqlx.Open("pgx", connString)
Вы получите бинарный протокол pgx + удобство sqlx.Get/sqlx.Select. Но pgx-специфичные фичи (batch, COPY, LISTEN/NOTIFY) в таком режиме недоступны.
Как мигрировать с lib/pq на pgx?
Два варианта:
Быстрый — замена драйвера без изменения API:
// Было
import _ "github.com/lib/pq"
db, err := sql.Open("postgres", connString)
// Стало
import _ "github.com/jackc/pgx/v5/stdlib"
db, err := sql.Open("pgx", connString)
Полный — переход на нативный API:
- Замените
*sql.DBна*pgxpool.Pool - Замените
sql.ErrNoRowsнаpgx.ErrNoRows - Уберите
defer rows.Close()— в pgx rows закрываются при полном прочтении (но закрывать вручную тоже безопасно) - Переведите массовые вставки на batch/COPY
Рекомендую поэтапную миграцию: сначала быстрый вариант, потом переводите критичные пути на нативный API.
Нужен ли ORM (GORM, ent)?
Зависит от проекта. ORM ускоряет разработку простых CRUD-приложений, но добавляет:
- Магию и неявное поведение (N+1 запросы, неожиданные JOIN’ы)
- Сложность отладки SQL
- Потерю контроля над запросами
В моей практике для сервисов с нетривиальными запросами (аналитика, отчёты, сложные JOIN’ы) чистый SQL + pgx работает лучше. Для админок и простых API — ORM может сэкономить время.
Компромисс: используйте sqlc — генератор типизированного Go-кода из SQL-запросов. Вы пишете обычный SQL, а sqlc генерирует структуры и функции.
Как выбрать размер пула соединений?
Формула для начала: MaxOpenConns = количество_ядер × 2 + количество_дисков.
На практике:
- Для веб-сервисов: 20-30 соединений
- Для воркеров: 5-10 соединений
MaxIdleConns= 50-100% отMaxOpenConnsConnMaxLifetime= 5-10 минут (помогает при использовании pgbouncer или облачных балансировщиков)
Мониторьте db.Stats() (или метрики pgxpool) — если WaitCount растёт, пул слишком мал. Если Idle всегда равен MaxOpen, пул можно уменьшить.
Не ставьте пул больше, чем max_connections PostgreSQL (по умолчанию 100). Если у вас 10 инстансов сервиса по 25 соединений — это уже 250, что превышает лимит. В таких случаях используйте PgBouncer.
Теги: