DEV Community

Cover image for Stop Concatenating SQL Strings in Go — Dynamic Queries Done Right
Andrey Kolkov
Andrey Kolkov

Posted on

Stop Concatenating SQL Strings in Go — Dynamic Queries Done Right

This is a follow-up to my earlier article about Relica — a zero-dependency SQL query builder for Go. Since then, we've shipped a major v0.12.0 release with comprehensive security hardening, zero-panic guarantee, and integration tests on all three databases. But today I want to talk about the real problem — the one that made me build Relica in the first place.


If you've built an API with filterable endpoints in Go, you've written code like this:

query := "SELECT * FROM products WHERE 1=1"
args := []interface{}{}

if req.Category != "" {
    query += " AND category = $" + strconv.Itoa(len(args)+1)
    args = append(args, req.Category)
}
if req.MinPrice > 0 {
    query += " AND price >= $" + strconv.Itoa(len(args)+1)
    args = append(args, req.MinPrice)
}
if req.InStock {
    query += " AND stock > 0"
}
Enter fullscreen mode Exit fullscreen mode

The infamous WHERE 1=1 hack. Manual placeholder numbering that breaks when you reorder conditions. String concatenation that smells like SQL injection. And when you switch from PostgreSQL ($1, $2) to MySQL (?, ?) — you rewrite every single query.

I've been writing Go for production systems since 2019, and this pattern shows up in every codebase I've worked on. It's the single most common SQL pain point in the Go ecosystem, and in 2026, the mainstream libraries still don't solve it cleanly.

Let me show you why — and what I did about it.

Why Every Go SQL Library Gets Dynamic Queries Wrong

The sqlc problem: static by design

sqlc is brilliant for what it does — generating type-safe Go code from SQL. But dynamic queries? That's been the #1 feature request since 2020, with 150+ reactions and dozens of comments — still unresolved.

The official workaround looks like this:

-- Optional filter in sqlc
SELECT * FROM products
WHERE (NOT @has_category::boolean OR category = @category)
  AND (NOT @has_min_price::boolean OR price >= @min_price);
Enter fullscreen mode Exit fullscreen mode

You're writing more SQL, not less. And this pattern defeats index optimization — PostgreSQL can't use a category index when the condition is wrapped in NOT @has_category OR ....

If your API has 8 optional filters, you get 8 boolean parameters and 8 CASE-like conditions. It's not scalable.

The sqlx problem: no builder, no maintainer

sqlx extends database/sql with struct scanning — and that's it. No query builder. Dynamic queries mean string concatenation, exactly like raw database/sql.

But here's the bigger problem: sqlx is effectively abandoned. Multiple issues (#883, #969) ask "is this project dead?" with no response. A community fork was created in March 2025 because "the original project has been inactive for several years."

Building production systems on an abandoned library is a ticking time bomb.

The GORM problem: magic without transparency

GORM handles dynamic queries well — db.Where("category = ?", cat).Where("price >= ?", min) works naturally. But GORM's abstractions come with hidden costs that surface in production:

The zero-value gotcha:

// GORM silently ignores zero-value fields
db.Where(&Product{Price: 0}).Find(&products)
// Generated: SELECT * FROM products
// Expected:  SELECT * FROM products WHERE price = 0
Enter fullscreen mode Exit fullscreen mode

GORM skips Price: 0 because Go's zero value for int is 0. Your query returns all products when you wanted free ones. This has burned countless developers.

Hidden N+1 queries:
Fetching 100 orders with customer data? GORM might fire 101 queries — one for orders, one per customer. You won't know until your p99 latency spikes from 2ms to 6 seconds under load. The SQL is hidden behind layers of reflection.

And it brings external dependencies into your go.sum — GORM core plus a driver means multiple transitive packages. Each one is a potential supply chain vulnerability, a version conflict, a breaking change in an update.

The squirrel problem: unmaintained and unsafe

squirrel was once the go-to Go query builder. But it has 96 open issues, no releases since 2023, and a known security issue with unsafe identifier quoting. The maintainer explicitly stated they "will not necessarily respond" to bug reports.

What Go Developers Actually Want

The Go community keeps having the same debate: ORM vs raw SQL. But both sides want the same thing — convenience without losing control.

