Examples
A cookbook of complete, copy-paste-ready recipes for every part of sorm. All examples share one domain — the library-lending system from the Quick Start (Member, Book, Loan, Genre, LibraryCard) — and show the code, the SQL it produces, and the result. For the underlying rules, follow the links into the reference pages.
1. The smallest possible program
One model, one gen run, one file-backed SQLite database — from zero to a persisted row.
// models/models.go
package models
type Book struct {
ID int64 `sorm:"pk,auto"`
ISBN string `sorm:"unique"`
Title string
Copies int
Version int64 `sorm:"version"`
}go run github.com/dvislobokov/sorm/cmd/sorm gen ./models # -> models/sormgenpackage main
import (
"context"
"database/sql"
"fmt"
"log"
_ "modernc.org/sqlite" // pure Go, no cgo
"github.com/dvislobokov/sorm/dialect/lite"
"github.com/dvislobokov/sorm/driver/sqld"
"github.com/dvislobokov/sorm/migrate"
"library/models"
"library/models/sormgen" // importing it registers the models
)
func main() {
ctx := context.Background()
sdb, err := sql.Open("sqlite", "file:library.db")
if err != nil {
log.Fatal(err)
}
if err := migrate.Apply(ctx, sdb, "sqlite"); err != nil { // schema from registered models
log.Fatal(err)
}
db := sqld.Wrap(sdb, lite.Dialect{})
c := sormgen.NewContext(db)
gopl := &models.Book{ISBN: "978-0134190440", Title: "The Go Programming Language", Copies: 2}
c.Books.Add(gopl)
if err := c.SaveChanges(ctx); err != nil {
log.Fatal(err)
}
fmt.Println(gopl.ID) // 1 — the generated key is written back
}See Quick Start for the full walkthrough and Databases for PostgreSQL/MySQL connection recipes.
2. Defining models: the tag toolbox
Everything lives in the single sorm:"..." tag. See Models for the complete reference.
type Member struct {
ID int64 `sorm:"pk,auto"` // DB-generated key
Email string `sorm:"unique"`
Name string
JoinedAt time.Time `sorm:"autoCreate"` // stamped on INSERT
Version int64 `sorm:"version"` // optimistic concurrency
Loans []*Loan `sorm:"hasMany:MemberID"` // navigation, not a column
Card *LibraryCard `sorm:"hasOne:MemberID"`
}
type Loan struct {
ID int64 `sorm:"pk,auto"`
BookID int64 `sorm:"fk:Book.ID,index:idx_loans_book_member"`
MemberID int64 `sorm:"fk:Member.ID,index:idx_loans_book_member"` // composite index
BorrowedAt time.Time `sorm:"autoCreate"`
DueAt time.Time
ReturnedAt *time.Time // pointer = nullable column
UpdatedAt time.Time `sorm:"autoUpdate"` // stamped on INSERT and UPDATE
Book *Book `sorm:"belongsTo:BookID"`
Member *Member `sorm:"belongsTo:MemberID"`
}
type User struct {
ID int64 `sorm:"pk,auto"`
Nickname string `sorm:"col:nick"` // explicit column name
DeletedAt *time.Time `sorm:"softDelete"` // soft deletion
Prefs map[string]any `sorm:"json"` // JSON column
Secret string `sorm:"-"` // not a column
}Rules worth remembering:
- Exactly one
pkfield marks a struct as an entity;autoworks only on integer keys. - A pointer field is a nullable column —
sql.NullStringand friends are rejected. - An exported field pointing at another entity must carry a relation tag or
sorm:"-".
3. Code generation, repeatable
Keep regeneration one go generate away and let CI catch drift. See Code Generation.
// models/models.go
//go:generate go run github.com/dvislobokov/sorm/cmd/sorm gen .
package modelsgo generate ./models
# sorm: generated 7 file(s) for 5 entit(ies) in models/sormgenThe models/sormgen package now holds typed column descriptors (sormgen.Book.Title), relation descriptors, entity metadata, table definitions, and the Context facade. Output is deterministic — regeneration produces stable diffs, so a CI step can verify it is current:
go generate ./models && git diff --exit-code models/sormgenTIP
Importing sormgen — even blank-imported — is what registers your models with the runtime. Migrations, sormtest.NewSQLite, and queries all rely on that registration.
4. Insert an object graph
New entities are wired through navigations, not raw FK values. SaveChanges inserts parents first, obtains the generated keys, and fills the children's FK columns in — all in one transaction. See Sessions.
c := sormgen.NewContext(db)
ada := &models.Member{Email: "ada@library.dev", Name: "Ada Lovelace"}
gopl := &models.Book{ISBN: "978-0134190440", Title: "The Go Programming Language",
Author: "Donovan & Kernighan", Year: 2015, Copies: 2}
c.Members.Add(ada)
c.Books.Add(gopl)
loan := &models.Loan{DueAt: time.Now().AddDate(0, 0, 14)}
loan.Book = gopl // navigation, not loan.BookID = ...
loan.Member = ada
c.Loans.Add(loan)
err := c.SaveChanges(ctx)ada.ID=1 gopl.ID=2 loan.ID=1 loan.BookID=2 loan.MemberID=1
autoCreate stamped: joined=true borrowed=trueA dependency cycle between new entities in one flush is reported as sorm.ErrCyclicGraph.
5. Querying: filters, ordering, paging
sorm.Query[E] starts an untracked, typed query; builders are immutable, so a base builder can be reused. See Queries.
b := sormgen.Book
page := 2
books, err := sorm.Query[models.Book](db).
Where(b.Year.Gte(1990), b.Copies.Gt(0)). // multiple args are ANDed
OrderBy(b.Year.Desc(), b.Title.Asc()).
Limit(10).
Offset((page - 1) * 10).
All(ctx) // []*models.Book, empty slice when nothing matchesToSQL() shows exactly what will run:
SQL: SELECT "id", "isbn", "title", "author", "year", "copies", "version" FROM "books" WHERE ("year" >= ? AND "copies" > ?) ORDER BY "year" DESC, "title" LIMIT 10 OFFSET 10
args: [1990 0]The predicate set depends on the column type — a few common ones:
b.Copies.Eq(0) // Eq(0) is a real condition, not "unset"
b.Year.Between(1990, 2010)
b.Title.HasPrefix("The ") // escaped literal, unlike Like
b.Title.Like("%Go%")
b.ISBN.In("978-0134190440", "978-0262162098")
sormgen.Loan.ReturnedAt.IsNull()6. One row, counting, existence, streaming
// exactly one (or ErrNotFound):
book, err := sorm.Query[models.Book](db).Where(b.ISBN.Eq("978-0134190440")).One(ctx)
if errors.Is(err, sorm.ErrNotFound) {
// 404, not a scan through nil
}
// count:
n, err := sorm.Query[models.Loan](db).Where(sormgen.Loan.ReturnedAt.IsNull()).Count(ctx)
// existence — a Count with Limit(1) reads nicer as:
exists, err := sorm.Query[models.Book](db).Where(b.ISBN.Eq("978-0134190440")).Limit(1).Count(ctx)
_ = exists > 0Iter streams rows without materializing the whole result set (iter.Seq2, Go 1.23+ range-over-func):
for book, err := range sorm.Query[models.Book](db).OrderBy(b.ID.Asc()).Iter(ctx) {
if err != nil {
return err
}
export(book)
}WARNING
Iter is incompatible with With — eager loading needs the full parent set. Such an iterator yields a single error.
7. Combining predicates and filtering by relations
And / Or / Not nest arbitrarily; relation descriptors filter the root entity through correlated EXISTS — no JOINs, no duplicated rows. See Relations.
m, l, g := sormgen.Member, sormgen.Loan, sormgen.Genre
// (author starts with Donovan) OR (year < 2000 AND copies >= 2)
sorm.Query[models.Book](db).Where(
sorm.Or(
b.Author.HasPrefix("Donovan"),
sorm.And(b.Year.Lt(2000), b.Copies.Gte(2)),
),
)
// members with at least one open loan
sorm.Query[models.Member](db).Where(m.Loans.Any(l.ReturnedAt.IsNull()))
// members with no loans at all
sorm.Query[models.Member](db).Where(m.Loans.None())
// loans whose book is tagged "Programming" (belongsTo + many2many, nested)
sorm.Query[models.Loan](db).Where(l.Book.Is(b.Genres.Any(g.Name.Eq("Programming"))))And() with no arguments renders TRUE, Or() with no arguments renders FALSE — safe for building predicate lists dynamically.
8. Typed subqueries
Subqueries carry their value type: an int64 subquery cannot meet a string column. Build the inner query with a nil db — rendering follows the outer query's dialect. See Queries.
// books that have ever been loaned: id IN (SELECT book_id FROM loans)
loaned := sorm.Pick(l.BookID, sorm.Query[models.Loan](nil))
books, err := sorm.Query[models.Book](db).
Where(sorm.InQuery(b.ID, loaned)).
All(ctx)
// books published in the newest year on the shelf: year = (SELECT max(year) FROM books)
maxYear := sorm.PickScalar(sorm.Max[models.Book, int](b.Year), sorm.Query[models.Book](nil))
newest, err := sorm.Query[models.Book](db).
Where(sorm.EqQ(b.Year, maxYear)).
All(ctx)Available comparisons: InQuery, NotInQuery, EqQ, NeqQ, GtQ, GteQ, LtQ, LteQ.
9. Update through change tracking
No Update call in the session world — load, mutate with plain Go, save. SaveChanges diffs against the load-time snapshot and updates only the changed columns. See Sessions.
c := sormgen.NewContext(db)
book, err := c.Books.Where(b.ISBN.Eq("978-0134190440")).One(ctx) // tracked
book.Copies = 5 // plain mutation
err = c.SaveChanges(ctx) // minimal diffThe only statement executed (captured via sorm.Instrument):
UPDATE "books" SET "copies" = ?, "version" = "version" + 1 WHERE "id" = ? AND "version" = ?Identity-map behavior worth knowing:
b1, _ := c.Books.Find(ctx, book.ID) // identity map first — no SQL, same pointer
b1 == book // true
// large read-only listings: skip tracking entirely
list, _ := c.Books.NoTracking().OrderBy(b.Title.Asc()).All(ctx)A SaveChanges with no changes executes no SQL at all.
10. Deleting, and soft delete
// hard delete through the session (children before parents, one transaction):
loans, _ := c.Loans.Where(l.MemberID.Eq(ada.ID)).All(ctx)
c.Loans.Remove(loans...)
err := c.SaveChanges(ctx)For an entity with a softDelete field, Remove becomes an UPDATE stamping the timestamp, and every query filters deleted rows implicitly:
type User struct {
ID int64 `sorm:"pk,auto"`
Email string `sorm:"unique"`
DeletedAt *time.Time `sorm:"softDelete"`
}
sorm.Query[models.User](db).All(ctx) // live rows only
sorm.Query[models.User](db).WithDeleted().All(ctx) // everything
sorm.Query[models.User](db).OnlyDeleted().All(ctx) // trash-bin view
// real purge, bypassing the soft delete:
n, err := sorm.Delete[models.User](db).Where(u.DeletedAt.IsNotNull()).Hard().Exec(ctx)See Models.
11. Transactions with RunInTx
The generated context wraps sorm.RunInTx: commit on nil, rollback on error, automatic retry of transient failures (deadlock, serialization) with exponential backoff, up to 3 retries. See Sessions.
err := c.RunInTx(ctx, func(txc *sormgen.Context) error {
book, err := txc.Books.Find(ctx, bookID)
if err != nil {
return err
}
if book.Copies == 0 {
return errors.New("no copies available") // -> rollback
}
book.Copies--
txc.Loans.Add(&models.Loan{DueAt: due, Book: book, Member: member})
return txc.SaveChanges(ctx) // flushes inside this transaction
})commit err: <nil>
rollback err: business rule: negative stock
copies after rollback: 2Each retry attempt gets a fresh child context — work only through txc inside the closure, and keep non-database side effects idempotent (fn may run more than once). For a transaction you manage yourself, use SaveChangesTx(ctx, tx) or construct the session over an open Tx.
Row locking for queue workers (PostgreSQL/MySQL only):
err := sorm.RunInTx(ctx, db, func(tx sorm.Tx) error {
job, err := sorm.Query[models.Job](tx).
Where(j.State.Eq("pending")).
ForUpdateSkipLocked().
One(ctx)
// ...
return nil
})12. Optimistic concurrency
A Version int64 field tagged sorm:"version" puts AND version = ? on every session UPDATE/DELETE. A lost update surfaces as *sorm.ConflictError, never as silent data loss. See Sessions.
c1, c2 := sormgen.NewContext(db), sormgen.NewContext(db)
b1, _ := c1.Books.Find(ctx, id)
b2, _ := c2.Books.Find(ctx, id)
b1.Copies = 10
_ = c1.SaveChanges(ctx) // ok, version 1 -> 2
b2.Copies = 99
err := c2.SaveChanges(ctx) // stale versionsecond save -> ConflictError: true | sorm: concurrency conflict on books pk=2 (row changed or deleted since load)var conflict *sorm.ConflictError
if errors.As(err, &conflict) {
// conflict.Table == "books", conflict.PK == 2
// reload, merge, retry — or surface HTTP 409
}Set-based Update builders bump the version too, so they cooperate with open sessions instead of hiding behind them.
13. Eager loading with With / Include
One extra query per include, distributed to parents in memory — no row-multiplying JOINs. Includes take child filters, child ordering, and nested includes. See Relations.
members, err := sorm.Query[models.Member](db).
With(
m.Card.Include(), // hasOne
m.Loans.Include( // hasMany with options:
l.ReturnedAt.IsNull(), // child filter
l.DueAt.Asc(), // child ordering
l.Book.Include(), // nested include (ThenInclude analog)
),
).
OrderBy(m.ID.Asc()).
All(ctx)Ada Lovelace card=LC-1001 loans=2
due 2026-07-20: Structure and Interpretation of Computer Programs
due 2026-07-27: The Go Programming Language
Grace Hopper card=LC-1002 loans=0After a hasMany include, an empty slice means "loaded, none matched"; nil means "not loaded". Includes work from sets too (c.Members.With(...)) — the materialized entities are then tracked.
14. many2many: Link and Unlink
sorm does not guess collection diffs for many-to-many — linking is an explicit operation on the descriptor. Both sides must already be persisted. See Relations.
err := sormgen.Book.Genres.Link(ctx, db, gopl, csGenre, progGenre) // INSERT into book_genres
err = sormgen.Book.Genres.Unlink(ctx, db, gopl, progGenre) // DELETE from book_genres
// linking the same pair twice violates the join table's composite PK:
if sorm.IsUniqueViolation(err) { /* already linked — usually fine */ }Loading and filtering work like any other relation:
books, err := sorm.Query[models.Book](db).
With(b.Genres.Include(g.Name.Asc())).
Where(b.Genres.Any(g.Name.Eq("Programming"))).
All(ctx)15. Projections, aggregates, joins
Report shapes come from sorm.From[E] + sorm.Project into an arbitrary struct; matching is strict — a mismatch is a *sorm.ScanError, never a silently dropped field. See Projections.
type memberStat struct {
MemberID int64 `sorm:"member_id"`
Loans int64 `sorm:"loans"`
MaxYear int64 `sorm:"max_year"`
}
stats, err := sorm.Project[memberStat](
sorm.From[models.Loan](db).
Join(sorm.InnerJoinOn(sorm.ColEq(b.ID, l.BookID))).
GroupBy(l.MemberID).
Having(sorm.CountAll[models.Loan]().Gte(1)),
sorm.Field(l.MemberID),
sorm.As(sorm.CountAll[models.Loan](), "loans"),
sorm.As(sorm.Max[models.Loan, int](b.Year), "max_year"),
).All(ctx)member 1: 2 loans, newest book year 2015Engine-specific aggregates live in pgagg (PostgreSQL) and myagg (MySQL); using them on the wrong dialect is an explicit error at execution, never wrong SQL:
import "github.com/dvislobokov/sorm/pgagg"
rows, err := sorm.Project[shelf](
sorm.From[models.Book](db).GroupBy(b.Author),
sorm.Field(b.Author),
sorm.As(pgagg.StringAgg[models.Book](b.Title, "; "), "titles"),
).All(ctx)Anything else can be assembled from parts with NewAgg (AggRaw, AggCol, AggArg, AggLit, AggDialect) — that is exactly how pgagg/myagg are built.
16. Set-based Update, Delete, Upsert
Bulk statements skip the session (and bypass lifecycle hooks) — the EF Core ExecuteUpdate/ExecuteDelete analog. See Queries.
// UPDATE ... WHERE — version column bumped automatically
n, err := sorm.Update[models.Book](db).
Set(b.Copies.Set(7)).
Where(b.ISBN.Eq("978-0262162098")).
Exec(ctx)
// DELETE returned loans
n, err = sorm.Delete[models.Loan](db).
Where(l.ReturnedAt.IsNotNull()).
Exec(ctx)
// a statement without Where is an error unless you mean it:
n, err = sorm.Update[models.Book](db).Set(b.Copies.Set(0)).AllRows().Exec(ctx)Multi-row upsert (ON CONFLICT on PostgreSQL/SQLite, ON DUPLICATE KEY on MySQL):
n, err := sorm.Upsert[models.Book](db).
Rows(book1, book2).
OnConflict(b.ISBN). // conflict target (ignored on MySQL — any unique key fires)
DoUpdate(b.Author, b.Year). // overwrite these columns from the incoming values
Exec(ctx)upsert affected=1 author now="Benjamin C. Pierce" copies kept=7DoNothing() skips conflicting rows instead. Auto-generated PKs are not written back by Upsert — reload if you need the keys.
17. Raw SQL escape hatch
Two hatches, both with strict column matching. Raw scans into entities via their metadata; RawAs scans into any struct — CTEs, window functions, anything. See Queries.
// entities from hand-written SQL:
soon, err := sorm.Raw[models.Loan](db,
`SELECT * FROM loans WHERE returned_at IS NULL AND due_at < ?`,
time.Now().AddDate(0, 0, 10)).All(ctx)
// arbitrary result shape — mapping by sorm:"col" tag or snake_case of the field:
type shelf struct {
Author string `sorm:"author"`
Titles int64 `sorm:"titles"`
}
shelves, err := sorm.RawAs[shelf](db,
`SELECT author, count(*) AS titles FROM books GROUP BY author ORDER BY author`).All(ctx)Abelson & Sussman: 1
Benjamin C. Pierce: 1
Donovan & Kernighan: 1A column/field mismatch returns a *sorm.ScanError listing the Missing and Extra columns. Placeholders follow the connection's dialect ($1 on PostgreSQL, ? on MySQL/SQLite), and InSchema does not rewrite raw SQL.
18. Migrations: declarative, versioned, seeded
Declarative for development — diff the live database against the registered models and apply exactly what is missing. See Migrations.
import (
"github.com/dvislobokov/sorm/migrate"
_ "library/models/sormgen" // registers the desired schema
)
stmts, err := migrate.Plan(ctx, sdb, "postgres") // dry run — print, review
err = migrate.Apply(ctx, sdb, "postgres") // apply; advisory-locked across replicasVersioned for production — SQL files reviewed in PRs, applied in order, protected by checksums:
go run github.com/dvislobokov/sorm/cmd/sorm migrate diff \
-dialect postgres -dir migrations -dev-dsn "postgres://localhost/scratch" \
add_loans ./models
# sorm: created migration migrations/20260713085554_add_loans.sql (+ .down.sql, sorm.sum)applied, err := migrate.Up(ctx, sdb, "postgres", "migrations") // on startup or from CI
pending, err := migrate.Pending(ctx, sdb, "postgres", "migrations")
reverted, err := migrate.Down(ctx, sdb, "postgres", "migrations", 1) // roll back last NOne-time data seeds, recorded in the same history table — re-running on new deploys or other replicas is a no-op:
err := migrate.Seed(ctx, sdb, "postgres", "default-admin",
func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx,
`INSERT INTO users (email, name, active, created_at, version)
VALUES ($1, $2, true, now(), 1)`, "admin@corp.io", "Admin")
return err
})TIP
Migrations always take a *sql.DB. On PostgreSQL open a database/sql handle with the pgx stdlib driver for migrations, and a pgxpool for the runtime.
19. Dialects, read replicas, multi-tenancy
The same models and queries run on all three engines — only the connection wiring differs. See Databases.
// PostgreSQL — pgx pool, one roundtrip per write batch
pool, _ := pgxpool.New(ctx, dsn)
db := pgxd.Wrap(pool)
// MySQL — always parseTime=true so time.Time scans correctly
sdb, _ := sql.Open("mysql", dsn+"?parseTime=true")
db := sqld.Wrap(sdb, my.Dialect{})
// SQLite in-memory — lives in a single connection
sdb, _ := sql.Open("sqlite", ":memory:")
sdb.SetMaxOpenConns(1)
db := sqld.Wrap(sdb, lite.Dialect{})Read/write splitting — untracked SELECTs round-robin over replicas, everything else (writes, transactions, sessions, ForUpdate) stays on the primary:
db := sorm.WithReplicas(pgxd.Wrap(primary), pgxd.Wrap(replica1), pgxd.Wrap(replica2))
fresh, _ := sorm.Query[models.Book](sorm.Primary(db)).All(ctx) // pin one query to the primaryPer-schema multi-tenancy — models stay schema-agnostic, the wrapper qualifies every table:
billing := sorm.InSchema(pgxd.Wrap(pool), "billing") // "billing"."loans"
c := sormgen.NewContext(billing) // the whole unit of work in that schemaComposition order for wrappers: instrumentation outside, InSchema in the middle, WithReplicas inside.
20. Observability: Instrument, query names, OpenTelemetry
One middleware seam sees every operation — queries, execs, batches, begin/commit/rollback. See Observability.
db = sorm.Instrument(db, func(ctx context.Context, op sorm.Op, next func(context.Context) error) error {
start := time.Now()
err := next(ctx)
slog.Info("sql", "kind", op.Kind, "sql", op.SQL, "dur", time.Since(start), "err", err)
return err
})Name queries so telemetry aggregates by operation, not by SQL text:
sorm.Query[models.Book](db).Named("catalog.recent").Where(...).All(ctx) // builder-level
ctx = sorm.WithQueryName(ctx, "checkout") // context-level (wins)OpenTelemetry spans and metrics in one line:
import "github.com/dvislobokov/sorm/otelsorm"
db = otelsorm.Wrap(db,
otelsorm.WithPoolStats(func() otelsorm.PoolStats {
s := pool.Stat()
return otelsorm.PoolStats{
Max: int64(s.MaxConns()), Idle: int64(s.IdleConns()),
Used: int64(s.AcquiredConns()),
WaitCount: s.EmptyAcquireCount(), WaitDuration: s.AcquireDuration(),
}
}),
)Recorded metrics include db.client.operation.duration, sorm.db.statements, sorm.db.errors (by typed error class), sorm.tx.duration, and sorm.tx.retries. SQL text stays on spans, never in metric attributes; query args are off by default (WithArgs() opts in).
21. Testing with sormtest
A test pyramid without mocking sorm.DB. See Observability and Testing.
// 1. Query construction — no database at all:
func TestRecentBooksQuery(t *testing.T) {
q := sorm.Query[models.Book](nil).
Where(sormgen.Book.Year.Gte(1990)).
OrderBy(sormgen.Book.Year.Desc())
sormtest.AssertSQL(t, q,
`SELECT "id", "isbn", "title", "author", "year", "copies", "version" FROM "books" WHERE "year" >= $1 ORDER BY "year" DESC`,
1990)
}
// 2. Data-access code — in-memory SQLite with the real schema, safe under t.Parallel():
func TestCheckout(t *testing.T) {
db := sormtest.NewSQLite(t) // schema applied, closed via t.Cleanup
sormtest.Load(t, db, "testdata/library.yaml") // YAML fixtures, inserted FK-first
db, queries := sormtest.CountQueries(db) // 3. N+1 guard
page, err := sorm.Query[models.Member](db).
With(sormgen.Member.Loans.Include(sormgen.Loan.Book.Include())).
All(ctx)
if err != nil {
t.Fatal(err)
}
if queries.Selects() > 3 {
t.Fatalf("N+1: %d selects for one page", queries.Selects())
}
_ = page
}# testdata/library.yaml — raw rows: every NOT NULL column must be present
members:
- {id: 1, email: ada@library.dev, name: Ada, joined_at: 2026-01-01T00:00:00Z, version: 1}
loans:
- {id: 1, book_id: 1, member_id: 1, borrowed_at: 2026-01-02T00:00:00Z,
due_at: 2026-01-16T00:00:00Z, updated_at: 2026-01-02T00:00:00Z}For PostgreSQL-only features (arrays, pgagg, ForUpdate) use sormtest.NewPostgres(t) — each test binds to a fresh schema in the server pointed to by SORM_TEST_DSN and is skipped when the variable is unset.
22. Error handling, all the cases
Every failure mode is a typed error — no parsing of driver codes or SQL text. See Sessions.
err := c.SaveChanges(ctx)
var conflict *sorm.ConflictError
var ce *sorm.ConstraintError
var se *sorm.ScanError
switch {
case errors.Is(err, sorm.ErrNotFound):
// One/Find matched nothing — the single not-found sentinel across the API
case errors.As(err, &conflict):
// optimistic concurrency: row changed or vanished since load (conflict.Table, conflict.PK)
case errors.As(err, &ce) && ce.Kind == sorm.ConstraintUnique:
// "email already taken" -> HTTP 409
case errors.As(err, &ce) && ce.Kind == sorm.ConstraintForeignKey:
// referenced row is gone
case errors.As(err, &se):
// Raw/RawAs/Project column mismatch: se.Missing / se.Extra list the columns
case errors.Is(err, sorm.ErrCyclicGraph):
// dependency cycle between new entities in one flush
case err != nil:
// driver/network error — instrumented, classified for RunInTx retries
return err
}The one-line shorthand for the most common case:
if sorm.IsUniqueViolation(err) { /* duplicate — 409 or ignore */ }ConstraintError.Kind values: ConstraintUnique, ConstraintForeignKey, ConstraintNotNull, ConstraintCheck.