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 (mass-assignment respects Fillable/Guarded) or a pre-built *User.
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: mgr.Query[T]() style accepts the same (ctx, conditions, values) pair so you can scope the lookup with extra Where clauses before the helper resolves.
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 := 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})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. - 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