---
title: "Интерфейс database/sql: pgx через StdLib"
description: "pgx через database/sql: адаптер stdlib, sql.Open с драйвером pgx, OpenDBFromPool для pgxpool, Raw() как люк в нативный API и что теряете против нативного кода."
course: "PostgreSQL в Go: драйвер pgx с нуля до продакшена"
url: "https://golangmaster.ru/courses/pgx/lessons/09-pgx-vs-database-sql/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Интерфейс database/sql: pgx через StdLib

*Курс «PostgreSQL в Go: драйвер pgx с нуля до продакшена», урок 9 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/pgx/lessons/09-pgx-vs-database-sql/*

pgx умеет жить внутри абстрактного интерфейса database/sql: адаптер stdlib регистрирует драйвер pgx под sql.Open, а поверх него работают *sql.DB, sql.NullString и всё, что ждёт от вас стандартная библиотека. Разберём, когда такой ход оправдан, что от pgx остаётся под интерфейсом, что безвозвратно теряется и как устроен люк Raw() обратно в нативный API.

Урок, которого могло бы не быть, если бы вся экосистема Go писала на нативных драйверах. Но вокруг database/sql вырос слой кода, который принимает именно *sql.DB: ORM GORM с драйвером postgres, генераторы, утилиты миграций, корпоративные обёртки с метриками, и подключить pgx к этому миру приходится через адаптер stdlib. Второй сценарий — чужой репозиторий: вам достался сервис на database/sql, и переписывать всё под нативный API никто не бюджетировал; stdlib позволяет сменить драйвер на pgx одной строкой импорта и получить его скорость, не трогая ни один запрос. Третий — библиотеки: если вы пишете пакет для чужих сервисов и не хотите навязывать базу, интерфейс database/sql остаётся разумным контрактом. Первый урок курса мы проговорили это с другой стороны — теперь посмотрим на адаптер вблизи: как он регистрируется, чем *sql.DB отличается от pgxpool под капотом, где заканчивается совместимость и почему я считаю stdlib переходной станцией, а не постоянным домом для прикладного сервиса.

### Драйвер pgx: пустой импорт и sql.Open

Адаптер живёт в подпакете stdlib того же модуля github.com/jackc/pgx/v5. Его функция init регистрирует драйвер под двумя именами — pgx и pgx/v5 — поэтому достаточно пустого импорта с подчёркиванием, и sql.Open("pgx", dsn) начинает работать с любым DSN из третьего урока: postgres://-строкой или keyword=value-формой. Секундная ошибка новичка: забыть пустой импорт — тогда sql.Open возвращает ошибку unknown driver «pgx» (forgotten import?), и текст прямо подсказывает диагноз. Если нужна тонкая настройка, которую не выразить строкой подключения, — pgx.ParseConfig даёт *pgx.ConnConfig, и её можно либо разыменовать и передать в stdlib.OpenDB напрямую, либо зарегистрировать через stdlib.RegisterConnConfig: функция вернёт служебную строку подключения, по которой sql.Open найдёт именно эту конфигурацию. Наконец, stdlib.GetDefaultDriver() отдаёт сам объект драйвера — путь для sql.OpenDB с собственным driver.Connector. Все четыре входа ведут к одному адаптеру; различается только то, кто владеет настройками соединений — строка DSN, конфиг или внешний pgxpool, о котором ниже.

### Привычная программа на database/sql

```go
package main

import (
        "database/sql"
        "fmt"
        "log"

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

func main() {
        db, err := sql.Open("pgx",
                "postgres://app:secret@localhost:5432/app?sslmode=disable")
        if err != nil {
                log.Fatal(err)
        }
        defer db.Close()

        var title string
        err = db.QueryRow(
                "SELECT title FROM notes WHERE id = $1", 1).Scan(&title)

        fmt.Println(title, err)
}
```

Пустой импорт stdlib регистрирует драйвер pgx, дальше — стандартный код database/sql: Open, QueryRow, Scan. Контекст здесь не обязателен — у database/sql есть и версии с суффиксом Context, и без него, в отличие от нативного pgx, где ctx — часть каждой сигнатуры. Обратите внимание: драйвер ленивый, поэтому реальное соединение откроется только на первом запросе.

Первое, что бросается в глаза после перехода с нативного кода, — мелкая грамматика. Ошибка пустого результата здесь sql.ErrNoRows из database/sql, а не pgx.ErrNoRows; нулевые типы принято брать sql.NullString и компанию из шестого урока; а соединениями управляет собственный пул database/sql с SetMaxOpenConns и SetMaxIdleConns — pgxpool из четвёртого урока в этой схеме не участвует. У стандартного пула свои приоритеты: он честно держит N соединений открытыми и переиспользует их, но у него нет MaxConnLifetime с джиттером, нет готовой статистики Stat и ленивого прогрева — те мелочи, которые pgxpool делает из коробки. Prepared statements управляются самим database/sql: интерфейс умеет Stmt, но драйверные нюансы кэширования прячутся внутри — db.Prepare вернёт *sql.Stmt для многократного переиспользования, тогда как нативный pgx строит кэш сам и обычному прикладному коду о нём думать не даёт. Ни одно из этих отличий не страшно, но все они означают, что часть знаний курса про пул и жизненный цикл коннектов к адаптеру не применяется напрямую — вы работаете по правилам стандартной библиотеки, и книгу, которую вы сейчас читаете, придётся на время закрыть.

### OpenDB и OpenDBFromPool: два конструктора

stdlib.OpenDB(config pgx.ConnConfig) собирает *sql.DB из полной конфигурации pgx — путь, когда настройки не лезут в DSN: кастомный Tracer из двадцать четвёртого урока, тонкая настройка TLS, свой Logger. Сюда же относятся параметры уровня коннекта вроде application_name и search_path — они живут в RuntimeParams той же конфигурации; хуки BeforeConnect и AfterConnect — собственность pgxpool: при OpenDBFromPool они остаются в пуле и продолжают срабатывать для каждого нового коннекта. Второй конструктор появился в v5.5 и решает деликатную задачу совместимости двух пулов: stdlib.OpenDBFromPool(pool *pgxpool.Pool) делает *sql.DB, чьи соединения — это коннекты вашего pgxpool. Без него два пула делили бы лимит max_connections базы, не зная друг о друге; с ним один pgxpool обслуживает и нативный код, и всё, что требует *sql.DB. Механика честная: database/sql при каждом запросе Acquire-ит коннект из pgxpool и Release-ит после, поэтому стандартному пулу запрещают собственные idle-соединения — OpenDBFromPool сам выставляет SetMaxIdleConns(0), чтобы не выгрызать коннекты у pgxpool. Владение разделено: закрытие *sql.DB не закрывает пул — кто-то один в приложении должен вызвать pool.Close(), и обычно это код, который этот пул создал. Это же правило делает OpenDBFromPool главным мостом для постепенной миграции: новые методы пишутся на нативном pgx, старые продолжают ходить через интерфейс, и оба мира делят одну обслугу.

### Нативный pgx против database/sql поверх него

| Возможность | Нативный pgx | Через stdlib |
| --- | --- | --- |
| Скорость драйвера и протокола | полная | та же — драйвер один и тот же |
| Batch и пайплайнинг | да | нет — интерфейс не знает про пачки |
| COPY FROM для массовой загрузки | да | только через Raw() |
| LISTEN/NOTIFY | да | нет |
| Типы pgtype без адаптеров | да | через pgtype.NewMap().SQLScanner |
| Контекст в каждой сигнатуре | обязателен | опционален — есть версии без Context |
| Коллекторы CollectRows и RowTo | да | нет — обобщений в интерфейсе нет |

### Проверка: что теряется

**Вопрос:** Какая возможность нативного pgx недоступна через чистый интерфейс database/sql?

- Параметризованные запросы с $1
- Транзакции
- ✅ Batch и LISTEN/NOTIFY
- Подключение по строке postgres://

Параметризация, транзакции и DSN — часть стандартного контракта database/sql. Batch, COPY, LISTEN/NOTIFY и коллекторы на обобщениях — нативные возможности pgx, которых в интерфейсе нет; COPY всё же достижим через люк Raw().

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

```go
package main

import (
        "database/sql"
        "fmt"
        "log"
)

func main() {
        db, err := sql.Open("pgx",
                "postgres://app:secret@localhost:5432/app")
        if err != nil {
                log.Fatal(err)
        }

        fmt.Println(err, db != nil)
}
```

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

- <nil> true — sql.Open не подключается сразу
- ✅ unknown driver "pgx" (forgotten import?) false
- ошибка соединения — sql.Open стучится в базу
- паника: драйвер не найден

Импорт stdlib забыт, драйвер pgx не зарегистрирован — sql.Open вернёт ошибку unknown driver с подсказкой про forgotten import. И даже при живом драйвере sql.Open не подключается сразу: соединения открываются лениво, при первом запросе.

### Что остаётся от pgx под интерфейсом

Термин «вы теряете скорость» в контексте stdlib вводит в заблуждение: драйвер — тот же самый, и весь нативный протокол с бинарной передачей значений работает под интерфейсом как есть. Проигрыш против нативного кода начинается на слое обёртки: database/sql конвертирует значения через интерфейсы и отражения, где pgx кладёт байты прямо в приёмники, — это не «разы», но и не ноль на горячем пути. Что правда живёт и здесь: режимы исполнения запросов. По умолчанию адаптер использует QueryExecModeCacheStatement — кэш prepared statements на каждую соединение; при pgbouncer в режиме transaction pooling режим переключают на QueryExecModeSimpleProtocol или Exec через конфиг, и это работает через stdlib так же, как через нативный pgx. Доступ к нативным типам из pgtype остаётся: pgtype.NewMap().SQLScanner(&target) оборачивает pgtype-приёмник в sql.Scanner для стандартного интерфейса — с ним массивы и NUMERIC сканируются и в *sql.DB. Наконец, ошибки на дороге: pgconn.PgError доходит и через database/sql — его вытаскивают из цепочки через errors.As, так что разбор SQLSTATE из шестнадцатого урока применим в обоих мирах. Забавная деталь свежих версий: с 5.7 pgx.ErrNoRows сам оборачивает sql.ErrNoRows, поэтому старые проверки на sql.ErrNoRows продолжают срабатывать и в нативном коде — мост между мирами проложен с обеих сторон. Потеря происходит не в скорости и не в типах, а в выразительности: пачки, NOTIFY, коллекторы и контексты в сигнатурах — интерфейс их просто не описывает.

### Raw(): люк обратно в нативный pgx

Когда под интерфейсом понадобилась нативная операция — COPY, SendBatch, LISTEN, — database/sql предлагает штатный механизм для таких «выходов из абстракции»: (*sql.Conn).Raw(). Схема из трёх шагов. Сначала db.Conn(ctx) арендует отдельное соединение из стандартного пула — его надо вернуть явно. Затем conn.Raw(func(driverConn any) error { ... }): внутри колбэка driverConn приводится к *stdlib.Conn, и метод .Conn() отдаёт настоящий *pgx.Conn, на котором работает весь нативный API. Внутри колбэка запрещено держать ссылку на соединение после выхода — Raw декорирует соединение на время вызова, и использование после возврата — гонка, которую ревью должно ловить. После колбэка conn.Close() возвращает соединение пулу. Люк делает карту «stdlib как постоянный дом» несостоятельной: массовые операции всё равно пишутся нативно, а значит, половина команды всё равно должна владеть нативным API. Практический вывод, к которому я веду весь урок: Raw() — отличный инструмент миграции и редких исключений, но если нативных операций в проекте больше двух-трёх, честнее признать, что дом проекта — нативный pgx, а stdlib — гостиница для legacy.

### Raw() + CopyFrom: массовая вставка из-под интерфейса

```go
conn, err := db.Conn(ctx)
if err != nil {
        log.Fatal(err)
}
defer conn.Close()

err = conn.Raw(func(driverConn any) error {
        pconn := driverConn.(*stdlib.Conn).Conn()

        _, err := pconn.CopyFrom(
                ctx,
                pgx.Identifier{"notes"},
                []string{"title", "body"},
                pgx.CopyFromRows(rowsData),
        )
        return err
})
```

db.Conn берёт соединение из стандартного пула, Raw декорирует его на время колбэка, а приведение к *stdlib.Conn отдаёт нативный *pgx.Conn — дальше работает обычный CopyFrom из девятнадцатого урока. После возврата из Raw соединение обязано использоваться только через database/sql: держать ссылку на *pgx.Conn после выхода — гонка.

### Проверка: люк в нативный API

**Вопрос:** Как из *sql.DB получить *pgx.Conn для нативной операции?

- Привести *sql.DB к *pgx.Conn через type assertion
- ✅ Вызвать db.Conn, а внутри conn.Raw() привести драйвер к *stdlib.Conn и взять .Conn()
- Открыть второй pgxpool рядом
- Никак — нативный API недоступен через stdlib

Raw() декорирует соединение на время колбэка: driverConn.(*stdlib.Conn).Conn() отдаёт *pgx.Conn. Прямое приведение *sql.DB невозможно — это интерфейс без знания о драйвере; второй пул делит лимит соединений с первым.

> **Живое место: stdlib «на всякий случай переезда».** Спорное мнение, которое я держу с первого урока: держать прикладной сервис на stdlib ради гипотетической смены СУБД — убыток. Что сломалось бы при таком переезде на деле: SQL-диалект, типы, миграции и тесты, а интерфейс драйвера — последняя из проблем; зато каждый день вы платите потерей Batch, NOTIFY и коллекторов. Условие, при котором я меняю позицию: вы поставляете библиотеку, которую подключают чужие сервисы, — там контракт database/sql честнее навязывания базы. Второе честное условие — поверх стоит GORM или ent, которые просят *sql.DB: драйвер postgres у [GORM](/courses/gorm/) и [ent](/courses/ent/) под капотом и так pgx, так что это вопрос не скорости, а владения конфигурацией.

### Когда stdlib оправдан одним списком

- Миграция унаследованного кода: смена драйвера одной строкой импорта без правки запросов
- Библиотека или SDK для чужих сервисов, где нельзя навязывать конкретную СУБД
- Зависимости, требующие *sql.DB: ORM, генераторы, корпоративные обёртки с метриками
- Постепенный переезд на нативный API с общим pgxpool через OpenDBFromPool
- Тестовые утилиты и CLI-инструменты, где вся мощь драйвера не нужна
- Во всех остальных случаях — нативный pgx: быстрее в написании, богаче в возможностях

### Соберите подключение через stdlib

Восстановите пустой импорт и открытие базы.

```go
import (
        "database/sql"
        ___ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
        db, err := sql.___("pgx",
                "postgres://app:secret@localhost:5432/app")
        if err != nil {
                log.Fatal(err)
        }
        defer db.Close()
}
```

Подсказка: Пустой импорт с подчёркиванием регистрирует драйвер через init, а базу открывает sql.Open с именем драйвера pgx.

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

### Соберите запрос через database/sql

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

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

1. `db, err := sql.Open("pgx", dsn)`
2. `if err != nil { log.Fatal(err) }`
3. `defer db.Close()`
4. `db.SetMaxOpenConns(10)`
5. `if err = db.PingContext(ctx); err != nil { log.Fatal(err) }`
6. `row := db.QueryRowContext(ctx, "SELECT title FROM notes WHERE id = $1", 1)`
7. `row.Scan(&title)`
8. `fmt.Println(title)`

### Карточки: pgx и database/sql

- **pgx/stdlib** — Подпакет-адаптер: пустой импорт регистрирует драйвер pgx для sql.Open("pgx", ...). Тот же протокол под интерфейсом database/sql.
- **sql.ErrNoRows** — Ошибка пустого результата в мире database/sql — в stdlib приходит именно она, а не pgx.ErrNoRows напрямую.
- **stdlib.OpenDBFromPool** — *sql.DB поверх существующего pgxpool: коннекты Acquire/Release из вашего пула, SetMaxIdleConns(0), db.Close() пул не закрывает.
- **RegisterConnConfig** — Регистрирует *pgx.ConnConfig в драйвере и возвращает служебную строку для sql.Open — способ прокинуть настройки, которых нет в DSN.
- **(*sql.Conn).Raw()** — Люк в нативный API: внутри колбэка driverConn.(*stdlib.Conn).Conn() даёт *pgx.Conn для COPY, Batch, NOTIFY.
- **pgtype.NewMap().SQLScanner** — Адаптер pgtype-типов под sql.Scanner — нативные типы pgx сканируются и в database/sql.

### Миграция на нативный pgx: по шагам

Когда решение принято, переезд на нативный API предсказуем. Шаг первый — соединения: *sql.DB заменяется на *pgxpool.Pool из четвёртого урока, SetMaxOpenConns переезжает в MaxConns конфига пула. Шаг второй — грамматика вызовов: у всех методов появляется обязательный контекст, Exec и Query меняют подписи местами, а QueryRow теряет суффиксы Context. Шаг третий — ошибки и типы: sql.ErrNoRows становится pgx.ErrNoRows, sql.NullString — pgtype.Text из шестого урока, а для NUMERIC и UUID появляются честные типы — о них десятый. Шаг четвёртый — проверки: ошибки соединений теперь приходят с нативными типами, и errors.As на pgconn.PgError заменяет разбор через driver.ErrBadConn. Пятый шаг — самое приятное: ручные циклы схлопываются в CollectRows, а место под Batch и COPY освобождается само. Опыт команд, которые это проходили, совпадает: неделя на механику, остальное — на удаление обёрток, которые были нужны только из-за интерфейса. Держите оба мира на одном pgxpool через OpenDBFromPool, пока миграция идёт — тогда лимит соединений базы никогда не станет сюрпризом посреди релиза.

Напоминание про запуск: песочница сайта исполняет тематическую программу на чистом Go без сети — stdlib-код из урока требует локального Go и Postgres из первого урока, и именно там интерактивы этого раздела превращаются в работающие команды. По курсу дальше: в [десятом уроке](/courses/pgx/lessons/10-tipy-dannyh-postgres/) — типы Postgres и pgtype, которые через адаптер доступны с оговорками, а нативно — во всей красе; в [девятнадцатом](/courses/pgx/lessons/19-copy-iz-zapisi/) — COPY, ради которого в этом уроке появился люк Raw(). Если после этого урока остался один вывод — «stdlib для чужого кода, нативный для своего», — материал сработал: это ровно та развилка, с которой начинается любой разговор о драйвере в команде.

> **ORM и генераторы тоже сидят на stdlib.** Драйвер postgres для GORM и ent через database/sql — под ним тот же pgx через stdlib. Понимание этого урока объясняет, почему ORM-конфиги принимают DSN в формате pgx и почему трассировка драйвера видна в ORM-логах: слои не дублируются, а складываются в один стек.

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

- Пустой импорт pgx/stdlib регистрирует драйвер pgx для sql.Open("pgx", ...) — ошибка forgotten import выдаёт забытый импорт.
- Скорость драйвера сохраняется: под интерфейсом тот же нативный протокол; теряются Batch, COPY, NOTIFY и коллекторы.
- stdlib.OpenDB(config) для тонких настроек, OpenDBFromPool(pool) — *sql.DB поверх pgxpool с Acquire/Release.
- OpenDBFromPool выставляет SetMaxIdleConns(0), и db.Close() не закрывает пул — пулом владеет создатель.
- Raw() даёт люк в нативный pgx: *stdlib.Conn → .Conn() → CopyFrom; держать ссылку после Raw нельзя.
- Ошибки в stdlib-мире — sql.ErrNoRows; pgconn.PgError доходит и через database/sql через errors.As.
- Миграция на нативный pgx — недели механики: контексты, pgtype, ошибки, коллекторы.

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

Возьмите программу из этого урока и прогоните её дважды: через sql.Open("pgx", ...) и через pgxpool с нативным pgx — сравните сигнатуры и почувствуйте разницу в контекстах. Затем соберите гибрид: pgxpool.OpenDBFromPool поверх пула из четвёртого урока, один метод списка на database/sql и один нативный с CollectRows — убедитесь, что оба мира делят один пул. В конце воспроизведите ошибку forgotten import, убрав пустой импорт, и запишите её текст: этот сюрприз вы ещё встретите в чужом коде.

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

### Медленнее ли pgx через database/sql, чем нативный?

Драйверная часть — та же, протокол и бинарные значения работают одинаково. Разница появляется на слое обёртки database/sql: конвертация значений через интерфейсы и отражения. На типовых запросах это единицы процентов; на горячих путях нативный pgx выигрывает заметнее, плюс получает Batch и коллекторы, которых у интерфейса нет.

### Нужен ли отдельный пул соединений при OpenDBFromPool?

Нет, и в этом смысл конструктора: соединения берутся из вашего pgxpool через Acquire и возвращаются Release-ом. Стандартный пул database/sql при этом работает без собственных idle-соединений — конструктор выставляет SetMaxIdleConns(0) сам, чтобы не выгрызать коннекты у pgxpool. Закрывать нужно пул, а не *sql.DB.

### Как через stdlib сканировать UUID или NUMERIC?

Стандартный путь — pgtype.NewMap().SQLScanner(&target): адаптер превращает pgtype-приёмник в sql.Scanner для database/sql. Простейшие случаи — UUID в строку — работают и без адаптера, но для массивов, NUMERIC и NULL-семантики pgtype честнее, и код не разваливается на два стиля.
