Relica is a zero-dependency SQL query builder for Go (PostgreSQL, MySQL, SQLite), the successor to ozzo-dbx. I wrote it from scratch, keeping the API design of ozzo-dbx — the query builder by Qiang Xue, creator of Yii framework. Qiang hadn't maintained it for years, and when he handed me the go-ozzo organization in mid-2026, Relica was already in production.
We run Relica in production across multiple services on PostgreSQL, MySQL and SQLite, and an external code auditor recently found six critical bugs in one session. This article is about those bugs, how they got there, and the patterns that prevent them.
The Bug That Would Have Woken Me at 3 AM
An external auditor ran our codebase on Go 1.27.1 (we support 1.25+) with the race detector and custom reproducing tests. One finding hit hard: One() was checking rows.Next() and returning ErrNotFound without ever calling rows.Err().
What this means in production: when PostgreSQL drops a connection mid-query — context cancellation, network blip, anything — rows.Next() returns false, and you get "not found" instead of "database unreachable." Your monitoring shows a spike in 404s. Your dashboards look fine. Users intermittently can't find their own profiles.
// Before v0.17.2 (broken):
if !rows.Next() {
return ErrNotFound // masks real errors!
}
// After v0.17.2:
if !rows.Next() {
if rowErr := rows.Err(); rowErr != nil {
return rowErr // context.Canceled, connection reset, etc.
}
return ErrNotFound // genuinely no rows
}
The fix was three lines. The lesson: ErrNotFound is a business outcome, not an infrastructure failure. Test for it explicitly in your handlers:
var user User
user.ID = requestedID
err := db.Select().Model(&user)
switch {
case errors.Is(err, relica.ErrNotFound):
return nil, ErrUserNotFound
case err != nil:
return nil, fmt.Errorf("fetch user: %w", err) // real DB error
}
PostgreSQL $N Placeholder Collision in Subqueries (bind message supplies N parameters)
If you've ever seen bind message supplies 3 parameters, but prepared statement requires 2 on PostgreSQL — this might be why.
Before v0.17.2, Relica built SQL in multiple passes. Each subquery numbered its own placeholders from $1. When a subquery was embedded inside an outer query, the numbers collided:
-- What Relica generated (broken):
WHERE active = $1 AND "id" IN (SELECT "user_id" FROM "orders" WHERE status = $1 AND total > $2)
-- args: [true, "done", 100]
-- $1 used for both 'true' and 'done'!
-- What it should be:
WHERE active = $1 AND "id" IN (SELECT "user_id" FROM "orders" WHERE status = $2 AND total > $3)
The fix required rearchitecting the builder. We split buildSQL into two methods: renderSQL assembles the entire query with ? placeholders, then buildSQL does a single replacePlaceholders pass to number them sequentially. One lexer, one pass, no collisions.
The same fix caught two more bugs we hadn't noticed: GroupByExpr and OrderByExpr with parameters weren't being renumbered on PostgreSQL at all — the ? stayed in the output SQL verbatim.
Squirrel hit the same bug class in 2019 (#203) — closed with a workaround. The maintainer called it a design issue that can't be fixed without breaking compatibility. Our renderSQL approach solves it cleanly.
Statement Cache: The Race Nobody Tested
Our statement cache used Get() and Set() — standard LRU. Two goroutines prepare the same query simultaneously:
- Goroutine A:
Get("SELECT...")→ miss - Goroutine B:
Get("SELECT...")→ miss (A hasn't Set yet) - A:
Prepare()→ stmt1,Set("SELECT...", stmt1) - B:
Prepare()→ stmt2,Set("SELECT...", stmt2)→ closes stmt1 - A:
stmt1.Query()→sql: statement is closed
The auditor reproduced this under concurrent load — hundreds of sql: statement is closed errors on cold start. The fix: GetOrSet() — an atomic cache-or-insert. The loser of the race closes its own statement (which nobody else has seen) and uses the cached one.
cached, inserted := cache.GetOrSet(sql, stmt)
if !inserted {
_ = stmt.Close() // lost the race — ours is unobserved
return cached, nil
}
return stmt, nil
Patterns That Actually Work
Model() for CRUD, Builder for Everything Else
The first version of Relica only had the fluent builder — that's what ozzo-dbx had. After months of production use, I added Model() for the most common case:
// Quick PK lookup — one line
var user User
db.Model(&user).Find(42)
// Need column selection or filters — full builder chain
user = User{ID: 42}
db.Select("name", "email").
Where(relica.IsNull("deleted_at")).
Model(&user)
Model() reads the primary key from struct fields, figures out the table name, and scans the result. The builder is for everything else.
Expression API Over String Conditions
// Portable across PostgreSQL, MySQL, SQLite:
query = query.Where(relica.Eq("status", filter.Status))
query = query.Where(relica.GreaterOrEqual("age", filter.MinAge))
query = query.Where(relica.IsNull("deleted_at"))
// Instead of:
query = query.Where("status = ?", filter.Status) // works, but less explicit
The Expression API handles identifier quoting and placeholder numbering internally. It also catches more errors at build time — Eq("status", nil) generates IS NULL, not a broken = NULL.
ForUpdate Inside Transactions
err := db.Transactional(ctx, func(tx *relica.Tx) error {
var account Account
account.ID = accountID
err := tx.Select().ForUpdate().Model(&account)
if err != nil {
return err
}
if account.Balance < amount {
return ErrInsufficientFunds
}
account.Balance -= amount
return tx.Model(&account).Update("balance")
})
ForUpdate() outside a transaction is pointless — the lock releases immediately. On SQLite, it's silently ignored because SQLite uses database-level locking. Your code stays portable.
QueryHook for Everything Observable
db, _ := relica.Open("postgres", dsn,
relica.WithQueryHook(func(ctx context.Context, e relica.QueryEvent) {
queryDuration.WithLabelValues(e.Operation).Observe(e.Duration.Seconds())
if e.Duration > 100*time.Millisecond {
slog.WarnContext(ctx, "slow query",
"sql", e.SQL, "duration_ms", e.Duration.Milliseconds())
}
}),
)
One hook replaced our logger, metrics middleware, and slow-query detector. Zero overhead when not configured — it's a nil check.
Subqueries Need .AsExpression()
The public *SelectQuery doesn't implement Expression directly. This compiles but treats the query object as a bind parameter value:
// Wrong — compiles, generates garbage SQL:
db.Select().From("users").Where(relica.In("id", sub)).All(&users)
// Correct:
db.Select().From("users").Where(relica.In("id", sub.AsExpression())).All(&users)
Same for Exists(). The .AsExpression() call is the explicit conversion from query object to SQL fragment.
What We Didn't Build
- Migrations. Use goose or atlas.
- Relations / eager loading. Write JOINs explicitly — that's the point of a query builder.
- Compile-time type safety. If you want that, sqlc or jet are better tools.
- Schema management. We build queries, not manage databases.
Relica vs Squirrel vs sqlx vs GORM vs sqlc (2026)
If you're choosing between Go SQL libraries in 2026:
| Tool | Style | When to pick it |
|---|---|---|
| go-jet | Code generation | Large schemas, compile-time type safety, IDE autocompletion |
| sqlc | Code generation | Static queries only, strongest type guarantees |
| goqu | Fluent builder | Mature, battle-tested, complex dynamic filters |
| sq | Generics/callback | Zero-reflection scanning, generics-first API |
| GORM | Full ORM | Relations, migrations, hooks — if you want the full stack |
| Relica | Fluent + Model API | Zero deps, transparent SQL, built-in LRU cache, Stripe-like IDs |
| Squirrel | Fluent builder | Maintenance mode since 2024. Known subquery placeholder limitation (#203) |
| sqlx | Scanning layer | Last release v1.4.0 (2024). A community fork exists |
If you need compile-time typed SQL — go-jet or sqlc are better tools. If you want a fluent builder without external deps that shows you exactly what SQL it generates — that's Relica.
Numbers
| Library code | 14,101 lines |
| Test code | 37,902 lines (2.7:1 ratio) |
| Test functions | 1,136 |
| Coverage | 85%+ |
| Production dependencies | 0 |
| CI | Unit: Linux, macOS, Windows. Integration: PostgreSQL, MySQL, SQLite |
| Releases | 37 |
| External audit bugs found | 6 (all fixed in v0.17.2) |
The Gotchas
Always implement TableName(). Default inference adds s and lowercases — Category → categorys, not categories. I'm not a fan of plural table names, and even if you are, naive pluralization won't work:
func (Category) TableName() string { return "category" }
Select("n.*") was broken until v0.17.1. The wildcard was quoted as "n"."*" — valid identifier syntax, invalid wildcard. If you're on an older version, use explicit column names.
Zero-dependency means zero-dependency. go.mod has no require block. Test and benchmark modules are separate. I've had people not believe this until they check.
Relica is at v0.17.3. The AGENTS.md has every API pattern — it's written for both humans and AI coding assistants.
Found a bug? Open an issue. Have a pattern that works well? Tell us in discussions.
Top comments (0)