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.
The thesis
Section titled “The thesis”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.
What each front-end is
Section titled “What each front-end is”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.
The same task, both ways
Section titled “The same task, both ways”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 populatedThe 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 populatedThat 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.
The trade-offs
Section titled “The trade-offs”| 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.
When to reach for each
Section titled “When to reach for each”| I want… | Use |
|---|---|
| create / get / update / delete a model | orm — NewRepo[T], Create/Get/Update/Delete |
| lifecycle hooks, auto-timestamps, soft delete | orm — they ride the repository verbs |
| associations, eager loading, migrations | orm — Load, 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 | query — ForUpdate/SkipLocked |
| SQL the builder doesn’t express | query.Raw[T] — the escape hatch |
They share one core
Section titled “They share one core”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.
Where to next
Section titled “Where to next”- The orm front-end — models, migrations, associations, the CRUD repository.
- The query builder — typed predicates and the advanced SQL surface.
- Context & session — why
*DBand a transaction are interchangeable. - Transactions — running both front-ends inside one unit of work.
- Errors — the sentinels both front-ends normalize to.