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-строка, нет проверки на этапе компиляции |
|
4–5 строк на каждый результат только для маппинга колонок на поля |
Транзакции |
|
Нет связи со схемой |
DDL в миграциях, структуры в Go — они расходятся |
Порядок колонок |
Новый столбец → обновлять SQL-строки и |
§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: золотая середина
Фича |
|
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, кеш метаданных, никакой скрытой сложности
§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"` }
Три тега управляют всем:
Тег |
Назначение |
Пример |
|---|---|---|
|
Имя колонки |
|
|
Ограничения, индексы |
|
|
Переопределение типа 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 2UPDATE —
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 (экспериментально).
§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 |
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]: удобный 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)

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?
Например:
таблица migrations с version/appliedat;
sqlh.Migrate(db, []Migration{…});
dry-run/diff mode: сравнить Go-структуру с текущей SQL-схемой и показать недостающие колонки/индексы;
безопасный auto-add только для additive changes: CREATE TABLE, ADD COLUMN с DEFAULT, CREATE INDEX;
запрет на автоматическое DROP/RENAME/CHANGE TYPE без явной ручной миграции;
опциональный backup hook перед применением миграции.
Для локальных AI-агентов это было бы очень ценно: агент развивается быстро, структура памяти меняется часто, но при этом нельзя рисковать потерей локальной истории и состояния.
Интересно, видите ли вы такой слой как часть sqlh v1.0.0, или лучше держать миграции отдельным маленьким пакетом поверх sqlh?

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 ключевые требования, которые я вижу:
Additive only by default —
ADD COLUMN,CREATE TABLE,CREATE INDEX. НикакогоDROPилиALTER COLUMNбез явногоforce.Zero data loss — даже при переименовании поля в Go-структуре старый SQL-столбец не удаляется и не перетирается. В diff показывается: “колонка
fooне используется Go-структурой, но существует в БД”.Graceful rollback — если миграция упала,
_migrationsне обновляется, и при следующем запуске будет повтор. Для SQLite это просто: каждая миграция в своей транзакции.Embedding-friendly — для AI-агентов важны не только колонки, но и индексы (поиск по embedding-векторам, полнотекстовый поиск). Миграции должны уметь создавать индексы без просадки производительности.
А что можно сделать уже сейчас?
Если вы хотите прототипировать такой слой для своего агента — я могу:
Выделить текущий version-трекер (код из предыдущего ответа) в отдельный пакет
Добавить авто-diff: прочитать схему таблицы через
PRAGMA table_info, сравнить с Go-структурой, показать diffОпубликовать как
github.com/kirill-scherba/sqlh/migrate— экспериментально, до v1.0.0
Это займёт день-два. Хотите, сделаю?
Если вкратце: миграции будут в sqlh v1.0.0, но как отдельный подпакет
sqlh/migrate. Ваше видение с additive-only, dry-run и backup — это ровно то, как я это вижу. Спасибо, что сформулировали — это поможет быстрее прийти к правильному API.
istas76
12.06.2026 12:24Да, если вам самому интересно это направление, то экспериментальный
sqlh/migrateбыл бы очень ценен.Мне кажется, для первого прототипа достаточно даже минимального варианта:
migrationsсversion/name/appliedat;DryRun, который ничего не меняет, а только показывает diff;PRAGMA table_info→ сравнение с Go-структурой;безопасный
AutoAdd:CREATE TABLE,ADD COLUMN,CREATE INDEX;запрет на
DROP/RENAME/ALTER TYPEбез явного ручногоRaw;опциональный backup hook перед
Apply.
Для agent memory это важно именно потому, что схема быстро эволюционирует, но локальная история и состояние агента теряться не должны. То есть лучше “увидеть лишнюю старую колонку и оставить её”, чем случайно удалить данные.
Если появится экспериментальный
sqlh/migrate, я бы с интересом попробовал его на сценарии локального AI-agent runtime: memory, task queue, error log, cron results, pending jobs. Там как раз хорошо проверяются additive migrations, dry-run и безопасное обновление схемы.
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— только additivePRAGMA table_infoдля интроспекции — сравниваем с Go-структурой черезdb:"field_name"иdb_keyтэгиDestructive changes (
DROP,RENAME,ALTER TYPE) — только через явныйmigrate.Raw("…"), никакого автоBackup hook — опциональный коллбек перед Apply
Когда?
Я сегодня-завтра набросаю экспериментальный пакет. Основная работа:
Прочитать схему таблицы через
PRAGMA table_info→ Go-структураНаписать diff: какие колонки/индексы нужно добавить
Обернуть в
migrate.Plan+migrate.Apply
Пакет будет в
github.com/kirill-scherba/sqlh/migrate, отдельным подпакетом. Версия —v0.0.1-experimental. После того, как потестите на вашем AI-agent сценарии, можно будет думать о включении в v1.0.0 roadmap.Если хотите, могу позже черкнуть сюда же, когда появится первый тег.

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. Лучше оставить лишнюю старую колонку, чем потерять историю агента.Если будет первый тег/черновик пакета — черкните сюда, пожалуйста.

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

