Advanced queries

Table of contents
  1. Advanced queries
    1. Introduction
    2. Where clauses
      1. Subqueries
      2. Relation conditions
    3. Full-text search
    4. Grouping
    5. Unions
    6. Locks
    7. Single records
    8. Pagination
    9. Streaming
    10. Relation aggregates
    11. Debugging

Introduction

Model[T].Query() returns a *Query[T]. Besides the basics in Models, it covers most of what Eloquent’s builder and Lucid’s ModelQueryBuilder offer. A query that cannot be built (a bad operator, a feature your database lacks) records the error and returns it from the terminal call (Get, First, Count, …) as a *migro.Error.

Where clauses

users := orm.New[User](db)

users.Query().WhereColumn("updated_at", ">", "created_at")      // compare two columns
users.Query().WhereLike("email", "%@example.com")               // WhereNotLike negates
users.Query().WhereDate("created_at", "=", "2026-01-31")
users.Query().WhereYear("created_at", ">=", 2026)               // WhereMonth, WhereDay
users.Query().WhereJSON("settings", "address.city", "=", "Rio") // a path inside a JSON column
users.Query().WhereRaw("lower(name) = ?", "ana")                // always pass values as arguments

Operators are checked against = <> != < <= > >=. JSON path segments must be names or array indexes (items.0.sku); anything else fails with QUERY_FAILED, because the path is part of the SQL text.

Subqueries

// users who have at least one post
users.Query().WhereExists(posts.Query().WhereColumn("posts.user_id", "=", "users.id"))
users.Query().WhereNotExists(...)

Relation conditions

users.Query().WhereRelation("Posts", "published = ?", true)  // WhereHas with a plain condition
posts.Query().WhereBelongsTo(user)                           // posts.user_id = user.id
posts.Query().WhereBelongsTo(user, "Editor")                 // name the relation when there are several
comments.Query().WhereHasMorph("Commentable", []string{"post", "video"}, nil)

WhereBelongsTo finds the belongs-to relation that points at the model you pass. It fails if there is none, or more than one and you did not name it.

// migration
t.FullText("title", "body")

// query
articles.Query().WhereFullText("go orm", "title", "body")
articles.Query().WhereFullTextBoolean("+go -orm", "title", "body")  // operator syntax
Database Search Index created by t.FullText
MySQL, MariaDB MATCH ... AGAINST FULLTEXT index
PostgreSQL, CockroachDB to_tsvector('simple', ...) @@ plainto_tsquery (websearch_to_tsquery in boolean mode) GIN index on the same expression
SQLite, SQL Server, Oracle, Redshift not supported not supported

On the unsupported databases both the query and the index fail with UNSUPPORTED_FEATURE. SQLite needs an FTS virtual table, and SQL Server a full-text catalog; neither can be described by a column list. PostgreSQL searches with the simple configuration (no stemming), and the index only helps a query that lists the same columns in the same order.

Grouping

posts.Query().Select("user_id").GroupBy("user_id").Having("COUNT(*) > ?", 3).Get(ctx)
posts.Query().Distinct("user_id").Get(ctx)

Unions

admins := users.Query().Where("role = ?", "admin")
staff := users.Query().Where("role = ?", "staff")
rows, err := admins.Union(staff).OrderBy("name", "asc").Limit(20).Get(ctx)   // UnionAll keeps duplicates

Each side keeps its own conditions and global scopes. The select lists must line up. The result is a query over the combined rows, so OrderBy, Limit, Count and Paginate apply to the union.

Locks

err := db.Transaction(ctx, func(tx *migro.DB) error {
    account, err := orm.New[Account](tx).Query().Where("id = ?", 1).LockForUpdate().First(ctx)
    ...
})

LockForUpdate adds FOR UPDATE and SharedLock a shared lock (FOR SHARE, or LOCK IN SHARE MODE on MySQL and MariaDB). Use them inside a transaction. SQLite ignores both, since it locks the whole database. SQL Server (which locks through table hints) and Oracle’s shared lock fail with UNSUPPORTED_FEATURE.

Single records

users.Query().Where("email = ?", e).Sole(ctx)   // NOT_FOUND for none, MULTIPLE_RECORDS_FOUND for several
users.Query().FirstWhere(ctx, "email", e)

Pagination

Method Cost Use it for
Paginate(ctx, page, perPage) a COUNT plus the page numbered pages with a total
SimplePaginate(ctx, page, perPage) the page only (reads one extra row) “previous / next” links
CursorPaginate(ctx, perPage, cursor) the page only, constant time at any depth infinite scroll, APIs, large tables
page, err := posts.Query().OrderBy("created_at", "desc").CursorPaginate(ctx, 20, "")
// page.Data, page.NextCursor, page.PrevCursor
next, err := posts.Query().OrderBy("created_at", "desc").CursorPaginate(ctx, 20, page.NextCursor)

A cursor page filters on the position of the last row instead of skipping rows, so inserts between requests do not shift it. The query needs at least one OrderBy, and the primary key is added as a tie-breaker. Order columns must not be NULL. The cursor is an opaque string; one made for a different ordering is refused with QUERY_FAILED.

Streaming

for u, err := range users.Query().Cursor(ctx) { ... }          // one statement, one row at a time; no eager loading
for u, err := range users.Query().With("Posts").Lazy(ctx, 500) { ... }  // chunk by chunk, relations loaded
err := users.Query().Each(ctx, 500, func(u *User) error { ... })
err := users.Query().ChunkByID(ctx, 500, "id", func(rows []User) error { ... })

Chunk and ChunkByID page by key rather than offset, so they stay correct when the callback updates or deletes rows. Breaking out of a range loop releases the statement.

Relation aggregates

users.Query().
    WithCount("Posts").
    WithSum("Orders", "total").WithAvg("Orders", "total").
    WithMin("Orders", "total").WithMax("Orders", "total").
    WithExists("Posts").
    Get(ctx)

u.Extra("orders_sum_total")   // float64, 0 when there are no orders
u.Extra("orders_avg_total")   // float64, nil when there are no orders
u.Extra("posts_exists")       // bool

orm.LoadSum(ctx, db, user, "Orders", "total")   // the same for a model you already hold
orm.LoadCount(ctx, db, user, "Posts", "Comments")

Values land in Extras, one extra query per aggregate. They work on has-one/many, morph one/many and the many-to-many kinds (the column is read from the related table through the pivot). Column names are validated.

Debugging

sql, err := users.Query().Where("active = ?", true).Limit(5).ToSQL(ctx)

ToSQL renders the statement with its bindings inlined and does not run it. See Strict mode and debugging for the query log.