Skip to content
gohacks.dev
Databases

Squirrel Goqu and Other Query Builders for Go Compared

Compare Go SQL query builders: squirrel and goqu generate the same PostgreSQL query, and sqlc, Bun and Ent take other routes. Real generated SQL for each.

8 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

Most Go services start by writing SQL as strings and end up wanting help when queries become dynamic. The options sit on a spectrum from "write SQL" to "never see SQL", and the useful decision is where on that spectrum your team is comfortable. This roundup compares the query builders that most Go projects consider, shows the SQL two of them produce for one query, and describes the alternatives from their documentation. The two builders were run on Go 1.25.6; the others were not.

Tested with Go 1.25.6, squirrel v1.5.4, goqu v9.19.0; sqlc, Bun, Ent and jet described from documentation

The spectrum

ApproachYou writeYou getExamples
Plain SQLSQL stringsFull control, no checkingdatabase/sql, pgx
Query builderGo method chainsComposable SQL text and argssquirrel, goqu, jet
SQL to Go generatorSQL filesTyped Go functionssqlc
ORMGo structs and methodsSchema, queries, relationsGORM, Bun, Ent

The dynamic query problem, optional filters and variable IN lists, is the main reason people move from the first row to the second. The dynamic query article shows how far you can get by hand.

The same query, built twice

main.goGo
package main

import (
	"fmt"

	sq "github.com/Masterminds/squirrel"
	"github.com/doug-martin/goqu/v9"
	_ "github.com/doug-martin/goqu/v9/dialect/postgres"
)

func main() {
	// The same query three ways: users in two countries, aged 18 or more, newest first, page two.
	// 1. plain SQL
	fmt.Println("plain:   SELECT id, name FROM users WHERE country IN ($1, $2) AND age >= $3 ORDER BY created_at DESC LIMIT 20 OFFSET 20")

	// 2. squirrel
	s, args, _ := sq.Select("id", "name").From("users").
		Where(sq.Eq{"country": []string{"US", "UK"}}).
		Where(sq.GtOrEq{"age": 18}).
		OrderBy("created_at DESC").Limit(20).Offset(20).
		PlaceholderFormat(sq.Dollar).ToSql()
	fmt.Printf("squirrel: %s %v\n", s, args)

	// 3. goqu
	g := goqu.Dialect("postgres")
	gs, gargs, _ := g.From("users").Select("id", "name").
		Where(goqu.C("country").In("US", "UK"), goqu.C("age").Gte(18)).
		Order(goqu.C("created_at").Desc()).Limit(20).Offset(20).
		Prepared(true).ToSQL()
	fmt.Printf("goqu:     %s %v\n", gs, gargs)

	// An insert and an upsert, where builders save the most typing.
	is, iargs, _ := g.Insert("users").Rows(goqu.Record{"name": "ada", "age": 36}).
		OnConflict(goqu.DoUpdate("name", goqu.Record{"age": 36})).Prepared(true).ToSQL()
	fmt.Printf("goqu upsert: %s %v\n", is, iargs)
	ss, sargs, _ := sq.Insert("users").Columns("name", "age").Values("ada", 36).
		Suffix("ON CONFLICT (name) DO UPDATE SET age = ?", 36).PlaceholderFormat(sq.Dollar).ToSql()
	fmt.Printf("squirrel upsert: %s %v\n", ss, sargs)
}
Shell
go run . 2>&1
Text
plain:   SELECT id, name FROM users WHERE country IN ($1, $2) AND age >= $3 ORDER BY created_at DESC LIMIT 20 OFFSET 20
squirrel: SELECT id, name FROM users WHERE country IN ($1,$2) AND age >= $3 ORDER BY created_at DESC LIMIT 20 OFFSET 20 [US UK 18]
goqu:     SELECT "id", "name" FROM "users" WHERE (("country" IN ($1, $2)) AND ("age" >= $3)) ORDER BY "created_at" DESC LIMIT $4 OFFSET $5 [US UK 18 20 20]
goqu upsert: INSERT INTO "users" ("age", "name") VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET "age"=$3 [36 ada 36]
squirrel upsert: INSERT INTO users (name,age) VALUES ($1,$2) ON CONFLICT (name) DO UPDATE SET age = $3 [ada 36 36]

The task: users in two countries, aged 18 or over, newest first, page two of twenty. The program prints the plain SQL as a reference, then the output of each library for PostgreSQL.

squirrel (github.com/Masterminds/squirrel) is a thin builder. Method calls add fragments, ToSql returns the SQL string and the arguments. The output:

Text
SELECT id, name FROM users WHERE country IN ($1,$2) AND age >= $3 ORDER BY created_at DESC LIMIT 20 OFFSET 20 [US UK 18]

PlaceholderFormat(sq.Dollar) turned the default ? into $1. Limit and Offset are written into the text, as numbers, not as parameters. Identifiers appear as given. The library is small, has no dependencies, and the SQL is close to what you would write.

goqu (github.com/doug-martin/goqu/v9) is a richer, dialect-aware builder. The output:

Text
SELECT "id", "name" FROM "users" WHERE (("country" IN ($1, $2)) AND ("age" >= $3)) ORDER BY "created_at" DESC LIMIT $4 OFFSET $5 [US UK 18 20 20]

