Let’s be honest: writing raw SQL in Go is a bit of a drag. You’re constantly juggling strings, wrestling with sql.Rows and sql.NullString, and playing a guessing game with your struct fields. It’s tedious, error-prone, and frankly, beneath you. You know SQL. You know Go. You just want a clean, type-safe way to marry the two without some bloated ORM getting in the way and trying to be clever. This is where sqlc enters the scene, not as a mediator, but as a brilliant compiler for your SQL.

Think of sqlc as your own personal code gen intern who’s a total stickler for types. You write SQL queries in their own files. You write a simple configuration file telling sqlc where your database schema lives. You run a command. And poof: it generates utterly pristine, fully typed Go methods and structs for every single one of your queries. No reflection, no magic at runtime—just clean, idiomatic Go code that you can immediately call from your application. It’s the “write what you mean” philosophy, applied to database access.

The Core Setup: sqlc.yaml is Your Blueprint

Your relationship with sqlc starts with a configuration file. This is where you tell it everything it needs to know. A typical sqlc.yaml looks something like this:

version: "2"
sql:
  - schema: "db/schema.sql"
    queries: "db/queries/"
    engine: "postgresql"
    gen:
      go:
        package: "db"
        out: "internal/db"
        sql_package: "pgx/v5"

This is beautifully straightforward. The schema path points to your database DDL—the single source of truth for your tables and types. The queries directory is where you’ll store all your .sql files. The gen.go section tells sqlc what language to output (Go, obviously), what to call the package, and where to put it. The sql_package is crucial: you can choose the standard database/sql or the superior, more performant github.com/jackc/pgx/v5 (for Postgres). Always choose pgx if you can; its native type support is worth it.

Authoring Queries: SQL as the Source of Truth

Your queries live in .sql files within the queries directory. The key here is that sqlc parses both your schema and these queries to understand the inputs and outputs. You use comments to give the generated function its name.

-- name: GetAuthor :one
SELECT * FROM authors
WHERE id = $1 LIMIT 1;

-- name: ListAuthors :many
SELECT * FROM authors
ORDER BY name;

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

-- name: DeleteAuthor :exec
DELETE FROM authors
WHERE id = $1;

Look at those comment directives: :one, :many, :exec. This is how you tell sqlc what to expect back. :one for a single row, :many for multiple rows, :exec for queries that return nothing (like INSERTs without RETURNING or DELETEs). RETURNING * is Postgres magic that makes CreateAuthor return the freshly created record, which is why it uses :one.

The Generated Gold: What You Get For Free

Run sqlc generate and behold the glory in your out directory. For the queries above, sqlc will generate a db.go file full of model structs and a queries.sql.go file. The generated code for GetAuthor will look essentially like this:

// Code generated by sqlc. DO NOT EDIT.

package db

import (
	"context"
)

const getAuthor = `-- name: GetAuthor :one
SELECT id, name, bio FROM authors
WHERE id = $1 LIMIT 1
`

func (q *Queries) GetAuthor(ctx context.Context, id int64) (Author, error) {
	row := q.db.QueryRow(ctx, getAuthor, id)
	var i Author
	err := row.Scan(&i.ID, &i.Name, &i.Bio)
	return i, err
}

It’s perfect. A prepared SQL string, a method on a Queries struct that takes exactly the right parameter type (int64), and returns exactly the right result type (Author) or an error. The Author struct was also generated directly from your authors table schema. You are now insulated from typos in column names and type mismatches. Your IDE’s autocomplete and go vet will now help you with your database calls. It’s a miracle.

What about more complex queries? sqlc handles them with ease. Let’s say you need a query with multiple parameters and a join that returns a custom result.

-- name: GetAuthorWithBooks :many
SELECT
  authors.id,
  authors.name,
  authors.bio,
  books.id as book_id,
  books.title as book_title
FROM authors
JOIN books ON authors.id = books.author_id
WHERE authors.name LIKE $1 || '%' AND books.rating > $2;

sqlc will ingeniously generate a new struct for this specific result set. It won’t try to force the Author model; it will create a new type, typically called GetAuthorWithBooksRow, with the fields ID, Name, Bio, BookID, and BookTitle, all with the correct Go types mapped from the SQL types. Your function signature will be GetAuthorWithBooks(ctx context.Context, name string, rating int32) ([]GetAuthorWithBooksRow, error). It’s meticulous, and it’s always correct.

Common Pitfalls and How to Avoid Them

The biggest “gotcha” is that sqlc is a code generator, not a runtime. If you change your database schema, you must re-run sqlc generate to keep the Go code in sync. Forget this, and you’ll get very confusing errors about missing columns in row.Scan. Integrate the sqlc generate command into your build process, right before go build.

Another edge case is dealing with nullable fields. sqlc correctly uses sql.NullString, sql.NullInt64, etc., for nullable columns. This is the right way to do it, but it does make your code a bit more verbose. It’s the price of type safety. You can use custom go_type mappings in sqlc.yaml to use pointers (*string) instead if you prefer, but I find the sql.Null* types more explicit.

Finally, remember that sqlc is not a migration tool. It doesn’t manage your database schema changes for you. It only reads your schema file to generate code. You still need a proper migration tool like goose, golang-migrate, or atlas to actually apply those changes to a running database. Don’t confuse the two responsibilities.

sqlc respects your skills. It assumes you can write efficient SQL and just want to get on with it. It removes the boilerplate, enforces type safety, and gets out of your way. It’s not just a tool; it’s a force multiplier.