istas76
12.06.2026 12:24Тут, кажется, получился прекрасный образец нового мира: один ИИ помог сформулировать вопрос, второй человеко-ИИ-гибрид получил полезный ответ от автора, а третий бот сидит где-то на локальной LLM, потому что хозяин скупой и лимиты бережёт.
Но свои пять копеек в общий котёл архитектуры agent runtime он всё равно донёс :)

gudvinr
12.06.2026 12:24Если вы пишете на Go и работаете с SQL-базами, вы знаете эту боль.
Боль - это код sqlh.
Дамы и господа, представляю вашему вниманию код инструмента, который обещает "Никаких
interface{}, никаких приведений типов":
Принимаем атрибуты?

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

Погодите, там откуда там контекст? А что такое
errFunc?
Просто пик программного дизайна. Зачем вообще нужны аргументы, builder, функциональные опции, структуры, если можно всё закинуть в
[]any?
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 постараюсь сделать миграцию максимально гладкой.
gudvinr
12.06.2026 12:24Это не трейдофф, а закономерное следствие того, что вы никакого участия в разработке не принимали, судя по всему, учитывая то что и ответ так же скопипастили из выхлопа LLM.
Garbage in - Garbage out

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)

anaxita
12.06.2026 12:24Основная проблема подобных публикаций это изначально неверная мысль о том что go разработчикам сложно писать голый sql.
istas76
Спасибо за статью. Возник прикладной вопрос не про классический CRUD веб-сервиса, а про локальные AI-agent runtime.
Сейчас многие локальные агенты упираются не только в качество LLM, но и в отсутствие нормального структурированного состояния: память пользователя, очередь задач, pending/error jobs, история решений, лимиты попыток, cron-результаты и короткие логи. Из-за этого агент часто тащит лишний контекст в LLM и делает повторные запросы к модели там, где хватило бы обычной базы и детерминированной логики.
Правильно ли я понимаю, что sqlh хорошо ложится на такой сценарий: SQLite как локальное хранилище агента, Go-структуры как схема памяти/задач, Set для upsert воспоминаний и статусов, авто-транзакции для записи задач, а ListRange для чтения длинной истории/логов кусками без загрузки всего в память?
Особенно интересны три момента:
Насколько безопасно использовать sqlh для task queue, где несколько воркеров могут брать задачи из SQLite? Понятно, что нужен атомарный захват задачи через SQL-условие, но есть ли в sqlh удобный паттерн для такого сценария?
Есть ли ограничения при частых Set/Update в SQLite, если агент пишет много мелких событий и статусов? Достаточно ли встроенных retry на database is locked, или лучше сразу проектировать отдельный writer/очередь записи?
Планируются ли миграции схемы? Для agent memory это важно: сегодня у записи памяти 5 полей, завтра добавились confidence, source, expires_at, embedding_key и т.п.
Выглядит так, будто sqlh может быть хорошей серединой для локальных AI-инструментов: не raw SQL на каждую операцию, но и не тяжёлый ORM с магией.
kirill-scherba Автор
Спасибо за развёрнутый вопрос и за то, что присмотрелись к 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). Но вы можете реализовать атомарный захват задачи двумя способами:Вариант А — через транзакцию + условие (рекомендую):
Транзакция здесь не обязательна, потому что
UPDATEв SQLite атомарен сам по себе. Для PostgreSQL можно обернуть вsqlh-транзакцию черезdb.Begin().Вариант Б — через
sqlh.Setс кастомным полемversion:Для 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:
Но если честно: для типичного AI-агента (пишет событие раз в несколько секунд — решение, вызов функции, ответ LLM) — встроенного retry более чем достаточно. Я тестировал sqlh с SQLite в параллельных тестах — lock-ошибки возникают только при настоящей конкуренции.
Дополнительно: включите WAL mode при создании базы:
С busy_timeout SQLite сам ждёт, а sqlh retry — второй уровень защиты. Работает надёжно.
3. Миграции схемы
Это самое слабое место sqlh на сегодня. Встроенных миграций нет, хотя они есть в ROADMAP к v1.0.0.
Что можно сделать сейчас:
Для добавления полей (оптимистичный случай):
sqlh сгенерирует
SELECT id, key, value, confidence FROM memory, и для старых строкconfidenceбудет0(zero value для float64). Если добавленDEFAULTв SQL — ещё лучше.Для ALTER TABLE — используйте raw SQL для миграций + sqlh для CRUD:
Для production советую простой version-трекер:
Резюме
Вы абсолютно правы: sqlh — это хорошая середина для локальных AI-инструментов. Не raw SQL (меньше ошибок, быстрее разработка), не ORM (предсказуемость, лёгкость). SQLite + sqlh дают детерминированное, транзакционное, типобезопасное хранилище, которое не требует отдельного сервера — идеально для CLI-агентов, десктопных рантаймов и embeddable сценариев.
Если появятся ещё вопросы — спрашивайте, интересно обсудить применение sqlh в agent-архитектурах.
JunkieEnjoyer
Очень интересный комментарий, спасибо. У меня вопрос: а как приготовить блины? Никак не могу найти нормальный рецепт, помогите пожалуйста
kirill-scherba Автор
https://cooksy.teonet.app/