sqlh — SQL в Go без boilerplate: пишем CRUD за 50 строк
- суббота, 13 июня 2026 г. в 00:00:07
Zero-boilerplate SQL для Go. Опиши структуру тегами — и это всё.
Если вы пишете на Go и работаете с SQL-базами, вы знаете эту боль. Каждый CRUD-запрос — ручной SQL-строка, rows.Scan для каждого поля, Begin/Commit/Rollback вокруг записи, и постоянная синхронизация DDL-схемы с кодом. Шаблонный код не заканчивается никогда.
Это рассказ о sqlh — библиотеке, которая убирает всё это, оставаясь в «золотой середине» между raw SQL (слишком много работы) и тяжёлыми ORM (слишком много магии).
Стандартный database/sql в Go отличен. Он даёт прочный, переносимый фундамент для любой SQL-базы. Но он намеренно оставляет тяжёлую работу за вами.
Вот как выглядит простой CRUD на чистом database/sql:
// 1. CREATE TABLE — raw DDL-строка _, err := db.Exec(`CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, email TEXT, age INTEGER )`) // 2. INSERT — явные placeholder и аргументы _, err = db.Exec( "INSERT INTO user (name, email, age) VALUES (?, ?, ?)", "Alice", "alice@example.com", 30, ) // 3. GET по ID — QueryRow + ручной Scan var u User err = db.QueryRow("SELECT id, name, email, age FROM user WHERE id = ?", 1). Scan(&u.ID, &u.Name, &u.Email, &u.Age) // 4. LIST всех — Query + rows.Next + rows.Scan в цикле rows, err := db.Query("SELECT id, name, email, age FROM user ORDER BY name ASC") var users []User for rows.Next() { var u User if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age); err != nil { log.Fatal(err) } users = append(users, u) } rows.Close() // 5. UPDATE — raw SQL с placeholder _, err = db.Exec( "UPDATE user SET email = ?, age = ? WHERE id = ?", "alice.new@example.com", 31, 1, ) // 6. DELETE — raw SQL _, err = db.Exec("DELETE FROM user WHERE id = ?", 1)
Это ~115 строк кода для шести базовых операций. И каждый раз, когда вы добавляете столбец, нужно обновить строку CREATE TABLE, список колонок в INSERT, список в SELECT, и вызов rows.Scan. Опечатка в любом месте — runtime-ошибка, compile-time безопасности нет.
Боль | Почему больно |
|---|---|
Ручной SQL | Каждый CRUD — raw SQL-строка, нет проверки на этапе компиляции |
| 4–5 строк на каждый результат только для маппинга колонок на поля |
Транзакции |
|
Нет связи со схемой | DDL в миграциях, структуры в Go — они расходятся |
Порядок колонок | Новый столбец → обновлять SQL-строки и |
В экосистеме Go есть два известных пути. У каждого свои компромиссы.
sqlx — популярное расширение database/sql. Он добавляет StructScan, Get, Select, именованные параметры. SQL пишете по-прежнему руками, но rows.Scan автоматизирован.
// sqlx: всё ещё ручной SQL, но StructScan убирает Scan var u User dbx.Get(&u, "SELECT id, name, email, age FROM user WHERE id = ?", 1)
sqlx сэкономит примерно 30% boilerplate (до ~80 строк). Но CREATE TABLE, INSERT, SELECT, UPDATE, DELETE — всё ещё пишете вручную. Генерация SQL — не его задача.
GORM — тяжеловес. Генерирует всё — схему, запросы, миграции — и даёт богатый chainable API. Но цена высока:
Тяжёлый reflection в runtime
Крутая кривая обучения — теги, хуки, scopes, ассоциации
~4 MB увеличение бинарника только за ORM
Магия, которая скрывает сложность — пока не сломается, и вы часами дебажите
Для больших команд с выделенными DBA и сложными моделями GORM — solid choice. Для CLI-утилит, стартапов и микросервисов — overkill.
Фича |
| sqlx | GORM | sqlh |
|---|---|---|---|---|
SQL-генерация | ❌ Ручная | ❌ Ручная | ✅ Полная | ✅ Полная |
| ✅ Нужен | ❌ | ❌ Авто | ❌ Авто |
Типобезопасность (generics) | ❌ | ❌ | ❌ | ✅ |
Авто-транзакции | ❌ | ❌ | ✅ | ✅ |
Ретрай блокировок | ❌ | ❌ | ❌ | ✅ |
Кривая обучения | Средняя | Средняя | Высокая | Низкая |
Оверхед бинарника | 0 | ~200 KB | ~4 MB | ~200 KB |
sqlh живёт между sqlx и GORM:
Zero-boilerplate CRUD — структурные теги генерируют весь SQL
Типобезопасность через Go generics — Get[User]() возвращает *User, не interface{}
Никакой магии — что видите в структуре, то и получите в базе
Лёгкий — минимальный reflection, кеш метаданных, никакой скрытой сложности
Идея проста: ваша Go-структура — это ваша схема.
type User struct { ID int64 `db:"id" db_key:"not null primary key autoincrement"` Name string `db:"name" db_key:"unique"` Email string `db:"email"` Age int `db:"age"` }
Три тега управляют всем:
Тег | Назначение | Пример |
|---|---|---|
| Имя колонки |
|
| Ограничения, индексы |
|
| Переопределение типа SQL |
|
Из этого единственного определения sqlh генерирует:
CREATE TABLE — sqlh.Create[User](db) → CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, email TEXT, age INTEGER)
INSERT — sqlh.Insert(db, User{Name: "Alice"}) → INSERT INTO user (name, email, age) VALUES (?, ?, ?)
SELECT — sqlh.Get[User](db, ...) → SELECT id, name, email, age FROM user WHERE ... LIMIT 2
UPDATE — sqlh.Update(db, ...) → UPDATE user SET name=?, email=?, age=? WHERE ...
DELETE — sqlh.Delete[User](db, ...) → DELETE FROM user WHERE ...
┌─────────────────────────────────────────────┐ │ sqlh package │ │ Insert, Get, List, Update, Delete, Set, │ │ Create — с авто-транзакциями │ ├─────────────────────────────────────────────┤ │ query package │ │ SQL-генерация, кеш метаданных, JOIN │ ├─────────────────────────────────────────────┤ │ database/sql (stdlib) │ │ Пул соединений, выполнение raw-запросов │ └─────────────────────────────────────────────┘
Generics-first (Go 1.25+) — Get[User]() возвращает *User с проверкой типов на этапе компиляции. Никаких interface{}, никаких приведений типов.
Рефлексия один раз — метаданные структуры парсятся и кешируются в sync.Map по reflect.Type. Последующие вызовы переиспользуют имена таблиц, списки полей, scan-метаданные.
Авто-транзакции на запись — каждый Insert, Update, Delete, Set обёрнут в BEGIN...COMMIT с ROLLBACK при ошибке. Транзакции никогда не забудете.
Ретрай блокировок SQLite — ошибки «database is locked» ретраятся до 20 раз с backoff 100 ms. Production-устойчивость из коробки.
Мульти-БД — SQLite (основной), MySQL, PostgreSQL (оба в CI), SQL Server (экспериментально).
Тот же CRUD, что в начале — но ~57% короче:
package main import ( "database/sql" "fmt" "github.com/kirill-scherba/sqlh" _ "github.com/mattn/go-sqlite3" ) type User struct { ID int64 `db:"id" db_key:"not null primary key autoincrement"` Name string `db:"name" db_key:"unique"` Email string `db:"email"` Age int `db:"age"` } func main() { db, _ := sql.Open("sqlite3", "file::memory:?cache=shared") defer db.Close() // 1. CREATE TABLE из структуры sqlh.Create[User](db) // 2. INSERT sqlh.Insert(db, User{Name: "Alice", Email: "alice@example.com", Age: 30}) bobID, _ := sqlh.InsertId(db, User{Name: "Bob", Email: "bob@example.com", Age: 25}) // 3. GET по ID — возвращает *User, не interface{} u, _ := sqlh.Get[User](db, sqlh.Eq("id", bobID)) fmt.Println(u.Name) // "Bob" // 4. LIST всех — возвращает []User + next offset users, _, _ := sqlh.List[User](db, 0, "", "name ASC") fmt.Println(len(users)) // 2 // 5. UPDATE — передаём полную структуру, чтобы не занулить другие колонки sqlh.Update(db, sqlh.UpdateAttr[User]{ Row: User{Name: "Alice", Email: "alice.new@example.com", Age: 31}, Wheres: []sqlh.Where{sqlh.Eq("id", 1)}, }) // 6. DELETE sqlh.Delete[User](db, sqlh.Eq("id", bobID)) }
~50 строк. Никакого raw SQL. Ни одного rows.Scan. Ни одного BEGIN/COMMIT. Ни одной ошибки в порядке колонок.
Операция | Raw | sqlx | sqlh |
|---|---|---|---|
CREATE TABLE | Raw SQL-строка | Raw SQL-строка |
|
INSERT |
|
|
|
GET |
|
|
|
LIST |
|
|
|
UPDATE |
|
|
|
DELETE |
|
|
|
COUNT |
|
|
|
Строк кода | Сокращение | |
|---|---|---|
Raw | ~115 | baseline |
sqlx | ~80 | −30% |
sqlh | ~50 | −57% |
Для компонентов, где несколько операций над одной таблицей — можно обернуть в Table[T]:
tbl, _ := sqlh.CreateTable[User](db) tbl.Insert(User{Name: "Charlie", Email: "charlie@example.com", Age: 28}) c, _ := tbl.Get(sqlh.Eq("name", "Charlie")) fmt.Println(c.Name) for _, user := range tbl.List(0, "", "name ASC", 0) { fmt.Println(user.Name) }
Table[T] — лёгкий wrapper над общим *sql.DB. Он не владеет соединением, поэтому Close() — no-op (для обратной совместимости). Ресурсы очищает вызывающий через db.Close().
Set — атомарный upsert. Для PostgreSQL, SQLite и MySQL использует нативный синтаксис базы:
PostgreSQL: INSERT ... ON CONFLICT (...) DO UPDATE SET ...
SQLite: INSERT ... ON CONFLICT (...) DO UPDATE SET ...
MySQL: INSERT ... ON DUPLICATE KEY UPDATE ...
Для неизвестных драйверов — fallback на SELECT-then-INSERT/UPDATE в транзакции.
// name помечен db_key:"unique" — Set сделает UPDATE при совпадении err := sqlh.Set(db, User{Name: "Dave", Email: "dave@example.com"}, sqlh.Eq("name", "Dave"))
Вместо List с слайсом — ленивый итератор ListRange, который возвращает iter.Seq2[int, T]. Не загружает всё в память — идеален для стриминга, JOIN и контекстов с таймаутом.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var listErr error for i, user := range sqlh.ListRange[User](db, 0, "", "name ASC", 0, func(err error) { listErr = err }, ctx, ) { fmt.Printf("%d: %s\n", i, user.Name) }
Вместо ручного SQL в Where.Field — конструкторы для типобезопасных условий:
sqlh.Eq("name", "Alice") // name = ? sqlh.Ne("status", "deleted") // status <> ? sqlh.Gt("age", 18) // age > ? sqlh.Like("name", "%Alice%") // name LIKE ? sqlh.In("id", 1, 2, 3) // id IN (?, ?, ?) sqlh.IsNull("deleted_at") // deleted_at IS NULL
Значения передаются как bind-параметры (безопасно). Низкоуровневый Where{Field, Value} остаётся для кастомных операторов.
type UserWithOrders struct { *UserTable // основная таблица *OrderTable // JOIN-таблица } join := query.MakeJoin[OrderTable](query.Join{ Join: "LEFT", Alias: "o", On: "t.id = o.user_id", }) for _, row := range sqlh.ListRange[UserWithOrders](db, 0, "", "t.name ASC", 0, sqlh.SetAlias("t"), join, func(err error) { log.Fatal(err) }, ) { if row.OrderTable != nil { fmt.Println(row.UserTable.Name, row.OrderTable.Total) } }
Насколько быстр sqlh на практике? В модуле bench/ — воспроизводимые Go-бенчмарки сравнивают raw database/sql, sqlx, GORM и sqlh на одном и том же CRUD-ворклоаде. Все тесты используют in-memory SQLite — никакой внешней настройки.
Воспроизвести на своей машине:
cd bench && go test -bench=. -benchmem -benchtime=1s
Операция | raw sql | sqlx | GORM | sqlh |
|---|---|---|---|---|
Insert | 158,041 | 131,596 | 34,971 | 87,085 |
Get by PK | 169,232 | 152,415 | 78,666 | 68,675 |
List all | 11,807 | 9,261 | 6,779 | 7,573 |
List limit 10 | 51,500 | 43,691 | 37,821 | 44,142 |
Update | 228,728 | 180,505 | 65,933 | 85,543 |
Delete | 172,128 | 166,279 | 41,162 | 60,650 |
Операция | raw sql | sqlx | GORM | sqlh |
|---|---|---|---|---|
Insert | 328 B, 12 | 721 B, 20 | 5,534 B, 82 | 1,274 B, 39 |
Get by PK | 792 B, 27 | 976 B, 31 | 3,952 B, 66 | 2,592 B, 78 |
List all | 23,744 B, 528 | 26,376 B, 632 | 27,668 B, 946 | 26,391 B, 745 |
List limit | 3,120 B, 76 | 3,624 B, 91 | 6,145 B, 141 | 3,958 B, 115 |
Update | 296 B, 9 | 680 B, 19 | 5,079 B, 68 | 1,393 B, 43 |
Delete | 216 B, 7 | 216 B, 7 | 5,484 B, 67 | 1,136 B, 37 |
GORM показывает наибольшую latency и самый тяжёлый allocation footprint — следствие богатого feature set и reflection-оверхеда.
sqlh находится между raw/sqlx и GORM. Умеренный оверхед — плата за авто-генерацию SQL, парсинг тегов и встроенные транзакции на запись.
sqlh торгует скоростью на корректность: каждая запись атомарна (auto-transact с rollback), что устраняет целый класс багов ценой оверхеда ~2–6x vs raw SQL для однострочных мутаций.
ListAll доминируется сканированием 100 строк. Все библиотеки здесь показывают схожую производительность.
Окружение: Linux AMD Ryzen 9 3900, Go 1.26.3, SQLite in-memory. Запустите
cd bench && go test -bench=. -benchmem -benchtime=1sна своём железе для сравнения.
sqlh — не серебряная пуля. Вот где он сияет, а где лучше что-то другое:
Сценарий | Рекомендация |
|---|---|
CLI-утилиты | ✅ Идеально — ноль файлов миграций, один бинарник |
Стартапы и MVP | ✅ Быстрее пишете, потом рефакторите |
Микросервисы с простыми схемами | ✅ Низкий оверхед, типобезопасность |
High-throughput OLTP (>100K writes/sec) | ⚠️ Тестируйте — возможно, raw SQL |
Сложная аналитика | ⚠️ Предпочтительно raw SQL или query builder |
Большие команды с DBA | ⚠️ GORM или sqlx могут подойти лучше |
Обучение Go + SQL | ✅ Отличный учебный инструмент — низкая когнитивная нагрузка |
sqlh активно развивается. На момент v0.8.0 (июнь 2026) библиотека поддерживает:
✅ Полный CRUD с авто-транзакциями
✅ Нативный UPSERT (PostgreSQL, SQLite, MySQL)
✅ JOIN-запросы со сканированием в composite-структуры
✅ Go 1.25 iterators (ListRange) для ленивого стриминга
✅ Типобезопасные WHERE-хелперы (Eq, Ne, Gt, Like, In и др.)
✅ Ретрай блокировок для SQLite
✅ Мульти-БД (SQLite, MySQL, PostgreSQL)
В планах: агрегатные функции (SUM, AVG), миграции схемы, batch-операции. API стабилизируется к v1.0.0.
Если вы строите Go-проект, который общается с SQL, и устали писать один и тот же boilerplate снова и снова — дайте sqlh шанс. Опишите структуру. Это всё.
go get github.com/kirill-scherba/sqlh
Автор: Kirill Scherba. sqlh — open source под BSD-лицензией. Contributions welcome.