Skip to content

Recipes

Patterns most apps need eventually. Each recipe is a few lines of code with the surrounding context — meant for copy-paste, not exhaustive coverage. For the underlying primitives see the linked guide pages.


Update one field on one known document

When you know the ID and only want to flip a single field, route through QuerySet.UpdateOne so the update is atomic and avoids the read-modify-write round-trip:

done, err := den.NewQuery[Todo](db, where.Field("_id").Eq(todoID)).
    UpdateOne(ctx, den.SetFields{"done": true})

Returns ErrNotFound if the document was deleted between your read and this call. If the document carries document.SoftDelete and you want to flip a field on a soft-deleted doc, chain .IncludeDeleted().

QuerySet.UpdateOne


Find or create with defaults

Atomic find-or-create using QuerySet.UpsertOne. The defaults template is used only on miss; fields is applied on both branches (pass den.SetFields{} if you want existing rows untouched):

user, inserted, err := den.NewQuery[User](db, where.Field("email").Eq("x@y.z")).
    UpsertOne(ctx,
        &User{Email: "x@y.z", LoginCount: 0},   // defaults — applied on miss only
        den.SetFields{"last_seen": time.Now()}, // applied on both paths
    )
if inserted {
    log.Println("created new user")
}

Concurrent inserts that both miss race on the unique constraint — one wins, the other gets ErrDuplicate. There is no internal retry; callers decide.

QuerySet.UpsertOne


Atomic counter increment

QuerySet.UpdateOne plus a tx for the read-then-write. The simplest correct version under contention uses ForUpdate to lock the row:

err := den.RunInTransaction(ctx, db, func(tx *den.Tx) error {
    counter, err := den.NewQuery[Counter](tx,
        where.Field("name").Eq("page_views"),
    ).ForUpdate().First(ctx)
    if err != nil {
        return err
    }
    counter.Value++
    return den.Save(ctx, tx, counter)
})

For high-throughput counters that don't need exact consistency, consider sharded counters (one row per shard, sum on read).

ForUpdate · RunInTransaction


Claim one job (queue-style worker)

The canonical "worker pool" pattern: each goroutine claims a single pending job, marks it in-flight, and releases the lock. ForUpdate(SkipLocked()) lets workers race without blocking each other:

err := den.RunInTransaction(ctx, db, func(tx *den.Tx) error {
    job, err := den.NewQuery[Job](tx,
        where.Field("status").Eq("pending"),
    ).Sort("created_at", den.Asc).
      Limit(1).
      ForUpdate(den.SkipLocked()).
      First(ctx)
    if errors.Is(err, den.ErrNotFound) {
        return nil // no work right now
    }
    if err != nil {
        return err
    }
    job.Status = "in_flight"
    job.WorkerID = workerID
    return den.Save(ctx, tx, job)
})

SkipLocked() skips rows another worker already locked instead of blocking. PostgreSQL maps this to FOR UPDATE SKIP LOCKED; SQLite serializes writers via IMMEDIATE tx so the option is a no-op there (one worker at a time anyway).

ForUpdate


Top-N with grouping

Server-side Top-N — compute groups, sort by an aggregate, limit. No Go-side post-processing:

type Top struct {
    Category string  `den:"group_key"`
    Total    float64 `den:"sum:price"`
}

var top []Top
err := den.NewQuery[Sale](db).
    Limit(5).
    GroupBy("category").
    OrderByAgg(den.OpSum, "price", den.Desc).
    Into(ctx, &top)

Sorting by group key uses the parent Sort(...); sorting by an aggregate uses OrderByAgg(op, field, dir) because no source-field name identifies the synthetic aggregate column. Limit / Skip cap and offset the group rows, not the underlying documents.

Aggregations


By default Link[T].Value is nil — explicit hydration via WithFetchLinks() resolves them in one batched IN-query per nesting level:

posts, err := den.NewQuery[Post](db,
    where.Field("status").Eq("published"),
).WithFetchLinks().All(ctx)
// each post.Author.Value is now non-nil

Forgetting WithFetchLinks() and dereferencing .Value is one of the more common new-user bugs — the linter won't catch it. If you need deeper nesting, WithNestingDepth(n) overrides the default of 3 levels.

Relations


Cursor pagination

Stable pagination across writes: cursor on _id (ULIDs sort chronologically) instead of offset:

const pageSize = 50

// First page
page, err := den.NewQuery[Post](db).Sort("_id", den.Asc).Limit(pageSize).All(ctx)

// Subsequent pages
last := page[len(page)-1].ID
next, err := den.NewQuery[Post](db).After(last).Sort("_id", den.Asc).Limit(pageSize).All(ctx)

After(id) and Before(id) translate to _id > ? / _id < ?. Sorting by _id is required to make the cursor meaningful. Mixing cursor with offset (Skip) returns ErrIncompatiblePagination — the two pagination styles have no defined interaction.

Queries


Upsert by unique field

You have a stream of records to ingest, and the natural deduplication key is a unique column (email, SKU, slug). QuerySet.UpsertOne does the right thing in one round-trip per record:

for _, record := range incoming {
    _, _, err := den.NewQuery[Customer](db, where.Field("email").Eq(record.Email)).UpsertOne(ctx, &Customer{Email: record.Email, Name: record.Name, Source: "import"}, den.SetFields{"name": record.Name, "last_synced_at": time.Now()})
    if err != nil {
        return err
    }
}

If two ingest workers race on the same email, one wins and the other gets ErrDuplicate from the unique constraint — handle by retrying, logging, or surfacing depending on your semantics.

For batch ingests with no per-record uniqueness needs, prefer SaveAll(ctx, db, docs) — much faster, but fail-fast: any per-doc error rolls back the whole transaction.

QuerySet.UpsertOne · SaveAll