The requirements are clear:

  1. Dynamic query builder — composable WHERE conditions without string concatenation
  2. Transparent SQL — see exactly what's being sent to the database
  3. Zero magic — no hidden queries, no zero-value gotchas, no reflection surprises
  4. Minimal dependencies — ideally zero in production
  5. Multi-database — same API for PostgreSQL, MySQL, SQLite

This is exactly why I built Relica.

Some context: ozzo-dbx by Qiang Xue (creator of the Yii framework) was one of the best-designed Go query builders — clean Expression API, composable WHERE clauses, struct scanning without ORM magic. But it was built in 2015-2016 and hasn't been updated for modern Go.

Qiang just gave me full maintainer rights to the entire go-ozzo ecosystem. I've been maintaining Relica as ozzo-dbx's successor — rebuilt from scratch with Go 1.21+, zero production dependencies, and comprehensive security hardening that the original never had.

If you used ozzo-dbx and loved the design but wished someone was keeping the lights on — Relica is what it was always meant to become.

Building Dynamic Queries Without String Concatenation

Here's that same filtering problem with Relica's Expression API:

q := db.Select("id", "name", "price", "category").From("products")

if req.Category != "" {
    q = q.Where(relica.Eq("category", req.Category))
}
if req.MinPrice > 0 {
    q = q.Where(relica.GreaterOrEqual("price", req.MinPrice))
}
if req.MaxPrice > 0 {
    q = q.Where(relica.LessOrEqual("price", req.MaxPrice))
}
if req.InStock {
    q = q.Where(relica.GreaterThan("stock", 0))
}

var products []Product
err := q.All(&products)
Enter fullscreen mode Exit fullscreen mode

No WHERE 1=1. No placeholder numbering. No string concatenation. Each .Where() call adds an AND condition — conditions compose naturally.

Column names are automatically quoted for your dialect ("price" for PostgreSQL, `price` for MySQL). Values are always parameterized — SQL injection is structurally impossible through the Expression API.

And switching databases? Change the connection string. The query code is identical.

See the SQL Before You Run It

Unlike GORM, you always know what SQL Relica generates:

sql, params := q.ToSQL()
fmt.Println(sql)
// PostgreSQL: SELECT "id", "name", "price", "category"
//   FROM "products"
//   WHERE "category" = $1 AND "price" >= $2 AND "stock" > $3

fmt.Println(params)
// [electronics 100 0]
Enter fullscreen mode Exit fullscreen mode

ToSQL() works on all six query types — SELECT, INSERT, UPDATE, DELETE, UPSERT, and BatchInsert. In development, log every query. In production, use it for debugging slow queries. No guessing, no Debug mode — just call ToSQL().

Complex Filters with Expression Combinators

For OR conditions, nested logic, and subqueries — the Expression API composes:

// Active premium users OR users with high spending
q := db.Select().From("users").
    Where(relica.Or(
        relica.And(
            relica.Eq("status", "active"),
            relica.Eq("plan", "premium"),
        ),
        relica.GreaterThan("total_spent", 10000),
    ))

// Generated: WHERE ("status" = $1 AND "plan" = $2) OR ("total_spent" > $3)
Enter fullscreen mode Exit fullscreen mode

This is composable at the Go level — not SQL string templates, not code generation, not struct tags. Plain Go code that produces safe, readable SQL.

Beyond Dynamic Queries: What a Modern Go Query Builder Looks Like

Struct-Based CRUD Without the ORM

user := User{Name: "Alice", Email: "alice@example.com"}
db.Model(&user).Insert()
// user.ID is populated automatically
// PostgreSQL uses RETURNING, MySQL uses LastInsertId — you don't care

original := user
user.Email = "alice.smith@example.com"
db.Model(&user).UpdateChanged(&original)
// Only updates email — detects changed fields via reflect
Enter fullscreen mode Exit fullscreen mode

This is not an ORM. There are no relations, no eager loading, no migrations, no hooks. It's struct scanning and query building — explicit operations that produce predictable SQL.

JOINs with Properly Quoted Table Aliases

db.Select("c.name", "e.name", "e.salary").
    From("companies c").
    LeftJoin("employees e", "e.company_id = c.id").
    Where(relica.Eq("c.status", "active")).
    Where(relica.GreaterThan("e.salary", 80000)).
    All(&results)
