CRUD Operations
> Create, read, update, and delete database records with Velocity ORM's ctx-first API.
Every state-changing entry point on the ORM (and every read terminal) takes context.Context as its first positional argument. There is no implicit auto-commit and no chain-level WithContext decoration: pass the ctx your handler already holds, and a tx slot in that ctx (set by Manager.Transaction or WithTxContext) automatically enrolls the call in the surrounding transaction. A bare context.Background() routes through the pool driver.
Create
Save Instance
orm.Save is the package-level persistence helper. Pass nil for the manager to use the package default registered by velocity.New().
user := User{
Name: "John Doe",
Email: "john@example.com",
Role: "user",
}
if err := orm.Save(ctx, nil, &user); err != nil {
return err
}
fmt.Printf("User created with ID: %d\n", user.ID)The same call updates an existing row when orm.IsExisting(&user) is true. Rows loaded via Find, Where(...).Get, First, etc. are marked existing automatically.
Create with Map
user, err := User{}.Create(ctx, map[string]any{
"name": "Jane Doe",
"email": "jane@example.com",
"role": "admin",
})Create accepts a map[string]any or a pre-built *User. Map-based writes are governed by the model’s mass-assignment policy and are denied unless the model declares one, see Mass Assignment below.
Create Multiple
users := []User{
{Name: "Alice", Email: "alice@example.com"},
{Name: "Bob", Email: "bob@example.com"},
}
if err := (User{}).CreateMany(ctx, users); err != nil {
return err
}Iteration is sequential. The first error short-circuits; preceding rows are part of the in-flight tx when ctx carries one, so a Manager.Transaction closure can return that error to roll back the partial batch.
First Or Create
Find by criteria; insert with conditions ∪ values if nothing matches.
user, err := User{}.FirstOrCreate(ctx,
map[string]any{"email": "john@example.com"}, // Search criteria
map[string]any{"name": "John Doe", "role": "user"}, // Creation attributes
)Returns (*T, error). The “did we just create it” signal moved to a side-channel: call orm.IsExisting(user) after the call if you need it (it is always true on the returned pointer; the distinction lives in your branching logic, e.g. counting rows beforehand).
Update Or Create
Lookup, update if found, insert if not.
user, err := User{}.UpdateOrCreate(ctx,
map[string]any{"email": "john@example.com"},
map[string]any{"name": "John Updated", "role": "admin"},
)The Query[T] chain forms exist too: Model[T]{}.Where(...).FirstOrCreate(ctx, conditions, values) (and the UpdateOrCreate counterpart) accept the same (ctx, conditions, values) triple, so you can scope the lookup with extra Where clauses before the helper resolves.
Mass Assignment
Writing a model from a map[string]any is attacker-shaped input: a client that can slip a role or is_admin key into a JSON body would otherwise write those columns. Velocity is deny-by-default here. A model must declare a policy before ANY application column can be written from a map.
Declare an allowlist with AssignableFields(), which satisfies the orm.Assignable interface:
type User struct {
orm.Model[User]
Name string `orm:"column:name"`
Email string `orm:"column:email"`
Role string `orm:"column:role"`
IsAdmin bool `orm:"column:is_admin"`
}
// Only these fields may be set from a map.
func (User) AssignableFields() []string {
return []string{"name", "email"}
}Or a denylist with ProtectedFields(), which satisfies orm.Protected:
func (User) ProtectedFields() []string {
return []string{"role", "is_admin"}
}Both key on the snake_case Go field name, not the orm:"column:..." tag value. That is the security invariant: a payload submitting the column alias resolves to the same policy entry, so the tag cannot be used to walk around the allowlist. Declaring both narrows twice: a field must be in the allowlist AND absent from the denylist.
Deny by default
A model that declares neither, and does not opt out, resolves to an empty allowlist. Map-based writes against it fail with *orm.MassAssignmentError naming the model and the rejected keys, before any SQL is compiled:
// Comment declares no policy, so every application column is denied.
type Comment struct {
orm.Model[Comment]
Body string `orm:"column:body"`
Approved bool `orm:"column:approved"`
}
_, err := Comment{}.Create(ctx, map[string]any{"body": "hi", "approved": true})
var massErr *orm.MassAssignmentError
if errors.As(err, &massErr) {
log.Warn("mass assignment denied",
"model", massErr.Model, // e.g. "models.Comment"
"keys", massErr.Keys) // the rejected map keys, in column order
}Once a policy IS declared, the established semantics resume: disallowed keys are silently skipped rather than erroring.
What the policy covers
| Path | Behaviour |
|---|---|
Create(map) | Policed. No declared policy rejects the write with *MassAssignmentError. |
FirstOrCreate, UpdateOrCreate | Policed across conditions AND values merged, because a condition key becomes a written column on the insert branch. |
Update(ctx, updates), static and chain form | Policed on the updates map, which is attacker-shaped exactly like a create map. |
Create(*T) | Deny-by-default does not apply, but a DECLARED policy still zeroes every non-assignable field before the insert. |
orm.Save, CreateMany | Not policed. You built the struct in code, so there is no map to police. |
Framework-managed embedded columns (id, created_at, updated_at, deleted_at) bypass the policy by design and never appear in a MassAssignmentError. That is what lets the soft-delete restore below pass deleted_at through a mass Update.
Key matching on the policed paths is case-insensitive against both the SQL column name and the snake-cased field name, so neither a casing variant like IS_ADMIN nor a column alias slips into the compiled statement.
Opting out
Models that genuinely want every column writable from a map implement the explicit marker:
func (ImportRow) AllowAllColumns() bool { return true }Declaring ProtectedFields() with an empty slice is equivalent, since an empty denylist guards nothing. When the model also declares AssignableFields() or ProtectedFields(), the declared policy wins and AllowAllColumns has no effect.
To inspect the resolved policy directly, orm.AccessFor(&model) returns the orm.AssignmentAccess the write paths use, and AssignmentAccess.Allows(fieldNameKey) answers for a single snake_case field name.
Read
See Query Builder for the full surface. The static helpers terminal in ctx:
user, err := User{}.Find(ctx, 1)
user, err := User{}.FindBy(ctx, "email", "john@example.com")
user, err := User{}.First(ctx)
user, err := User{}.Last(ctx)
users, err := User{}.All(ctx)
// Chained queries return *Query[T]; terminal methods take ctx.
users, err := User{}.Where("role = ?", "admin").Get(ctx)
count, err := User{}.Count(ctx)
exists, err := User{}.Where("email = ?", email).Exists(ctx)Update
Update via Save
Modify the loaded struct, then re-save:
user, err := User{}.Find(ctx, 1)
if err != nil {
return err
}
user.Name = "Updated Name"
user.Email = "newemail@example.com"
if err := orm.Save(ctx, nil, user); err != nil {
return err
}Because Find marked the row as existing, Save takes the UPDATE branch.
Mass Update
Update by static helper:
affected, err := User{}.Update(ctx,
map[string]any{"role": "guest"}, // conditions
map[string]any{"active": false}, // updates
)Update through the chain when conditions are richer than field = value:
affected, err := User{}.
Where("role = ?", "guest").
Where("created_at < ?", cutoff).
Update(ctx, map[string]any{"active": false})Both forms run the updates map through the same mass-assignment gate as Create(map): a model with no declared policy rejects the write with *orm.MassAssignmentError before SQL compilation. See Mass Assignment. Only the updates map is checked here; the conditions map compiles into the WHERE clause. FirstOrCreate and UpdateOrCreate check conditions and values together, since a condition key becomes a written column when the insert branch fires.
updated_at is stamped automatically with the driver-appropriate NOW() / CURRENT_TIMESTAMP sentinel; pass orm.NOW (or any other orm.RawSQL value) explicitly when you need a column other than updated_at to take the server’s clock.
Bulk write hooks (post-commit)
Per-row AfterCommit / AfterRollback hooks fire on Save / Create but
NOT on bulk Update / Delete / ForceDelete: the bulk path never
hydrates row instances. Models that need to react to a bulk write once the
surrounding tx commits implement BulkAfterCommitHook:
type Post struct {
orm.Model[Post]
Title string `orm:"column:title"`
}
func (Post) BulkAfterCommit(ctx context.Context, ids []any, op orm.BulkOp) error {
// op is BulkOpUpdate, BulkOpDelete, or BulkOpForceDelete
// ids is the affected primary-key set (possibly empty)
return cache.ForgetTags(ctx, "posts")
}The hook fires exactly once per bulk statement, even when zero rows
matched (len(ids) == 0). The receiver is the zero value; do NOT touch it.
Composite primary keys are unsupported on this path; reach for
Query.WithRowHooks() instead, which materialises rows and falls back to
the per-row AfterCommitHook contract.
ID-capture by driver
| Driver | Strategy | Race window | QueryExecuted events |
|---|---|---|---|
| PostgreSQL | RETURNING <pk> appended to the write | none (atomic) | one per statement |
| MySQL / SQLite | Pre-SELECT before the write | yes (rows may shift) | two per statement (SELECT + write) |
The Postgres path captures ids atomically inside the write itself. MySQL and SQLite issue a SELECT first, then the write; rows can shift between the two under concurrent traffic.
WithBulkLock for race-free capture on the pre-SELECT path
When the bulk hook MUST observe exactly the rows that committed, chain
WithBulkLock to issue the pre-SELECT with FOR UPDATE:
mgr.Transaction(ctx, func(ctx context.Context) error {
_, err := orm.Model[Post]{}.
Where("status = ?", "draft").
Where("created_at < ?", cutoff).
WithBulkLock().
Delete(ctx)
return err
})Contract:
- Only meaningful inside
Manager.Transaction. Outside a tx the auto-commit releases the lock immediately, making the call a no-op. - No-op on the atomic RETURNING path (Postgres today, plus any adapter
that opts in via
drivers.ReturningGrammar). The write IS the capture, so there is no pre-SELECT to lock. - No-op on SQLite. Its grammar accepts the
LockForUpdateflag but never emitsFOR UPDATE(SQLite has no row-level locking). - Cost: lock contention. Concurrent writers that touch the same rows block for the duration of the surrounding tx.
- Propagates through
Query.Cloneand the soft-deleteDelete -> Updatedelegation;q.WithBulkLock().Delete(ctx)locks the pre-SELECT for soft-deletable models on the pre-SELECT path.
Reach for WithBulkLock only when exact fidelity between captured ids and
committed rows matters more than throughput, and only on MySQL.
Increment / Decrement
// Static helpers
err := User{}.Increment(ctx, "login_count") // +1
err := User{}.Increment(ctx, "points", 100) // +100
err := User{}.Decrement(ctx, "credits", 10) // -10
// Chain form: scope the rows first
err := User{}.Where("active = ?", true).Increment(ctx, "bonus", 5)The generated UPDATE is atomic (SET col = col + ?) so concurrent increments do not lose updates.
Delete
Soft Delete
Models composed with orm.SoftDeleteModel[T] (or any composition that includes the SoftDeletes[T] trait) carry a deleted_at *time.Time column. Delete on those models stamps deleted_at = NOW(); the row stays in the table and is filtered out of subsequent reads by the registered soft-delete scope.
type User struct {
orm.SoftDeleteModel[User]
Name string `orm:"column:name"`
}
// Static helper: soft-delete by conditions.
affected, err := User{}.DeleteWhere(ctx, map[string]any{"role": "guest"})
// Chain: soft-delete with richer predicates.
affected, err := User{}.Where("created_at < ?", cutoff).Delete(ctx)Force Delete
Permanent removal, even on soft-delete models:
affected, err := User{}.ForceDeleteWhere(ctx, map[string]any{"id": 42})
// Chain form
affected, err := User{}.Where("active = ?", false).ForceDelete(ctx)On models without soft delete, Delete and ForceDelete are equivalent.
Working with Soft Deleted Records
// Default scope hides trashed rows.
users, err := User{}.All(ctx)
// Include trashed rows.
users, err := User{}.WithTrashed().Get(ctx)
// Only trashed rows.
users, err := User{}.OnlyTrashed().Get(ctx)To restore a trashed row, clear deleted_at via mass update with the trashed-aware scope explicitly opted in:
affected, err := User{}.WithTrashed().
Where("id = ?", id).
Update(ctx, map[string]any{"deleted_at": nil})Append-Only Models
Tables that should never be mutated after insert (audit_logs, events, outbox, ledger entries) embed orm.ImmutableModel[T] (or orm.ImmutableUUIDModel[T]) instead of orm.Model[T]. The composition is IDInt[T] + CreatedAtOnly + AppendOnly: there is no UpdatedAt column and no UPDATE branch.
type AuditLog struct {
orm.ImmutableModel[AuditLog]
Actor string `orm:"column:actor"`
Action string `orm:"column:action"`
Meta map[string]any `orm:"column:meta;type:jsonb"`
}
log, err := AuditLog{}.Create(ctx, map[string]any{
"actor": "user:42",
"action": "user.deleted",
"meta": map[string]any{"target": 99},
})Reads work like any other model:
log, err := AuditLog{}.Find(ctx, id)
recent, err := AuditLog{}.
Where("actor = ?", actor).
OrderBy("created_at", "DESC").
Limit(50).
Get(ctx)orm.Save(ctx, mgr, &existing) on an already-persisted immutable record returns orm.ErrImmutableModelUpdate. To “edit” an immutable row, append a new one.
orm.Save(ctx, mgr, &record) is the only persistence path for ImmutableModel[T] parents; there is no instance method. Create / CreateMany on the static-like helpers go through orm.Save automatically.Transactions
Manager.Transaction runs a closure inside a database transaction. The ctx passed to the closure carries a *sql.Tx; every ORM call you make with that ctx auto-enrolls in the tx.
err := manager.Transaction(ctx, func(ctx context.Context) error {
user, err := User{}.Create(ctx, map[string]any{
"name": "John",
"email": "john@example.com",
})
if err != nil {
return err // auto rollback
}
if _, err := (Profile{}).Create(ctx, map[string]any{
"user_id": user.ID,
"bio": "Developer",
}); err != nil {
return err
}
return nil // auto commit
})Lifecycle:
- Closure returns a non-nil error: rollback, error returned to caller.
- Closure panics: rollback, panic re-raised. Rollback failures surface as
TxRecoverevents. - Closure returns nil: commit, then per-tx callbacks and buffered events flush.
Nested Transactions (Savepoints)
Re-entering Transaction from inside a closure reuses the outer tx. The inner closure observes savepoint semantics: an inner rollback discards only the inner work; the outer tx still commits or rolls back on its own boundary.
err := manager.Transaction(ctx, func(ctx context.Context) error {
user, err := User{}.Create(ctx, map[string]any{"name": "John"})
if err != nil {
return err
}
// Savepoint: inner failure is contained.
inner := manager.Transaction(ctx, func(ctx context.Context) error {
_, err := (Post{}).Create(ctx, map[string]any{
"user_id": user.ID,
"title": "Draft",
})
return err
})
if inner != nil {
log.Printf("post creation failed: %v", inner)
}
return nil
})Manual Transactions
When the closure form does not fit (long-lived txs, savepoint issuance), Manager.Begin returns the raw *sql.Tx. Thread it through ctx with orm.WithTxContext:
tx, err := manager.Begin(ctx)
if err != nil {
return err
}
txCtx := orm.WithTxContext(ctx, tx)
if err := orm.Save(txCtx, manager, &user); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()Prefer Transaction for everyday work; the manual path skips the per-tx event buffer and callback drain wiring that Transaction installs for you.
Commit Callbacks
OnCommit, OnRollback, and OnCommitFailure register work to fire after the surrounding transaction settles. They are the durable replacement for “do this thing only if the row actually persisted” branches that used to live inline.
ctx = orm.PrepareTxCallbacks(ctx)
err := manager.Transaction(ctx, func(ctx context.Context) error {
user, err := User{}.Create(ctx, map[string]any{"email": email})
if err != nil {
return err
}
// Fires only after a successful commit.
_ = orm.OnCommit(ctx, func(ctx context.Context) error {
return cache.Forget(ctx, "users:"+user.Email)
})
// Fires only if the tx rolls back.
_ = orm.OnRollback(ctx, func(ctx context.Context) error {
log.Warn("user create rolled back", "email", email)
return nil
})
return nil
})PrepareTxCallbacks attaches the callback holder to ctx; without it, the OnCommit / OnRollback calls return orm.ErrNoTxCallbacks so callers can fall back to inline execution.
Important guarantees:
- Callbacks fire AFTER the tx has settled, never inside it.
- The ctx supplied to a callback is detached from cancellation via
context.WithoutCancel. A request deadline that triggered the rollback will not poison the cache-invalidation cascade. - A panic inside a callback is isolated. It surfaces as a
TxRecoverevent withCause == "callback_panic"; subsequent callbacks still run. - Savepoint inner registrations defer to the outer tx: only the outermost commit/rollback boundary drains.
Commit Failure
If tx.Commit() itself returns an error, the transaction is in an AMBIGUOUS state: the database may have committed but the network failed before the OK reached the client. OnCommitFailure is the only callback list that fires in this case; rollback callbacks do NOT run because the rollback was never confirmed.
_ = orm.OnCommitFailure(ctx, func(ctx context.Context, commitErr error) error {
log.Error("commit ambiguous - leaving outbox/cache untouched",
"err", commitErr)
return nil
})The safe default is to log and leave outboxes / caches alone. Aggressive cleanup risks duplicate work (re-enqueueing jobs that already fired) or stale-cache reads (invalidating changes that DID land).
Model Hooks: AfterCommit / AfterRollback
Models that implement orm.AfterCommitHook or orm.AfterRollbackHook are auto-registered against the active TxCallbacks on every successful Save. This is the right home for outbox dispatch, cache invalidation, webhook fanout: anything that requires durability before the side effect can be safely observed.
type Order struct {
orm.Model[Order]
Customer string `orm:"column:customer"`
Total int64 `orm:"column:total"`
}
func (o *Order) AfterCommit(ctx context.Context) error {
return events.Dispatch(ctx, OrderPlaced{ID: o.ID})
}
func (o *Order) AfterRollback(ctx context.Context) error {
log.Warn("order rolled back", "id", o.ID)
return nil
}Outside a Transaction, the implicit auto-commit already happened by the time Save returns, so AfterCommit fires inline immediately after the in-tx hooks. AfterRollback only fires inside a Transaction that ultimately rolls back.
Change Tracking
Tracking is opt-in. Calling orm.Track snapshots the current field values; IsDirty, IsClean, and HasChanged compare against that snapshot lazily. Models that never call Track pay zero: the side-channel is allocated only on demand.
user, err := User{}.Find(ctx, 1)
if err != nil {
return err
}
orm.Track(&user) // baseline
user.Name = "Updated"
orm.IsDirty(&user) // true
orm.HasChanged(&user, "Name") // true
orm.HasChanged(&user, "Email") // false
if err := orm.Save(ctx, nil, &user); err != nil {
return err
}
orm.MarkClean(&user) // re-baseline so subsequent edits are diffed against the saved state
orm.IsClean(&user) // trueHasChanged keys on the Go field name (e.g. "Name", not "name"). Tracking on an in-memory struct that was never persisted is a no-op.
Existence
orm.IsExisting(&model) reports whether the row is persisted. Rows loaded via any read path (Find, Get, First, raw queries) are marked existing automatically; freshly-constructed structs are not until the first successful Save.
var u User
u.Name = "draft"
orm.IsExisting(&u) // false
if err := orm.Save(ctx, nil, &u); err != nil {
return err
}
orm.IsExisting(&u) // trueThis is the bit that drives Save’s INSERT-vs-UPDATE branch. Reach for it directly when you want to branch in user code without round-tripping the database.
Model Lifecycle Hooks
Models can implement any subset of the lifecycle hook interfaces. The save path detects them via type assertion and fires them at the appropriate point.
type User struct {
orm.Model[User]
Name string `orm:"column:name"`
Email string `orm:"column:email"`
}
// Before insert: validate or normalize.
func (u *User) BeforeCreate() error {
u.Email = strings.ToLower(u.Email)
return nil
}
// After insert.
func (u *User) AfterCreate() error {
log.Printf("user %d created", u.ID)
return nil
}
// Before update.
func (u *User) BeforeUpdate() error {
return nil
}
// After update.
func (u *User) AfterUpdate() error {
return nil
}
// Before delete.
func (u *User) BeforeDelete() error {
return nil
}
// After delete.
func (u *User) AfterDelete() error {
return nil
}These run inside the same connection / transaction as the write. For work that must wait until the row is durable (cache invalidation, queue dispatch), implement AfterCommit instead. See Commit Callbacks.
Best Practices
- Always pass ctx. The compile-time requirement is the point: there is no silent auto-commit code path to forget about.
- Prefer
Manager.TransactionoverBegin. The closure form wires per-tx event buffering and callback drains for you. - Use
AfterCommitfor side effects. In-txAfterCreateruns before the row is durable; webhooks and queue jobs belong onAfterCommit. - Check
OnCommitFailuresemantics. Treat commit-failure as ambiguous; logging is safe, retrying is not. - Validate in
BeforeCreate/BeforeUpdate. Mass updates skip lifecycle hooks, so cross-check critical invariants in the request layer when going through the chainUpdate. - Declare
AssignableFields()on every model that takes map writes. Deny-by-default means an undeclared model errors instead of silently persisting attacker keys; prefer the allowlist overProtectedFields(), and treatAllowAllColumnsas a last resort. - Index columns used in WHERE. The ORM does not synthesize indexes; see Migrations.
Related
- Queries: read-side counterpart, Where / First / Get / pagination patterns
- Global Query Scopes: the primitive behind soft-delete, also useful for multi-tenant filters and draft visibility
- Transactional Outbox: commit queue jobs and events atomically with the write that triggered them
- Relationships: HasMany / BelongsTo wiring used by Save and lifecycle hooks
- Migrations: schema and indexes that back the models you create and update