The orm package maps plain structs to tables. There is no Active Record: persistence functions take an executor (*database.DB or *database.Tx), so models carry no hidden connection.
Models#
type Post struct {
orm.Model // ID, CreatedAt, UpdatedAt
orm.SoftDeletes // optional: DeletedAt, Delete becomes a soft delete
Title string `json:"title"`
Body string `json:"body"`
AuthorID int64 `json:"author_id" db:"author_id"`
Meta orm.JSON[PostMeta] `json:"meta"` // stored as JSON text; the value is Meta.V
}| Convention | Default | Override |
|---|---|---|
| Table | snake_case plural of the type (Post → posts) | Table() string (orm.Tabler) |
| Column | snake_case of the field (AuthorID → author_id) | db:"name"; db:"-" skips a field |
| Primary key | id | PrimaryKey() string (orm.Keyer) |
orm.TableOf[T]() and orm.Columns[T]() report what Vukem derived.
Writing#
post := models.Post{Title: "Hello"}
err := orm.Create(ctx, db, &post) // sets ID, CreatedAt, UpdatedAt
post.Title = "Edited"
err = orm.Update(ctx, db, &post) // bumps UpdatedAt
err = orm.Delete(ctx, db, &post) // soft delete when the model embeds SoftDeletes
err = orm.Restore(ctx, db, &post)
err = orm.ForceDelete(ctx, db, &post)Unique-constraint violations return an error matching orm.ErrConflict (HTTP 409 when returned from a handler).
Bulk writes go through a query:
n, err := orm.QueryOf[models.Post](db).Where("published", false).Update(ctx, orm.Set{"archived": true})
n, err = orm.QueryOf[models.Post](db).Where("created_at", "<", cutoff).Delete(ctx)orm.CreateMany inserts a slice with multi-row INSERT statements, batched under the bound-parameter limit (32766 on SQLite, 65535 on PostgreSQL and MySQL):
numbers := make([]models.Number, 1000)
for i := range numbers {
numbers[i] = models.Number{RaffleID: raffle.ID, Value: i + 1}
}
err := orm.CreateMany(ctx, db, numbers) // fills timestamps and ids- Atomicity holds for
dbandtxonly: ondbthe batches share one transaction (all rows or none); on atxthey join it. Any otherdatabase.Executor(a wrapper) runs the statements directly, so batches written before a failure stay written and keep their ids. BeforeCreateruns for every model before the first insert.AfterCreateruns after the last, once the transaction ondbhas committed: anAfterCreateerror there means the rows are stored and you still get the error.- Auto-increment ids are filled when every model leaves its id zero (mixing set and zero ids is an error). PostgreSQL and SQLite return them with
RETURNING; they must be strictly increasing (an ascending sequence), otherwiseCreateManyfails and rolls back instead of guessing which row got which id. MySQL cannot report the ids of a multi-row insert, so there such rows are inserted one statement each. - A model whose only column is its generated key is rejected; use
orm.Create.
Querying#
posts, err := orm.QueryOf[models.Post](db).
Where("published", true). // col = ?
Where("views", ">=", 100). // allow-listed operators, including LIKE
WhereIn("author_id", 1, 2, 3).
WhereGroup(func(q orm.Query[models.Post]) orm.Query[models.Post] {
return q.Where("title", "LIKE", "%go%").OrWhere("body", "LIKE", "%go%")
}).
OrderBy("id", orm.Desc).
Limit(20).
Get(ctx) // []models.Post| Builders | Terminals |
|---|---|
Select, Where, OrWhere, WhereIn, WhereNotIn, WhereNull, WhereNotNull, WhereBetween, WhereRaw, WhereGroup | Get, First, Find(id), Count, Exists |
LockForUpdate(), ForShare(), SkipLocked(), NoWait() (row locks) | |
OrderBy(col, orm.Asc/orm.Desc), Latest(), Limit, Offset | Pluck[V](col), Each, Chunk, Paginate |
Scope(fns...), WithTrashed(), OnlyTrashed() | Update(set), Delete(), SQL() |
Column names are validated against the model, never interpolated from input; values are always bound parameters. WhereRaw(sql, args...) is the explicit escape hatch and checks the number of ? placeholders. It finds them the way the dialect lexes SQL, with the same rules as database.Rebind: ? inside literals, quoted identifiers, comments and PostgreSQL dollar-quoted bodies does not count, and ?? is a literal ? on PostgreSQL (jsonb operators). A fragment that ends inside a literal, identifier or comment (a trailing -- comment too) is rejected, since it would swallow the SQL appended after it, and so are explicit $n parameters on PostgreSQL. Without an executor the fragment is lexed with SQLite's rules. The lexer assumes the servers' default string and quote settings; placeholders inside MySQL's executable /*! */ and /*+ */ comments are not supported.
post, err := orm.QueryOf[models.Post](db).Find(ctx, id) // orm.ErrNotFound → 404 in handlers
titles, err := orm.QueryOf[models.Post](db).Pluck[string](ctx, "title")
for post, err := range orm.QueryOf[models.Post](db).Each(ctx) { // streams rows
if err != nil {
return err
}
index(post)
}
page, err := orm.QueryOf[models.Post](db).Latest().Paginate(ctx, c.QueryInt("page", 1), 15)
// page.Items, page.Total, page.Page, page.PerPage, page.LastPage; JSON: {"data": [...], "total": …}Reusable conditions are plain functions:
func Published(q orm.Query[models.Post]) orm.Query[models.Post] { return q.Where("published", true) }
orm.QueryOf[models.Post](db).Scope(Published).Get(ctx)Row locks#
Inside a transaction, lock the rows you read so concurrent requests cannot change them before you write:
err := db.Tx(ctx, func(tx *database.Tx) error {
n, err := orm.QueryOf[models.Number](tx).
Where("raffle_id", raffleID).Where("value", value).
LockForUpdate(). // SELECT … FOR UPDATE
First(ctx)
if err != nil {
return err
}
n.Status = "reserved"
return orm.Update(ctx, tx, &n)
})| Builder | PostgreSQL | MySQL / MariaDB | SQLite |
|---|---|---|---|
LockForUpdate() | FOR UPDATE | FOR UPDATE | no-op |
ForShare() | FOR SHARE | LOCK IN SHARE MODE | no-op |
.SkipLocked() / .NoWait() after a lock | SKIP LOCKED / NOWAIT | same (with ForShare, MySQL 8 only: FOR SHARE …) | no-op (NoWait never fails) |
- A locking query on
dbreturnsorm.ErrLockOutsideTx: without a transaction the lock ends with the statement. This holds on SQLite too, so code stays portable. - SQLite has no row locks; its write transactions (
BEGIN IMMEDIATE) already lock the whole database, so the lock clauses are dropped. CountandExistsignore locks (PostgreSQL rejects them on aggregates), and eager-loaded relations are not locked.
Hooks#
Models can implement BeforeCreate, AfterCreate, BeforeUpdate, AfterUpdate and BeforeDelete (each func(ctx context.Context) error). An error aborts the write.
func (p *Post) BeforeCreate(ctx context.Context) error {
p.Title = strings.TrimSpace(p.Title)
return nil
}Timestamps use orm.WithClock(ctx, now) when present, so tests can fix the time.