The Case for SQL in Go

After months of evaluating options for a database-heavy Go application, we’ve settled on sqlc as our default approach for working with Postgres. The mainstream alternatives all have meaningful drawbacks that become more painful as an application grows.

Here’s a quick rundown of what’s out there:

  • database/sql: Go’s built-in package is database-agnostic, which sounds nice but means it sticks to the lowest common denominator. No Postgres-specific features are available, and hydrating results into structs requires manual Scan calls.

  • lib/pq: An early Postgres driver that was solid for its era but is no longer actively maintained.

  • pgx: A well-crafted, performant Postgres driver, but deliberately free of ORM-like conveniences. You still write SELECT lists by hand and scan results field by field. scany eliminates the manual scanning but not the repeated field listing.

  • go-pg and gorm: Postgres-specific and general ORMs respectively. The former ships its own driver and isn’t pgx-compatible; go-pg is in maintenance mode in favor of Bun. ORMs generally miss a lot of Postgres features.

The Problem With Queries-as-Strings

With vanilla database/sql or pgx, SQL lives in string literals:

var name string
var weight int64
err := conn.QueryRow(ctx, "SELECT name, weight FROM widgets WHERE id = $1", 42).
	Scan(&name, &weight)
if err != nil {
	...
}
fmt.Println(name, weight)

That’s acceptable for trivial queries, but the compiler sees only opaque text. Confidence requires exhaustive tests. Larger applications often start concatenating and gluing query fragments together to share code:

err := conn.QueryRow(ctx, `SELECT ` + scanTeamFields + ` ...)

It can work, and tests may catch mistakes, but it gets messy fast.

What ORMs Actually Give You

ORMs like go-pg add some typing to the mix, which helps reduce mistakes:

story := new(Story)
err = db.Model(story).
    Relation("Author").
    Where("story.id = ?", story1.Id).
    Select()
if err != nil {
    panic(err)
}

Without generics, Go’s type system offers limited protection. Model(), Relation(), and Where() all return *Query objects, so a wide range of errors only surface at runtime. There’s also an impedance mismatch: developers know SQL, and reaching for the ORM docs to figure out how to express an upsert or a CTE is slower than writing the SQL in the first place.

Writing SQL, Getting Typed Go

sqlc takes a different path. You maintain *.sql files containing table definitions and queries annotated with names and return types in magic comments:

CREATE TABLE authors (
  id   BIGSERIAL PRIMARY KEY,
  name text      NOT NULL,
  bio  text
);

-- name: CreateAuthor :one
INSERT INTO authors (
  name, bio
) VALUES (
  $1, $2
)
RETURNING *;

Run sqlc generate and you get callable, type-safe Go:

author, err = dbsqlc.New(tx).CreateAuthor(ctx, dbsqlc.CreateAuthor{
    Name: "Haruki Murakami",
    Bio:  "Author of _Killing Commendatore_. Running and jazz enthusiast.",
    ...
})

if err != nil {
    return nil, xerrors.Errorf("error creating author: %w", err)
}

fmt.Printf("Author name: %s\n", author.Name)

It’s not an ORM, but it brings the most valuable ORM feature: automatic mapping of query results into structs. With SELECT * or RETURNING *, sqlc knows the table’s schema and emits a standard struct. All queries returning the full record share that same output type.

Validation happens before runtime. sqlc parses queries with PGAnalyze’s pg_query_go, which embeds Postgres’s actual query parser. Invalid SQL never compiles. That’s a meaningful improvement over raw string queries. And because SQL is declarative, it tends to produce fewer logical bugs than equivalent procedural code, meaning you need less exhaustive test coverage.

Codegen That Doesn’t Hurt

The philosophical aversion to code generation is understandable, but sqlc won us over in practice. Installation is a single go get command, and the development loop is effectively instant. Our project has around 100 queries across a dozen input files, and codegen completes in well under a second:

$ time sqlc generate

real    0.07s
user    0.08s
sys     0.01s

Even at 10,000 queries, the cycle would still be comfortable. A GitHub Action verifies that committed code matches generated output—checkout, binary download, and generation run together in just 4 seconds.

pgx Compatibility Arrives

Previously, sqlc lacked pgx support, which ruled it out for us. A recent pull request added multi-driver support, and it’s available in the latest release. The integration is loosely coupled—our codebase already used pgx heavily with custom abstractions on top, and we slotted sqlc in alongside them in less than an hour. Within a single transaction, sqlc and raw pgx calls can coexist, so migration is incremental.

Workarounds for sqlc’s Rough Edges

A few things feel less convenient than an ORM:

  • No variadic parameters: Multi-row inserts don’t work directly. You can pass arrays in a single query and unnest them:

    -- Upsert many marketplaces, inserting or replacing data as necessary.
    INSERT INTO marketplace (
        name,
        display_name
    )
    SELECT unnest(@names::text[]) AS name,
        unnest(@display_names::text[]) AS display_names
    ON CONFLICT (name)
        DO UPDATE SET display_name = EXCLUDED.display_name
    RETURNING *;
    
  • No dynamic UPDATE clauses: You can’t build SET a = 1, b = 2 at runtime. Instead, conditionally apply each field based on a boolean parameter:

    -- Update a team.
    -- name: TeamUpdate :one
    UPDATE team
    SET
        customer_id = CASE WHEN @customer_id_do_update::boolean
            THEN @customer_id::VARCHAR(200) ELSE customer_id END,
    
        has_payment_method = CASE WHEN @has_payment_method_do_update::boolean
            THEN @has_payment_method::bool ELSE has_payment_method END,
    
        name = CASE WHEN @name_do_update::boolean
            THEN @name::text ELSE name END
    WHERE
        id = @id
    RETURNING *;
    

The Go caller ends up looking like this:

team, err = queries.TeamUpdate(ctx, dbsqlc.TeamUpdateParams{
    NameDoUpdate: true,
    Name:         req.Name,
})

Finally, sqlc has no opinions about query naming or file layout—establish your own conventions to keep things discoverable.

Current Verdict

sqlc feels fast and unobtrusive, much like Go itself. It’s not necessarily the best tool in every language—Rust’s type system enables near-wizardry in its SQL drivers—but it’s our clear preference for Go.

Generics in Go could reshape this space, potentially enabling a new generation of ORMs with better compile-time checking and type completion. That’s a year or two out at best. In the meantime, sqlc is where we’re staying.