Querying
Every method here is chainable and copy-on-write: it returns a new repository and leaves the one it was called on untouched. Nothing runs until a finisher (FindOne, FindAll, Count, …) is called.
users := repository.New[User](ctx)
list, err := users.
Where("status = ?", "active").
Order("created_at desc").
Limit(20).
FindAll()Conditions
users.Where("email = ?", email)
users.Where("age >= ? AND age < ?", 18, 65)
users.Where("status IN ?", []string{"active", "trial"})
users.Where("name LIKE ?", "%"+q+"%")
users.Where(User{Status: "active"}) // struct: non-zero fields only
users.Where(map[string]any{"status": "active", "verified": false})
users.Where("status = ?", "active").Or("role = ?", "admin")
users.Not("status = ?", "banned")Always use ? placeholders. Where(fmt.Sprintf("email = '%s'", email)) is an injection, and the fact that it works in a test is exactly why it survives to production.
struct vs map. A struct condition ignores zero values, so
Where(User{Verified: false})matches everything — thefalseis indistinguishable from "not set". A map means what it says. Use a map whenever a zero value is a real value.
Finishers
user, err := users.Where("id = ?", id).FindOne() // *User, ordered by PK, NOT_FOUND if none
user, err = users.Where("id = ?", id).Take() // *User, no implicit order
user, err = users.Order("id").Last() // *User, last by PK
list, err := users.Where("status = ?", "active").FindAll() // []User, empty is not an error
n, err := users.Where("status = ?", "active").Count()
ok, err := users.Where("email = ?", email).Exists()Conditions can also be passed straight to a finisher, which is shorter for a one-condition read:
user, err := users.FindOne("email = ?", email)
list, err := users.FindAll("status = ?", "active")FindOne applies an implicit ORDER BY on the primary key so the result is stable; Take does not, which makes it marginally cheaper when the condition already selects one row (a unique column).
Ordering, limiting, projecting
users.Order("created_at desc")
users.Order("status asc, created_at desc")
users.Limit(20).Offset(40)
users.Select("id", "email") // only these columns
users.Select("id, email") // same thing
users.Omit("password_hash") // everything except
users.Distinct("status")
users.Group("status").Having("count(*) > ?", 10)A Select that omits a column leaves that field at its zero value in the returned struct — which is fine for a list endpoint and a trap if the value is then written back with Save. Use Updates for partial writes (writing).
Relations
type User struct {
// ...
Profile Profile `gorm:"foreignKey:UserID"`
Orders []Order `gorm:"foreignKey:UserID"`
}Preload loads relations in separate queries:
users.Preload("Profile").FindAll()
users.Preload("Orders", "status = ?", "paid").FindAll() // conditional
users.Preload("Orders.Items").FindAll() // nestedJoins joins in the same query — use it when you need to filter on the related table:
users.Joins("JOIN orders ON orders.user_id = users.id").
Where("orders.total > ?", 1000).
Distinct("users.*").
FindAll()
users.InnerJoins("Profile").Where("Profile.city = ?", "BKK").FindAll()The rule of thumb: Preload to fetch related rows, Joins to filter by them. Preloading a has-many and then filtering the parents in Go pulls the whole table across the wire to throw most of it away.
Scopes
A scope is a named, reusable piece of a query. It is the cure for the same three conditions being spelled slightly differently in nine places:
func Active(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "active").Where("deleted_at IS NULL")
}
func OfTenant(id string) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ?", id) }
}
list, err := users.Scopes(Active, OfTenant(tenantID)).Order("id desc").FindAll()Tenant scoping in particular belongs in a scope rather than in each call site: one forgotten Where is a cross-tenant data leak, and it will not look like a bug in review.
Projections
Scan reads into any struct — it does not have to be the model:
type statusCount struct {
Status string `json:"status"`
Total int64 `json:"total"`
}
var stats []statusCount
err := users.
Select("status, count(*) as total").
Group("status").
Scan(&stats)Pluck reads one column into a slice:
var emails []string
err := users.Where("status = ?", "active").Pluck("email", &emails)Large result sets
FindAll materialises everything. Past a few thousand rows that is a memory spike waiting for a bad day; read in batches instead:
var batch []User
err := users.Where("status = ?", "active").
FindInBatches(&batch, 500, func(tx *gorm.DB, n int) error {
for _, u := range batch {
if err := export(u); err != nil {
return err // stops the iteration
}
}
return nil
})Rows and Row return the database/sql handles for streaming a query the repository has no shape for. Close what you open.
Raw SQL
var n int64
err := users.Raw(&n, "SELECT count(*) FROM users WHERE age > ?", 18)
type row struct {
Month string
Total int64
}
var rows []row
err = users.Raw(&rows, `
SELECT to_char(created_at, 'YYYY-MM') AS month, count(*) AS total
FROM users
WHERE created_at >= ?
GROUP BY 1 ORDER BY 1`, since)
err = users.Exec("UPDATE users SET status = ? WHERE last_seen < ?", "dormant", cutoff)Raw scans into dest; Exec runs a statement that returns nothing. Both take placeholders, and both still run on the request's context.
Soft deletes and Unscoped
A model with a gorm.DeletedAt field is soft-deleted, and every query silently excludes deleted rows. Unscoped turns that off:
all, err := users.Unscoped().FindAll() // including deleted
gone, err := users.Unscoped().Where("deleted_at IS NOT NULL").FindAll()Going below the repository
err := users.
Where("status = ?", "active").
DB(). // *gorm.DB, ctx-bound and scoped
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&list).ErrorAnything GORM can do is one DB() away — window functions, ON CONFLICT, FOR UPDATE, dialect-specific clauses. The repository does not try to own them.
Reading part of a row
Three ways to avoid materialising whole models, in increasing order of how little they transfer:
// the model, minus some columns
users.Select("id", "email").FindAll() // []User, other fields zeroed
// a projection struct — does not have to be the model at all
var rows []struct {
Email string
City string
}
users.Select("users.email, profiles.city").
Joins("JOIN profiles ON profiles.user_id = users.id").
Scan(&rows)
// one column
var emails []string
users.Pluck("email", &emails)Scan does not apply the model's soft-delete scope when you also change the table with Table(), so a projection over Table("users") sees deleted rows. Add the condition yourself when that matters.
Common query shapes
// IN, from a slice
users.Where("status IN ?", []string{"active", "trial"})
// NOT IN, safely handling an empty slice
if len(excluded) > 0 {
users = users.Where("id NOT IN ?", excluded)
}
// a nullable column
users.Where("deleted_at IS NULL")
users.Where("verified_at IS NOT NULL")
// a date range — half-open, so a row is never in two ranges
users.Where("created_at >= ? AND created_at < ?", from, to)
// case-insensitive match (postgres)
users.Where("lower(email) = lower(?)", email)
// JSON column (postgres)
users.Where("meta->>'plan' = ?", "pro")
// an OR group that does not leak out of its parentheses
users.Where("tenant_id = ?", id).
Where(ctx.DB().Where("role = ?", "admin").Or("role = ?", "owner"))That last one is worth reading twice. A bare .Or() on the outer chain applies to the whole condition, so Where(tenant).Or(role) matches every admin in every tenant. Nesting the alternatives in their own Where is what produces tenant_id = ? AND (role = ? OR role = ?).
An empty slice is the other trap: Where("id IN ?", []string{}) becomes IN (NULL) and matches nothing, which is right for IN and wrong for NOT IN. Guard it.
Counting
n, err := users.Where("status = ?", "active").Count()Count ignores Limit, Offset and Order, so it can be called on the same chain a listing uses. It does not ignore Select — a Select with an aggregate turns the count into something else. Branch before projecting:
base := users.Where("status = ?", "active")
total, _ := base.Count()
rows, _ := base.Select("id", "email").Limit(20).FindAll()Copy-on-write is what makes those two independent.
Next
- Writing — create, update, delete, upsert
- Relations — preload, joins, N+1
- Transactions
- Pagination
- Recipes — complete endpoint and service shapes