Zero-boilerplate SQL для Go. Опиши структуру тегами — и это всё.

Если вы пишете на Go и работаете с SQL-базами, вы знаете эту боль. Каждый CRUD-запрос — ручной SQL-строка, rows.Scan для каждого поля, Begin/Commit/Rollback вокруг записи, и постоянная синхронизация DDL-схемы с кодом. Шаблонный код не заканчивается никогда.

Это рассказ о sqlh — библиотеке, которая убирает всё это, оставаясь в «золотой середине» между raw SQL (слишком много работы) и тяжёлыми ORM (слишком много магии).

§1. Проблема: Go + SQL = смерть от тысячи rows.Scan

Стандартный 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-строка, нет проверки на этапе компиляции

rows.Scan

4–5 строк на каждый результат только для маппинга колонок на поля

Транзакции

db.Begin() + defer tx.Rollback() + tx.Commit() — везде

Нет связи со схемой

DDL в миграциях, структуры в Go — они расходятся

Порядок колонок

Новый столбец → обновлять SQL-строки и Scan-вызовы

§2. Существующие решения: sqlx и GORM

В экосистеме Go есть два известных пути. У каждого свои компромиссы.

sqlx: лучше, но всё ещё ручной SQL

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: полный ORM, полная магия

GORM — тяжеловес. Генерирует всё — схему, запросы, миграции — и даёт богатый chainable API. Но цена высока:

  • Тяжёлый reflection в runtime

  • Крутая кривая обучения — теги, хуки, scopes, ассоциации

  • ~4 MB увеличение бинарника только за ORM

  • Магия, которая скрывает сложность — пока не сломается, и вы часами дебажите

Для больших команд с выделенными DBA и сложными моделями GORM — solid choice. Для CLI-утилит, стартапов и микросервисов — overkill.

sqlh: золотая середина

Фича

database/sql

sqlx

GORM

sqlh

SQL-генерация

❌ Ручная

❌ Ручная

✅ Полная

✅ Полная

rows.Scan

✅ Нужен

StructScan

❌ Авто

❌ Авто

Типобезопасность (generics)

Авто-транзакции

Ретрай блокировок

Кривая обучения

Средняя

Средняя

Высокая

Низкая

Оверхед бинарника

0

~200 KB

~4 MB

~200 KB

sqlh живёт между sqlx и GORM:

  • Zero-boilerplate CRUD — структурные теги генерируют весь SQL

  • Типобезопасность через Go genericsGet[User]() возвращает *User, не interface{}

  • Никакой магии — что видите в структуре, то и получите в базе

  • Лёгкий — минимальный reflection, кеш метаданных, никакой скрытой сложности

§3. Как sqlh решает проблему: структурные теги как единственный источник правды

Идея проста: ваша 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"`
}

Три тега управляют всем:

Тег

Назначение

Пример

db

Имя колонки

db:"user_name"

db_key

Ограничения, индексы

db_key:"primary key autoincrement"

db_type

Переопределение типа SQL

db_type:"TEXT"

Из этого единственного определения sqlh генерирует:

  • CREATE TABLEsqlh.Create[User](db)CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, email TEXT, age INTEGER)

  • INSERTsqlh.Insert(db, User{Name: "Alice"})INSERT INTO user (name, email, age) VALUES (?, ?, ?)

  • SELECTsqlh.Get[User](db, ...)SELECT id, name, email, age FROM user WHERE ... LIMIT 2

  • UPDATEsqlh.Update(db, ...)UPDATE user SET name=?, email=?, age=? WHERE ...

  • DELETEsqlh.Delete[User](db, ...)DELETE FROM user WHERE ...

Архитектура

┌─────────────────────────────────────────────┐
│  sqlh package                               │
│  Insert, Get, List, Update, Delete, Set,    │
│  Create — с авто-транзакциями               │
├─────────────────────────────────────────────┤
│  query package                              │
│  SQL-генерация, кеш метаданных, JOIN        │
├─────────────────────────────────────────────┤
│  database/sql (stdlib)                        │
│  Пул соединений, выполнение raw-запросов    │
└─────────────────────────────────────────────┘

Ключевые дизайн-решения

  1. Generics-first (Go 1.25+)Get[User]() возвращает *User с проверкой типов на этапе компиляции. Никаких interface{}, никаких приведений типов.

  2. Рефлексия один раз — метаданные структуры парсятся и кешируются в sync.Map по reflect.Type. Последующие вызовы переиспользуют имена таблиц, списки полей, scan-метаданные.

  3. Авто-транзакции на запись — каждый Insert, Update, Delete, Set обёрнут в BEGIN...COMMIT с ROLLBACK при ошибке. Транзакции никогда не забудете.

  4. Ретрай блокировок SQLite — ошибки «database is locked» ретраятся до 20 раз с backoff 100 ms. Production-устойчивость из коробки.

  5. Мульти-БД — SQLite (основной), MySQL, PostgreSQL (оба в CI), SQL Server (экспериментально).

§4. CRUD за 50 строк: быстрый старт

Тот же 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 database/sql

sqlx

sqlh

CREATE TABLE

Raw SQL-строка

Raw SQL-строка

sqlh.Create[User](db)

INSERT

Exec(?,?,?)

NamedExec

Insert(T)

GET

QueryRow + Scan

Get(&T)

Get[T](where)

LIST

rows.Next + Scan

Select

List[T](...)

UPDATE

Exec(?,?,?,?)

NamedExec

Update(attr)

DELETE

Exec(?)

Exec(?)

Delete[T](where)

COUNT

QueryRow + Scan

Get(&int)

Count[T]()

Строк кода

Сокращение

Raw database/sql

~115

baseline

sqlx

~80

−30%

sqlh

~50

−57%

Table[T]: удобный method-based API

Для компонентов, где несколько операций над одной таблицей — можно обернуть в 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): нативный UPSERT

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

ListRange: Go 1.25 iterators

Вместо 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)
}

Типобезопасные WHERE-хелперы

Вместо ручного 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} остаётся для кастомных операторов.

JOIN: composite-структуры

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

§5. Бенчмарки: производительность в цифрах

Насколько быстр sqlh на практике? В модуле bench/ — воспроизводимые Go-бенчмарки сравнивают raw database/sql, sqlx, GORM и sqlh на одном и том же CRUD-ворклоаде. Все тесты используют in-memory SQLite — никакой внешней настройки.

Воспроизвести на своей машине:

cd bench && go test -bench=. -benchmem -benchtime=1s

CRUD Throughput (ops/sec)

Операция

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

Memory Allocations (bytes/op, allocs/op)

Операция

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 на своём железе для сравнения.

§6. Когда использовать sqlh

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.