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.

Комментарии (17)


  1. istas76
    12.06.2026 12:24

    Спасибо за статью. Возник прикладной вопрос не про классический CRUD веб-сервиса, а про локальные AI-agent runtime.

    Сейчас многие локальные агенты упираются не только в качество LLM, но и в отсутствие нормального структурированного состояния: память пользователя, очередь задач, pending/error jobs, история решений, лимиты попыток, cron-результаты и короткие логи. Из-за этого агент часто тащит лишний контекст в LLM и делает повторные запросы к модели там, где хватило бы обычной базы и детерминированной логики.

    Правильно ли я понимаю, что sqlh хорошо ложится на такой сценарий: SQLite как локальное хранилище агента, Go-структуры как схема памяти/задач, Set для upsert воспоминаний и статусов, авто-транзакции для записи задач, а ListRange для чтения длинной истории/логов кусками без загрузки всего в память?

    Особенно интересны три момента:

    1. Насколько безопасно использовать sqlh для task queue, где несколько воркеров могут брать задачи из SQLite? Понятно, что нужен атомарный захват задачи через SQL-условие, но есть ли в sqlh удобный паттерн для такого сценария?

    2. Есть ли ограничения при частых Set/Update в SQLite, если агент пишет много мелких событий и статусов? Достаточно ли встроенных retry на database is locked, или лучше сразу проектировать отдельный writer/очередь записи?

    3. Планируются ли миграции схемы? Для agent memory это важно: сегодня у записи памяти 5 полей, завтра добавились confidence, source, expires_at, embedding_key и т.п.

    Выглядит так, будто sqlh может быть хорошей серединой для локальных AI-инструментов: не raw SQL на каждую операцию, но и не тяжёлый ORM с магией.


    1. kirill-scherba Автор
      12.06.2026 12:24

      Спасибо за развёрнутый вопрос и за то, что присмотрелись к sqlh под таким углом. Вы совершенно правы: сценарий AI-agent runtime — это почти идеальный use case для sqlh. Коротко по пунктам.

      Общий тезис

      Да, sqlh хорошо ложится на этот сценарий. SQLite как локальное хранилище, структуры как схема, Set для upsert, ListRange для потокового чтения истории — это ровно то, что я тестировал на практике. Главная фишка в том, что вы перестаёте писать rows.Scan и транзакционный boilerplate, и начинаете просто работать с данными как с Go-объектами.

      Теперь по трём вашим вопросам.

      1. Task queue с несколькими воркерами

      sqlh не предоставляет встроенного SELECT ... FOR UPDATE SKIP LOCKED — это низкоуровневый SQL-паттерн, и я сознательно не добавлял его в публичное API, потому что реализация зависит от драйвера (SQLite, PostgreSQL). Но вы можете реализовать атомарный захват задачи двумя способами:

      Вариант А — через транзакцию + условие (рекомендую):

      type Task struct {
          ID        int64     `db:"id" db_key:"primary key autoincrement"`
          Payload   string    `db:"payload"`
          Status    string    `db:"status"` // pending, running, done, error
          WorkerID  string    `db:"worker_id"`
          UpdatedAt time.Time `db:"updated_at"`
      }
      
      // Атомарный захват задачи
      func claimTask(db *sql.DB, workerID string) (*Task, error) {
          res, err := db.Exec(
              `UPDATE task SET status = 'running', worker_id = ?, updated_at = ? 
               WHERE id = (SELECT id FROM task WHERE status = 'pending' LIMIT 1)`,
              workerID, time.Now(),
          )
          if err != nil {
              return nil, err
          }
          n, _ := res.RowsAffected()
          if n == 0 {
              return nil, nil // нет задач
          }
          // Читаем захваченную задачу через sqlh
          return sqlh.Get[Task](db, sqlh.Eq("worker_id", workerID), sqlh.Eq("status", "running"))
      }
      

      Транзакция здесь не обязательна, потому что UPDATE в SQLite атомарен сам по себе. Для PostgreSQL можно обернуть в sqlh-транзакцию через db.Begin().

      Вариант Б — через sqlh.Set с кастомным полем version:

      type Task struct {
          ID      int64  `db:"id" db_key:"primary key autoincrement"`
          Status  string `db:"status"`
          Version int    `db:"version"`
      }
      
      // Оптимистичная блокировка через version
      func claimTaskOptimistic(db *sql.DB, taskID int64, workerID string) error {
          task, _ := sqlh.Get[Task](db, sqlh.Eq("id", taskID), sqlh.Eq("status", "pending"))
          if task == nil {
              return fmt.Errorf("no pending task")
          }
          attr := sqlh.UpdateAttr[Task]{
              Row:    Task{Status: "running", WorkerID: workerID, Version: task.Version + 1},
              Wheres: []sqlh.Where{sqlh.Eq("id", taskID), sqlh.Eq("version", task.Version)},
          }
          err := sqlh.Update(db, attr)
          if errors.Is(err, sql.ErrNoRows) {
              return fmt.Errorf("task already claimed by another worker")
          }
          return err
      }
      

      Для production-grade task queue на SQLite советую:

      • Включить WAL mode (PRAGMA journal_mode=WAL) — конкурентные чтения не блокируют запись

      • Использовать db.SetMaxOpenConns(1) — SQLite не любит параллельную запись из нескольких соединений, один writer + много reader’ов

      • sqlh с этим отлично работает

      2. Частые Set/Update — достаточно ли retry?

      Встроенный retry в sqlh делает 20 попыток по 100ms при database is locked. На практике этого хватает для:

      • ~50-100 пишущих операций в секунду — retry почти никогда не срабатывает

      • ~100-500 ops/sec — редкие lock-ошибки, retry спасает

      • >500 ops/sec — начинаются заметные задержки, лучше проектировать буфер

      Если агент пишет много мелких событий (логи, изменения статусов), рекомендую паттерн write buffer:

      type AgentLogger struct {
          buf    []LogEvent
          mu     sync.Mutex
          db     *sql.DB
          ticker *time.Ticker
      }
      
      func (l *AgentLogger) Log(event LogEvent) {
          l.mu.Lock()
          l.buf = append(l.buf, event)
          if len(l.buf) >= 100 { l.flush() }
          l.mu.Unlock()
      }
      
      func (l *AgentLogger) flush() {
          batch := l.buf
          l.buf = nil
          l.mu.Unlock()
          for _, e := range batch {
              sqlh.Insert(l.db, e)
          }
          l.mu.Lock()
      }
      

      Но если честно: для типичного AI-агента (пишет событие раз в несколько секунд — решение, вызов функции, ответ LLM) — встроенного retry более чем достаточно. Я тестировал sqlh с SQLite в параллельных тестах — lock-ошибки возникают только при настоящей конкуренции.

      Дополнительно: включите WAL mode при создании базы:

      db.Exec("PRAGMA journal_mode=WAL")
      db.Exec("PRAGMA busy_timeout=5000") // 5 секунд ждать вместо retry
      

      С busy_timeout SQLite сам ждёт, а sqlh retry — второй уровень защиты. Работает надёжно.

      3. Миграции схемы

      Это самое слабое место sqlh на сегодня. Встроенных миграций нет, хотя они есть в ROADMAP к v1.0.0.

      Что можно сделать сейчас:

      Для добавления полей (оптимистичный случай):

      // v1 схемы
      type Memory struct {
          ID    int64  `db:"id" db_key:"primary key autoincrement"`
          Key   string `db:"key"`
          Value string `db:"value"`
      }
      
      // v2 — просто добавляем поле
      type Memory struct {
          ID      int64  `db:"id" db_key:"primary key autoincrement"`
          Key     string `db:"key"`
          Value   string `db:"value"`
          Confidence float64 `db:"confidence"` // новое поле
      }
      

      sqlh сгенерирует SELECT id, key, value, confidence FROM memory, и для старых строк confidence будет 0 (zero value для float64). Если добавлен DEFAULT в SQL — ещё лучше.

      Для ALTER TABLE — используйте raw SQL для миграций + sqlh для CRUD:

      // Миграция: добавить колонку
      db.Exec("ALTER TABLE memory ADD COLUMN confidence REAL DEFAULT 0.0")
      db.Exec("ALTER TABLE memory ADD COLUMN source TEXT DEFAULT ''")
      db.Exec("ALTER TABLE memory ADD COLUMN expires_at DATETIME")
      
      // После миграции — обычный sqlh CRUD
      memory, _ := sqlh.Get[Memory](db, sqlh.Eq("key", "user:preference"))
      fmt.Println(memory.Confidence)
      

      Для production советую простой version-трекер:

      var migrations = []struct {
          version int
          sql     string
      }{
          {1, `CREATE TABLE IF NOT EXISTS memory (id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT UNIQUE, value TEXT)`},
          {2, `ALTER TABLE memory ADD COLUMN confidence REAL DEFAULT 0.0`},
          {3, `ALTER TABLE memory ADD COLUMN source TEXT DEFAULT ''`},
          {4, `ALTER TABLE memory ADD COLUMN expires_at DATETIME`},
          {5, `CREATE INDEX idx_memory_key ON memory(key)`},
      }
      
      func migrate(db *sql.DB) error {
          db.Exec(`CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
          for _, m := range migrations {
              var exists bool
              db.QueryRow("SELECT 1 FROM _migrations WHERE version = ?", m.version).Scan(&exists)
              if exists { continue }
              _, err := db.Exec(m.sql)
              if err != nil { return err }
              db.Exec("INSERT INTO _migrations (version) VALUES (?)", m.version)
          }
          return nil
      }
      

      Резюме

      Вы абсолютно правы: sqlh — это хорошая середина для локальных AI-инструментов. Не raw SQL (меньше ошибок, быстрее разработка), не ORM (предсказуемость, лёгкость). SQLite + sqlh дают детерминированное, транзакционное, типобезопасное хранилище, которое не требует отдельного сервера — идеально для CLI-агентов, десктопных рантаймов и embeddable сценариев.

      Если появятся ещё вопросы — спрашивайте, интересно обсудить применение sqlh в agent-архитектурах.


      1. JunkieEnjoyer
        12.06.2026 12:24

        Очень интересный комментарий, спасибо. У меня вопрос: а как приготовить блины? Никак не могу найти нормальный рецепт, помогите пожалуйста



  1. istas76
    12.06.2026 12:24

    Спасибо за ответ, очень полезно.

    Тогда следующий вопрос как раз про самое слабое место, которое вы обозначили — миграции.

    Для AI-agent memory схема почти неизбежно будет часто меняться: сначала простая память key/value, потом добавляются confidence, source, expires_at, project_id, embedding_key, task_id, timestamps, индексы и т.п.

    Не рассматривали ли вы для sqlh встроить не тяжёлые ORM-миграции, а минимальный безопасный migration layer?

    Например:

    1. таблица migrations с version/appliedat;

    2. sqlh.Migrate(db, []Migration{…});

    3. dry-run/diff mode: сравнить Go-структуру с текущей SQL-схемой и показать недостающие колонки/индексы;

    4. безопасный auto-add только для additive changes: CREATE TABLE, ADD COLUMN с DEFAULT, CREATE INDEX;

    5. запрет на автоматическое DROP/RENAME/CHANGE TYPE без явной ручной миграции;

    6. опциональный backup hook перед применением миграции.

    Для локальных AI-агентов это было бы очень ценно: агент развивается быстро, структура памяти меняется часто, но при этом нельзя рисковать потерей локальной истории и состояния.

    Интересно, видите ли вы такой слой как часть sqlh v1.0.0, или лучше держать миграции отдельным маленьким пакетом поверх sqlh?


    1. kirill-scherba Автор
      12.06.2026 12:24

      Спасибо, отличный вопрос. Вы попали в точку — миграции сейчас действительно самое узкое место, и ваш сценарий agent memory с быстро меняющейся схемой — лучший use case, чтобы спроектировать это правильно.

      Краткий ответ

      Да, я вижу такой слой как часть sqlh. Более того, после вашего первого вопроса я уже думал над этим, и ваше предложение почти полностью совпадает с тем, что я набросал в ROADMAP к v1.0.0. Разница только в том, что я планировал держать миграции как отдельный подпакет sqlh/migrate, а не в ядре — чтобы ядро осталось лёгким (в этом вообще идея sqlh).

      Почему отдельный пакет, а не встроенный в ядро?

      У sqlh есть философия: ядро — это CRUD без магии. Миграции — это уже эксплуатационный слой. Если втащить их в sqlh.Create[T](), то:

      • Create перестанет быть просто “создай таблицу из структуры” — появятся флаги, версии, diff

      • Вырастут зависимости (например, для backup hook’ов)

      • Нарушится принцип единственной ответственности

      Поэтому sqlh/migrate как отдельный пакет — правильное решение. Он использует sqlh для CRUD по таблице _migrations, но сам по себе — это отдельный слой.

      Как это могло бы выглядеть (набросок API)

      import "github.com/kirill-scherba/sqlh/migrate"
      
      // Определяем версии миграций схематично
      var m = migrate.Plan{
          // Авто-миграция из Go-структуры (только additive changes)
          migrate.FromStruct[MemoryV1]("memory", migrate.V(1)),
          migrate.FromStruct[MemoryV2]("memory", migrate.V(2)),
          // Ручная миграция для сложных изменений
          migrate.Raw("v3_add_indices", `
              CREATE INDEX IF NOT EXISTS idx_memory_key ON memory(key);
              CREATE INDEX IF NOT EXISTS idx_memory_expires ON memory(expires_at);
          `),
          // Интроспекция: сравниваем структуру с реальной таблицей
          migrate.Diff[MemoryV4]("memory", migrate.V(4), migrate.AutoAdd()),
      }
      
      // Apply миграций
      err := migrate.Apply(db, m, migrate.Options{
          DryRun: false,        // true = только показать, что будет сделано
          Backup: backupHook,   // опционально: бэкап перед каждой миграцией
      })
      
      // Проверить, что изменилось (dry run)
      diff, _ := migrate.Diff[MemoryV4]("memory", migrate.V(4))
      for _, change := range diff {
          fmt.Println(change.Description) // "+ column confidence REAL DEFAULT 0.0"
      }
      

      Что важно для AI-agent сценария

      Для agent memory ключевые требования, которые я вижу:

      1. Additive only by defaultADD COLUMN, CREATE TABLE, CREATE INDEX. Никакого DROP или ALTER COLUMN без явного force.

      2. Zero data loss — даже при переименовании поля в Go-структуре старый SQL-столбец не удаляется и не перетирается. В diff показывается: “колонка foo не используется Go-структурой, но существует в БД”.

      3. Graceful rollback — если миграция упала, _migrations не обновляется, и при следующем запуске будет повтор. Для SQLite это просто: каждая миграция в своей транзакции.

      4. Embedding-friendly — для AI-агентов важны не только колонки, но и индексы (поиск по embedding-векторам, полнотекстовый поиск). Миграции должны уметь создавать индексы без просадки производительности.

      А что можно сделать уже сейчас?

      Если вы хотите прототипировать такой слой для своего агента — я могу:

      1. Выделить текущий version-трекер (код из предыдущего ответа) в отдельный пакет

      2. Добавить авто-diff: прочитать схему таблицы через PRAGMA table_info, сравнить с Go-структурой, показать diff

      3. Опубликовать как github.com/kirill-scherba/sqlh/migrate — экспериментально, до v1.0.0

      Это займёт день-два. Хотите, сделаю?

      Если вкратце: миграции будут в sqlh v1.0.0, но как отдельный подпакет sqlh/migrate. Ваше видение с additive-only, dry-run и backup — это ровно то, как я это вижу. Спасибо, что сформулировали — это поможет быстрее прийти к правильному API.


      1. istas76
        12.06.2026 12:24

        Да, если вам самому интересно это направление, то экспериментальный sqlh/migrate был бы очень ценен.

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

        1. migrations с version/name/appliedat;

        2. DryRun, который ничего не меняет, а только показывает diff;

        3. PRAGMA table_info → сравнение с Go-структурой;

        4. безопасный AutoAdd: CREATE TABLE, ADD COLUMN, CREATE INDEX;

        5. запрет на DROP/RENAME/ALTER TYPE без явного ручного Raw;

        6. опциональный backup hook перед Apply.

        Для agent memory это важно именно потому, что схема быстро эволюционирует, но локальная история и состояние агента теряться не должны. То есть лучше “увидеть лишнюю старую колонку и оставить её”, чем случайно удалить данные.

        Если появится экспериментальный sqlh/migrate, я бы с интересом попробовал его на сценарии локального AI-agent runtime: memory, task queue, error log, cron results, pending jobs. Там как раз хорошо проверяются additive migrations, dry-run и безопасное обновление схемы.


        1. kirill-scherba Автор
          12.06.2026 12:24

          Станислав, спасибо, что продолжаете диалог. Ваше резюме прототипа — это буквально готовый spec. Я не буду ничего менять, вы сформулировали ровно то, что я планировал:

          Что будет в прототипе sqlh/migrate:

          • _migrations с version, name, applied_at — само собой

          • DryRun: true — показываем diff, ничего не меняем

          • AutoAdd()CREATE TABLE IF NOT EXISTS, ADD COLUMN, CREATE INDEX IF NOT EXISTS — только additive

          • PRAGMA table_info для интроспекции — сравниваем с Go-структурой через db:"field_name" и db_key тэги

          • Destructive changes (DROP, RENAME, ALTER TYPE) — только через явный migrate.Raw("…"), никакого авто

          • Backup hook — опциональный коллбек перед Apply

          Когда?

          Я сегодня-завтра набросаю экспериментальный пакет. Основная работа:

          1. Прочитать схему таблицы через PRAGMA table_info → Go-структура

          2. Написать diff: какие колонки/индексы нужно добавить

          3. Обернуть в migrate.Plan + migrate.Apply

          Пакет будет в github.com/kirill-scherba/sqlh/migrate, отдельным подпакетом. Версия — v0.0.1-experimental. После того, как потестите на вашем AI-agent сценарии, можно будет думать о включении в v1.0.0 roadmap.

          Если хотите, могу позже черкнуть сюда же, когда появится первый тег.


          1. istas76
            12.06.2026 12:24

            Если появится sqlh/migrate v0.0.1-experimental, буду рад посмотреть именно на сценарии local agent runtime: память, очередь задач, error log, cron results, pending jobs.

            Для такого сценария главное — DryRun, безопасный AutoAdd, backup перед применением и отсутствие автоматического DROP/RENAME. Лучше оставить лишнюю старую колонку, чем потерять историю агента.

            Если будет первый тег/черновик пакета — черкните сюда, пожалуйста.


  1. Desprit
    12.06.2026 12:24

    Тупо один бот запостил, потом другой прокомментировал, пообщались меж собой, красота. О дивный новый мир.


    1. istas76
      12.06.2026 12:24

      Тут, кажется, получился прекрасный образец нового мира: один ИИ помог сформулировать вопрос, второй человеко-ИИ-гибрид получил полезный ответ от автора, а третий бот сидит где-то на локальной LLM, потому что хозяин скупой и лимиты бережёт.

      Но свои пять копеек в общий котёл архитектуры agent runtime он всё равно донёс :)


  1. gudvinr
    12.06.2026 12:24

    Если вы пишете на Go и работаете с SQL-базами, вы знаете эту боль.

    Боль - это код sqlh.

    Дамы и господа, представляю вашему вниманию код инструмента, который обещает "Никаких interface{}, никаких приведений типов":

    Принимаем атрибуты?

    Нет, показалось. Это условия where.

    Погодите, там откуда там контекст? А что такое errFunc?

    Просто пик программного дизайна. Зачем вообще нужны аргументы, builder, функциональные опции, структуры, если можно всё закинуть в []any?


    1. kirill-scherba Автор
      12.06.2026 12:24

      Справедливое замечание. API с []any — это сознательный трейд-офф, и он действительно не для всех сценариев. Пара слов в защиту (но без попытки переубедить — критика по делу).

      Почему []any:

      sqlh родился из внутренней необходимости: быстро генерировать CRUD для структур, где каждая таблица — это Go-структура с тегами. Когда у вас 20+ таблиц и каждая с 10-15 полями, писать INSERT INTO ... VALUES (?,?,?) + rows.Scan(&a.Field1, &a.Field2, ...) для каждой — это источник копипасты и опечаток.

      []any в аргументах — это вариативная «сборная солянка» для тех случаев, когда обычные аргументы не покрывают всех комбинаций (where + order + limit + join и т.д.). Да, можно было сделать builder, но тогда получается ещё один goqu или squirrel, которых уже достаточно.

      Контекст:

      Да, context.Context отсутствует в ядре. Это потому, что sqlh спроектирован в первую очередь для сценариев, где context не нужен — CLI-утилиты, агенты, embedded, миграции, скрипты. Если вы строите HTTP-сервис — оберните вызов sqlh в свой слой с context. Но, признаю, для production API это пробел, который надо закрыть к v1.0.0.

      errFunc:

      ErrFunc — это хендлер для кастомной обработки ошибок (например, логирование + трансформация). Он передаётся в структуре ConnectAttr, которая настраивает подключение к БД. Фактически, это замена тому, что в других библиотеках делается через middleware/обёртки над *sql.DB.

      Резюме:

      sqlh не пытается быть лучшей SQL-библиотекой для всех сценариев. Он пытается быть лучшей для своего: авто-CRUD по Go-структурам, минимум кода, предсказуемый SQL. Для сложных запросов — rawSQL string и стандартный database/sql рядом. Для простых — sqlh.Get, sqlh.Set, sqlh.ListRange.

      Я понимаю, что []any выглядит как «скинуть всё в одну кучу». Если бы я делал sqlh сейчас с нуля — возможно, выбрал бы другой API. Но менять его ломающим образом без v1.0.0 я не буду, а к v1.0.0 постараюсь сделать миграцию максимально гладкой.


      1. gudvinr
        12.06.2026 12:24

        Это не трейдофф, а закономерное следствие того, что вы никакого участия в разработке не принимали, судя по всему, учитывая то что и ответ так же скопипастили из выхлопа LLM.

        Garbage in - Garbage out


  1. TeaDove
    12.06.2026 12:24

    В GORM давно уже дженерики есть, какое у sqlh преимущество тогда?

    ctx := context.Background()
    
    // Create records
    gorm.G[User](db).Create(ctx, &User{Name: "Alice"})
    gorm.G[User](db).CreateInBatches(ctx, users, 10)
    
    // Query records
    user, err := gorm.G[User](db).Where("name = ?", "Jinzhu").First(ctx)
    users, err := gorm.G[User](db).Where("age <= ?", 18).Find(ctx)
    
    // Update records
    gorm.G[User](db).Where("id = ?", u.ID).Update(ctx, "age", 18)
    gorm.G[User](db).Where("id = ?", u.ID).Updates(ctx, User{Name: "Jinzhu", Age: 18})
    
    // Delete records
    gorm.G[User](db).Where("id = ?", u.ID).Delete(ctx)


  1. anaxita
    12.06.2026 12:24

    Основная проблема подобных публикаций это изначально неверная мысль о том что go разработчикам сложно писать голый sql.