DEV Community

Cover image for Go SQL Query Builder Without Dependencies: Relica, the ozzo-dbx Successor — What Broke on PostgreSQL and SQLite
Andrey Kolkov
Andrey Kolkov

Posted on

Go SQL Query Builder Without Dependencies: Relica, the ozzo-dbx Successor — What Broke on PostgreSQL and SQLite

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Goroutine A: Get("SELECT...") → miss
  2. Goroutine B: Get("SELECT...") → miss (A hasn't Set yet)
  3. A: Prepare() → stmt1, Set("SELECT...", stmt1)
  4. B: Prepare() → stmt2, Set("SELECT...", stmt2) → closes stmt1
  5. 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
})
Enter fullscreen mode Exit fullscreen mode

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())
        }
    }),
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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)