Skip to content

Query or ORM?

LiteORM gives you two front-ends over one core: an explicit, generics-first query builder and a declarative, tag-driven orm. They are not rival libraries you commit to — they share the same session, the same transaction, and the same normalized errors, so a value you fetch through one feeds the other on the same connection. This page is the decision guide: what each front-end is, the same task written both ways, the trade-offs, and the rule of thumb that ends the debate.

Use orm for CRUD; drop to query for the hot path — on the same Session, in the same transaction, with the same errors. You don’t pick a camp for your whole app. You reach for the declarative repository when you’re doing ordinary create-read-update-delete against a model, and you reach for the explicit builder when a query gets interesting — a join, a window function, a set operation, a tuned aggregate. Because both run on one core, mixing them is not a migration; it’s a method call.

The query builder is explicit and low-magic. You write query.Select[T](sess), chain typed, column-validated predicates and clauses, and finish with a terminal that runs the SQL and scans rows straight into your struct T. There is no lifecycle, no convention layer — just the SQL you asked for, made type-safe. It carries the advanced surface: joins, subqueries, CTEs, window functions, set operations, row locking, and a Raw[T] escape hatch.

The orm front-end is declarative. You describe your data as plain structs with orm:"..." (or gorm:"...") tags, and a typed repository handles CRUD, associations, lifecycle hooks, soft delete, and schema migration. It’s convention-driven but never magical — no lazy loading, no silent pluralization, and an ambiguous mapping is a hard error rather than a guess. Under the hood it composes the query builder rather than forking it.

Take one model and one read: the ten most-viewed live posts by a given author.

With the orm repository, the read surface composes onto the model and the soft-delete scope applies for free:

posts := orm.NewRepo[Post](db)
top, err := posts.
Where("author_id = ?", ada.ID).
OrderBy("views DESC").
Limit(10).
Find(ctx)

With the query builder, you spell the same read explicitly with typed predicates:

top, err := query.Select[Post](db).
Filter(query.Col[int64]("author_id").Eq(ada.ID)).
Order(query.Desc(query.Col[int64]("views"))).
Limit(10).
All(ctx)

Both return []Post, both run the same class of SELECT, and both accept the same db. The orm version is terser and carries model behavior (the soft-delete scope); the query version is fully explicit and generalizes to joins, subqueries, and projections the moment the read stops being all-columns-of-one-table.

Now a write. Creating a post through orm stamps auto-timestamps, fires Before/AfterCreate hooks, and reads the generated key back:

p := Post{AuthorID: ada.ID, Title: "Generics in Go"}
err := posts.Create(ctx, &p) // p.ID, p.CreatedAt now populated

The query Repo does the plain insert — no hooks, no timestamp stamping, just the row and its generated key:

repo := query.NewRepo[Post](db)
p := Post{AuthorID: ada.ID, Title: "Generics in Go"}
err := repo.Insert(ctx, &p) // p.ID populated

That gap is the whole point: orm.Create is CRUD with model semantics, query.Repo.Insert is CRUD without the ceremony. Pick the one whose behavior you actually want for that write.

Dimension query builder orm front-end
Style explicit — you assemble the SQL declarative — you describe the model
Control full: joins, CTEs, windows, set ops, locking the common CRUD + read surface; drop to query for more
Verbosity more typing per read terser for CRUD against a model
Convention none — nothing is inferred table/column/PK/FK inference (all overridable)
Lifecycle none hooks, auto-timestamps, soft-delete scope
Associations & migrations not its job AutoMigrate, N+1-safe eager Load
Type safety typed Col[V] predicates, typed result T typed repo + the same typed predicates in Filter

Neither column is “advanced” and the other “beginner.” The query builder is not a lower-level fallback — it’s the front-end that does joins, window functions, and set operations that most ORMs can’t express typed at all. The orm front-end is not a leaky abstraction — it’s the shortest path for the writes and reads that make up most of an app.

I want… Use
create / get / update / delete a model ormNewRepo[T], Create/Get/Update/Delete
lifecycle hooks, auto-timestamps, soft delete orm — they ride the repository verbs
associations, eager loading, migrations ormLoad, AutoMigrate
a filtered list, First/Count/Exists either — orm’s read surface, or query.Select[T]
a join, subquery, or EXISTS query — the typed join and subquery helpers
window functions, CTEs, Union/Intersect query — the advanced SQL surface
a grouped aggregate or Pluck a column query — typed Into and aggregate terminals
row locking for a work queue queryForUpdate/SkipLocked
SQL the builder doesn’t express query.Raw[T] — the escape hatch

The reason you can mix them freely is that both take a liteorm.Session, and both a *liteorm.DB and a transaction satisfy it. So the same repository and the same builder run unchanged on a handle or inside a Begin:

tx, _ := db.Begin(ctx)
// orm for the write: hooks, timestamps, generated key.
err := orm.NewRepo[Post](tx).Create(ctx, &p)
// query for the read: a typed predicate on the same tx.
back, err := query.Select[Post](tx).
Filter(query.Col[int64]("id").Eq(p.ID)).
First(ctx)
_ = tx.Commit(ctx)

Because the orm repository composes the query builder, this isn’t two systems bolted together — it’s one core with two faces. A row created with orm is visible to the query read in the same transaction, and a constraint hit either way normalizes to the same sentinel (liteorm.ErrUniqueViolation, liteorm.ErrNoRows, …). When an orm.Repo read needs joins, unions, projections, or grouping, you build a query.Select[T] on the same Session directly — that is the drop-to-query path, and it costs nothing to take.