Querying
Here's the one idea that makes everything in this phase click: a GORM chain doesn't run anything until you tell it to. When you write db.Where(...).Order(...).Limit(...), you're not hitting the database — you're assembling a query, clause by clause, in memory. Each method bolts one more piece onto a query that's still just sitting there. Nothing touches the database until you call a finalizer — Find, First, Count, and friends — which is the moment GORM turns your assembled chain into actual SQL and sends it over the wire.
💡 This is exactly how a SQL query builder works in any language.
Where≈ theWHEREclause,Order≈ORDER BY,Limit≈LIMIT. You're describing a SQL statement in Go syntax, and the finalizer compiles and runs it. Hold that mapping and GORM stops feeling like magic.
Throughout this phase, picture the SQL each chain produces. We'll show it side by side so the translation becomes second nature.
The chain builds; the finalizer runs
// This line does NOT hit the database. It returns a *gorm.DB
// carrying a half-built query.
db
// THIS line runs it — Find is the finalizer.
query
SELECT * FROM posts WHERE published = true ORDER BY created_at desc;
What just happened: The first statement assembled a WHERE and an ORDER BY and handed back a *gorm.DB with that state stored inside. No SQL ran. Only Find(&posts) triggered the round-trip: GORM compiled the accumulated clauses into one SELECT, ran it, and scanned each row into the posts slice. Common finalizers: Find (many rows), First/Take/Last (one row), Count (a number), plus Create/Save/Delete from the other phases.
Where, in all its forms
Where is where most of your filtering lives. The workhorse form is a string with ? placeholders plus arguments — and you should always use placeholders, never string concatenation, because GORM parameterizes them and the database escapes them for you (no SQL injection).
// Single condition
db
// Multiple conditions in one string
db
// IN — pass a slice, GORM expands it
db
// LIKE — the % wildcards go in the argument, not the SQL
db
SELECT * FROM posts WHERE view_count > 1000;
SELECT * FROM posts WHERE view_count > 1000 AND published = true;
SELECT * FROM posts WHERE id IN (1,2,3);
SELECT * FROM posts WHERE title LIKE '%go%';
What just happened: Each Where became a WHERE clause. For IN, you hand GORM a Go slice and it expands it into (1,2,3) — note there are no parentheses in your Go string; GORM adds them. For LIKE, the % wildcards belong in the argument value ("%go%"), so they pass through the placeholder safely. The ? placeholders mean the values are sent separately from the SQL text, which is what keeps you safe from injection.
Chaining, Or, and Not
Chain multiple Where calls and GORM joins them with AND. For OR, use Or; to negate, use Not.
// Chained Where = AND
db
// Or
db
// Not
db
SELECT * FROM posts WHERE published = true AND view_count > 500;
SELECT * FROM posts WHERE view_count > 1000 OR featured = true;
SELECT * FROM posts WHERE NOT (published = false);
What just happened: Two Wheres in a row produced AND — that's the default glue. Or switched the connector to OR for that fragment, and Not wrapped its condition in a negation. When you start mixing AND and OR, watch the generated SQL closely: operator precedence in the database may not group things the way you assumed, and a stray OR can quietly widen your result set.
Struct vs map conditions — the zero-value trap
GORM lets you pass a struct as a condition: it reads the non-empty fields and builds equality checks. It's clean and type-safe — until it silently drops a field on you.
// Struct condition — matches on the fields you set
db
SELECT * FROM users WHERE name = 'Alice' AND active = true;
What just happened: GORM walked the struct, found Name and Active set, and turned each into an = condition. Looks great. Now here's the trap. ⚠️
// You WANT: everyone whose age is 0. You get: everyone.
db
SELECT * FROM users; -- the Age condition vanished!
What just happened: Struct conditions ignore zero-value fields. Age: 0 is the zero value for an int, so GORM can't tell "I deliberately want age 0" apart from "I didn't set age," and it leaves the condition out entirely. Same for Name: "", Active: false, nil pointers — all silently dropped. The query you thought filtered returns the whole table.
The fix when you genuinely need to match a zero value: use a map, where a present key always becomes a condition.
// Map condition — the key is there, so the condition is there
db
SELECT * FROM users WHERE age = 0;
What just happened: A map carries no notion of "zero means unset" — if the key "age" is in the map, GORM emits age = 0, full stop. Rule of thumb: structs for the common case (non-zero values, type safety); maps the moment a zero, empty string, or false is a real value you need to filter on.
Order, Limit, Offset, Select, Count
These map straight onto their SQL clauses. Order sorts, Limit caps the row count, and Offset skips rows — together they give you pagination.
// Page 3 of 10-per-page: skip 20, take 10, newest first
db
SELECT * FROM posts ORDER BY created_at desc LIMIT 10 OFFSET 20;
What just happened: Order set the sort, Limit capped results at 10, and Offset skipped the first 20 — so this is page 3 (offsets 0, 10, 20...). The general pagination formula is Offset((page - 1) * pageSize).Limit(pageSize). Always pair pagination with an Order; without a stable sort, "page 2" isn't guaranteed to exclude what "page 1" already showed.
Use Select to fetch only the columns you need, and Count to get a number instead of rows.
// Only pull two columns
db
// Count rows matching a condition — note Model + Count
db
SELECT name, email FROM users;
SELECT count(*) FROM users WHERE active = true;
What just happened: Select narrowed the SELECT list to two columns — handy when a table is wide and you only need a couple of fields. Count is a finalizer that returns a count rather than scanning rows; because there's no slice to infer the table from, you tell GORM the table with Model(&User{}), and the result lands in an int64 you pass by pointer.
Scopes — reusable query fragments
Once you've written Where("published = ?", true) for the fifth time, pull it into a scope: a function that takes a *gorm.DB, adds some clauses, and returns it. You then drop it into any chain with Scopes(...).
// A scope is just: func(*gorm.DB) *gorm.DB
.DB
.DB
// Compose them into a chain — order them however reads best
db
SELECT * FROM posts
WHERE published = true AND view_count > 1000
ORDER BY created_at desc;
What just happened: Published and Popular each take the in-progress *gorm.DB, tack on a Where, and hand it back. Scopes(Published, Popular) ran both against the chain before the finalizer, so their conditions joined with AND — exactly as if you'd written the two Wheres inline. Now "published and popular" is a named, testable, reusable thing you can compose anywhere instead of copy-pasting filter strings.
💡 Scopes are also where pagination logic usually lives — a
Paginate(page, size)scope keeps every list endpoint consistent. Keep the Phase 1 SQL logger on while you build them so you can see what each scope adds to the statement. The moment a chain produces SQL you didn't expect — a missing condition, a surprise full scan — you've caught a bug before it ships. That habit of reading the generated SQL is the same skill that saves you in Why Is My Query Slow?.
Recap
- A chain builds a query lazily; nothing runs until a finalizer (
Find,First,Count, ...) compiles and executes it. Wheretakes a string with?placeholders plus args — always parameterize. ChainedWhere= AND; useOrandNotfor the rest.INtakes a slice;LIKEputs the%in the argument.- Struct conditions silently ignore zero-value fields (
0,"",false,nil). When a zero value is a real filter, use amap[string]anyinstead. Order+Limit+Offsetgive pagination (Offset((page-1)*size).Limit(size)); always pair with anOrder.Selectnarrows columns;CountneedsModel(&T{})and anint64.- Scopes are
func(*gorm.DB) *gorm.DBfragments composed viaScopes(...)— reusable, testable filters. Keep the SQL logger on to confirm each chain generates what you expect.
Check your grip on the lazy chain and the zero-value trap:
[
{
"q": "When does `db.Where(\"age > ?\", 18).Order(\"name\")` actually run SQL against the database?",
"choices": ["As soon as Where is called", "As soon as Order is called", "Only when a finalizer like Find or Count is called", "When the *gorm.DB variable goes out of scope"],
"answer": 2,
"explain": "The chain only builds the query in memory. SQL runs when a finalizer (Find, First, Count, etc.) compiles and executes it."
},
{
"q": "What does `db.Where(&User{Age: 0}).Find(&users)` return?",
"choices": ["Only users with age 0", "All users — the zero-value Age condition is dropped", "A compile error", "No users at all"],
"answer": 1,
"explain": "Struct conditions ignore zero-value fields, so `Age: 0` is omitted and the query has no WHERE. Use map[string]any{\"age\": 0} to match a zero value."
},
{
"q": "What is a GORM scope?",
"choices": ["A struct tag that limits column access", "A function `func(*gorm.DB) *gorm.DB` that adds clauses and is composed via Scopes(...)", "A transaction boundary", "A way to scope a connection to one goroutine"],
"answer": 1,
"explain": "A scope takes the in-progress *gorm.DB, adds query clauses, and returns it. You compose scopes into a chain with Scopes(...)."
}
]
← Phase 3: Create & Read · Guide overview · Phase 5: Update & Delete →
Check your understanding
1. When does `db.Where("age > ?", 18).Order("name")` actually run SQL against the database?
2. What does `db.Where(&User{Age: 0}).Find(&users)` return?
3. What is a GORM scope?