Enter fullscreen mode Exit fullscreen mode

Table-aliased columns like c.status are correctly split and quoted for each dialect — "c"."status" for PostgreSQL, `c`.`status` for MySQL. This was a real bug we found and fixed — every identifier in the entire SQL generation pipeline is now properly quoted.

Transactions with Full API Parity

All query methods available on DB are also available on Tx — including batch operations and upserts:

err := db.Transactional(func(tx *relica.Tx) error {
    tx.BatchInsert("orders", []string{"user_id", "total"}).
        Values(1, 99.99).
        Values(2, 149.50).
        Execute()

    return nil  // auto-commit
})
// Panic inside the closure? Auto-rollback + re-panic
Enter fullscreen mode Exit fullscreen mode

Error Classification That Works Across Databases

_, err := db.Model(&user).Insert()

if relica.IsUniqueViolation(err) {
    // Duplicate email — same function on PostgreSQL, MySQL, SQLite
    return ErrEmailTaken
}

if relica.IsForeignKeyViolation(err) {
    // Referenced record doesn't exist
    return ErrInvalidReference
}
Enter fullscreen mode Exit fullscreen mode

No parsing error strings. No database-specific error code checks. One function, three databases.

Production-Grade Engineering

Relica isn't a weekend project. The v0.12.0 release involved a thorough security review that fixed 32 findings across identifier quoting, error handling, API consistency, and correctness:

  • 1700+ tests, ~90% coverage — unit tests with go-sqlmock, integration tests on real PostgreSQL, MySQL, and SQLite via testcontainers
  • Zero production dependencies — only database/sql from the standard library
  • Zero panics from public API — every error path returns error, never crashes your process
  • Zero lint issues — golangci-lint with 10+ analyzers including cyclop, govet, goconst
  • Listed in awesome-go under SQL Query Builders
  • API stability commitment — Relica follows "never release v2.0.0" philosophy. After v1.0, your code won't break on updates

Integration tests run on real databases with SQL reserved words as column names (order, select, group) — because if your query builder can't handle WHERE "order" = $1, it's not production-ready.

Getting Started in 30 Seconds

go get github.com/coregx/relica
Enter fullscreen mode Exit fullscreen mode
import (
    "github.com/coregx/relica"
    _ "github.com/lib/pq"  // or mysql, or sqlite
)

db, err := relica.Open("postgres",
    "postgres://user:pass@localhost/mydb?sslmode=disable")
if err != nil {
    log.Fatal(err)
}
defer db.Close()

// Dynamic query — no string concatenation
q := db.Select().From("users")
if status != "" {
    q = q.Where(relica.Eq("status", status))
}

var users []User
err = q.OrderBy("created_at DESC").Limit(20).All(&users)
Enter fullscreen mode Exit fullscreen mode

Works with lib/pq, pgx, go-sql-driver/mysql, modernc.org/sqlite, and any database/sql-compatible driver.

Full docs: pkg.go.dev/github.com/coregx/relica
Migration guides: From GORM | From sqlx

The Go SQL Ecosystem Deserves Better

The fact that dynamic WHERE clauses — the most basic requirement of any API with filters — remain unsolved in Go's most popular SQL libraries is a failure of the ecosystem.

sqlc is great but static. sqlx is abandoned. GORM trades transparency for convenience. squirrel is unmaintained and insecure.

Relica exists because I needed the middle ground: query builder convenience with raw SQL transparency, zero dependencies, and an API that won't change when you upgrade.

If you've been doing WHERE 1=1 — you can stop now.


GitHub: github.com/coregx/relica

How You Can Help

Relica is open source and actively maintained. Here's how to get involved:

  • Try it — replace one WHERE 1=1 query in your project and see if it clicks
  • Report bugs — if something doesn't work as expected, open an issue. We respond fast (0 open issues as of this writing)
  • Send PRs — bug fixes, new dialect support, documentation improvements — all welcome
  • Star the repo — it helps other developers discover Relica when searching for Go query builders
  • Tell others — if Relica solved a real problem for you, share it with your team or community
  • Write about it — blog posts, tutorials, comparisons — the more perspectives, the better the ecosystem

The Go SQL ecosystem has been stuck between "too much magic" and "too much boilerplate" for too long. Let's fix that together.

Top comments (0)