Three differences stand out. goqu quotes every identifier, which protects against reserved words and mixed case but makes the SQL harder to read in logs. It wraps conditions in parentheses. With Prepared(true) it turns even the limit and offset into parameters, five arguments in total. You select a dialect (goqu.Dialect("postgres")) and import its package, and it adapts quoting and placeholders. It also has struct-based features: it can build inserts and updates from structs with db tags, and it can execute queries and scan results itself, if you give it a *sql.DB.

Inserts and upserts

Builders save the most typing on writes. The program builds the same upsert twice:

Text
goqu upsert:     INSERT INTO "users" ("age", "name") VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET "age"=$3 [36 ada 36]
squirrel upsert: INSERT INTO users (name,age) VALUES ($1,$2) ON CONFLICT (name) DO UPDATE SET age = $3 [ada 36 36]

goqu models the ON CONFLICT clause with DoUpdate; squirrel has no first-class method for it and uses Suffix with a raw string and its own arguments. Both work. Notice goqu sorted the column names (age, name) and so ordered the arguments [36 ada 36]: when you build from a map, the column order is the library's, not yours.

The alternatives, from their documentation

sqlc takes the other route: you write SQL in .sql files, annotate each query with a name and a return type, and sqlc generate writes typed Go functions that call database/sql or pgx. The SQL is checked against your schema at generation time, so a typo in a column name is a build error instead of a runtime one. Dynamic queries are its weak point, because each query is fixed text; it offers sqlc.narg and conditional patterns for optional filters. It was not run here. The team that wants real SQL and compile-time safety usually lands on it.

Bun (uptrace/bun) is a lightweight ORM-and-builder hybrid over database/sql with a fluent query API and struct mapping, strongest on PostgreSQL, MySQL and SQLite. jet (go-jet/jet) generates a type-safe builder from your schema, so column names are Go identifiers checked by the compiler. Ent (entgo.io) is a code-generated ORM driven by a schema written in Go, with graph-style traversals. GORM is the most widely used Go ORM, with a large ecosystem, hooks and associations, and the usual ORM trade-offs. I did not run Bun, jet, Ent or GORM for this article, and the descriptions come from their documentation.

How they compare

squirrelgoqusqlcBunEnt
StyleBuilderBuilderSQL to GoBuilder and ORMGenerated ORM
Compile-time column checksNoNoYes, at generationPartialYes
Dynamic filtersNaturalNaturalAwkwardNaturalNatural
Identifier quotingAs writtenAlwaysAs writtenConfigurableGenerated
Raw SQL escape hatchYesYesIt is the defaultYesLimited
Learning curveVery lowLowLowMediumHigh
DependenciesNoneSmallBuild toolModerateGenerator

How to choose

  1. Few queries, mostly fixed: plain SQL with pgx or database/sql, and sqlx or pgx.CollectRows for scanning. You do not need a builder.
  2. Many fixed queries, you want type safety: sqlc.
  3. Many dynamic queries, you want to see SQL: squirrel for a minimal layer, goqu for a richer one.
  4. A large domain model with relations: an ORM, accepting its costs; Ent if you like generated, typed APIs; GORM if the ecosystem matters most.
  5. A mix: use sqlc for the fixed majority and a builder for the dynamic reports. Both emit SQL with placeholders and run on the same pool.

Overhead, logging and testing

Builders run at request time, so they cost CPU and allocations: squirrel and goqu allocate a few dozen small objects per query. For most services that is invisible next to a network round trip of hundreds of microseconds. If a hot path builds the same query thousands of times a second, build the text once and reuse it, or use a fixed query with sqlc. Measure with a benchmark before deciding, using the method in the benchmark guide.

Log the generated SQL and arguments when something is slow, but never log argument values at a level that reaches shared logs if they hold personal data. A good habit is a small wrapper that records the SQL text, the argument count and the duration for each call, and leaves out the values.

Test dynamic queries at two levels. The first is cheap: assert the generated text and arguments for a table of inputs, which runs in microseconds and needs no database. The second is an integration test against the real engine, to catch what the text check cannot see, such as a reserved word, a type mismatch or a missing index. A builder that produces valid-looking SQL for PostgreSQL can still produce SQL that MySQL rejects, because the placeholder style and quoting differ by dialect; test on the database you deploy. The article on fakes versus real databases covers the container route.

Migrating between them

Moving from one builder to another, or from a builder to sqlc, can be done one query at a time because every approach produces SQL that runs on the same database/sql or pgx connection. Start with the queries that hurt most: the ones with the most conditionals, or the ones that drift out of sync with the schema. Leave the stable ones alone. A repository layer that hides the data access behind an interface, with methods named for the domain, makes the change invisible to callers and gives you a place to test it.

What none of them fix

A builder does not make a query fast; look at the plan. It does not protect raw strings you pass in, such as an OrderBy fragment from user input, as explained in SQL injection. It does not decide your transaction boundaries. And it adds a layer between you and the SQL that the database receives, which you must read when something is slow: log the generated text and arguments in development, and use explain on it.

For all of them, keep the usual discipline: parameters for values, allowlists for names, a context on every call. The libraries are documented at pkg.go.dev for squirrel and goqu, the sqlc project at sqlc.dev, and the scanning side in the sqlx article.

Taggedroundup
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.