← Voltar para o blog

sqlc em Go: SQL Type-Safe com PostgreSQL

Aprenda sqlc em Go para gerar código type-safe a partir de SQL real: PostgreSQL, pgx, migrations, transações, testes e padrões de produção.

sqlc em Go resolve uma tensão comum em backends: você quer escrever SQL explícito, otimizado e fácil de revisar, mas também quer segurança de tipos, autocomplete, testes melhores e menos código repetitivo no repositório. Em vez de esconder o banco atrás de um ORM pesado, o sqlc lê suas queries SQL e gera código Go com structs, métodos e assinaturas coerentes com o schema.

Essa abordagem combina bem com a cultura Go: ferramentas simples, contratos explícitos e pouco runtime mágico. Você continua controlando índices, joins, transações, EXPLAIN, locks e migrations, mas ganha uma camada type-safe para chamar essas queries na aplicação. O resultado é especialmente útil em APIs, workers, backends financeiros, produtos B2B e sistemas que dependem de PostgreSQL de forma séria.

Este guia mostra quando usar sqlc, como organizar queries, como configurar com PostgreSQL e pgx, como pensar em transações e quais cuidados evitam dor em produção. Se você ainda está montando a base, leia também Go com PostgreSQL, migrations em Go para banco de dados e context.Context em Go.

O problema: SQL solto ou abstração demais

Em muitos projetos Go, o acesso ao banco começa com database/sql direto no handler ou no repositório:

row := db.QueryRowContext(ctx, `
    SELECT id, email, name
      FROM users
     WHERE id = $1
`, userID)

Isso é honesto e funciona. O problema aparece quando o projeto cresce. Você passa a repetir Scan, esquecer ordem de colunas, duplicar structs, descobrir erro de tipo só em runtime e fazer refactors sem apoio do compilador. Uma coluna muda de TEXT para UUID, uma query deixa de retornar um campo, um NULL aparece onde o Go esperava string, e a falha só surge no teste ou em produção.

No extremo oposto, alguns times colocam um ORM para evitar repetição. O ORM pode ajudar em CRUD simples, mas também pode esconder queries ruins, dificultar otimização, gerar SQL inesperado e criar uma camada de abstração que não conversa bem com recursos específicos do PostgreSQL.

O sqlc fica no meio: SQL continua sendo SQL. O código Go gerado vira uma fronteira tipada entre aplicação e banco.

Como o sqlc funciona

O fluxo básico tem três entradas:

  1. Arquivos de migration ou schema, com CREATE TABLE, índices, enums e constraints.
  2. Arquivos .sql com queries nomeadas.
  3. Um sqlc.yaml dizendo qual engine usar e onde gerar o pacote Go.

A partir disso, o sqlc valida as queries contra o schema e gera métodos Go. Uma query chamada GetUser vira algo como:

user, err := queries.GetUser(ctx, id)

Se a query espera uuid.UUID, a assinatura expõe isso. Se retorna colunas nullable, o tipo gerado reflete pgtype, sql.NullString ou o tipo configurado. Se você remove uma coluna usada por uma query, a geração falha antes do deploy.

Esse feedback curto é o maior ganho. O banco deixa de ser um contrato implícito espalhado em strings e passa a participar do ciclo normal de compilação e revisão.

Instalação e configuração mínima

Instale a ferramenta:

go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest

Uma configuração moderna com PostgreSQL e pgx pode começar assim:

version: "2"
sql:
  - engine: "postgresql"
    schema: "db/migrations"
    queries: "db/queries"
    gen:
      go:
        package: "db"
        out: "internal/db"
        sql_package: "pgx/v5"
        emit_json_tags: true
        emit_prepared_queries: false
        emit_interface: true
        emit_exact_table_names: false

O caminho db/migrations pode apontar para seus arquivos usados por golang-migrate, Atlas, Goose ou outra ferramenta. O importante é que o schema visto pelo sqlc seja o mesmo que chega ao banco de produção. Se migrations e sqlc divergem, você perde parte do benefício.

Para gerar:

sqlc generate

Em CI, rode esse comando e falhe se houver diff não commitado. Assim, query, schema e código gerado andam juntos.

Exemplo de schema e query

Imagine uma tabela simples de usuários:

CREATE TABLE users (
    id UUID PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Agora crie db/queries/users.sql:

-- name: GetUserByID :one
SELECT id, email, name, active, created_at
  FROM users
 WHERE id = $1;

-- name: CreateUser :one
INSERT INTO users (id, email, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, active, created_at;

-- name: ListActiveUsers :many
SELECT id, email, name, active, created_at
  FROM users
 WHERE active = true
 ORDER BY created_at DESC
 LIMIT $1 OFFSET $2;

-- name: DeactivateUser :exec
UPDATE users
   SET active = false
 WHERE id = $1;

Os comentários -- name: definem o nome do método e a cardinalidade esperada. :one exige uma linha, :many retorna slice, :exec executa sem mapear retorno. Esse contrato é simples e aparece claramente no review.

No serviço Go, o uso fica próximo do domínio:

func (s *UserService) CreateUser(ctx context.Context, input CreateUserInput) (db.User, error) {
    return s.queries.CreateUser(ctx, db.CreateUserParams{
        ID:    uuid.New(),
        Email: strings.ToLower(input.Email),
        Name:  input.Name,
    })
}

Você não escreve Scan, não repete struct manualmente e não descobre coluna fora de ordem por acidente.

Transações sem mágica

Em aplicações reais, uma query isolada raramente basta. Você precisa abrir transação, atualizar domínio, gravar outbox, criar auditoria ou validar consistência. O sqlc não impede isso. Ele gera um tipo Queries que pode trabalhar com uma interface compatível com conexão ou transação.

Um padrão comum:

func (s *Service) Transfer(ctx context.Context, input TransferInput) error {
    tx, err := s.pool.Begin(ctx)
    if err != nil {
        return err
    }
    defer tx.Rollback(ctx)

    qtx := s.queries.WithTx(tx)

    if err := qtx.DebitAccount(ctx, db.DebitAccountParams{
        ID:     input.FromAccountID,
        Amount: input.Amount,
    }); err != nil {
        return err
    }

    if err := qtx.CreditAccount(ctx, db.CreditAccountParams{
        ID:     input.ToAccountID,
        Amount: input.Amount,
    }); err != nil {
        return err
    }

    return tx.Commit(ctx)
}

A regra continua a mesma de qualquer código Go com banco: use context.Context, defina timeout no nível certo, trate Rollback como limpeza, mantenha transações curtas e não faça chamada HTTP externa enquanto segura lock. Para eventos, combine com outbox pattern em Go, não publique no broker dentro da transação principal.

Nullable, enums e tipos customizados

O ponto que mais separa exemplo de produção é o tratamento de NULL. SQL permite ausência de valor; Go precisa de tipo explícito. Dependendo da configuração, sqlc pode gerar sql.NullString, tipos de pgtype ou ponteiros. Escolha uma convenção e mantenha em todo o projeto.

Para campos realmente opcionais, NULL pode fazer sentido. Para estado de domínio, prefira NOT NULL com default e constraints. Um status TEXT NOT NULL CHECK (...) costuma ser mais claro que um campo nullable que aceita qualquer string.

Enums também merecem atenção. PostgreSQL enum é rígido e bom para estados estáveis, mas migrations de enum podem ser menos flexíveis. TEXT com CHECK é mais simples de evoluir em alguns produtos. O sqlc lida com ambos, mas a decisão é de modelagem, não da ferramenta.

Para UUID, timestamps, JSONB e numeric, configure overrides se os tipos padrão não forem os que o time usa. O objetivo é evitar conversões manuais espalhadas nos serviços.

Organização por domínio

Em projetos pequenos, um pacote internal/db com todas as queries funciona. Em sistemas maiores, pense em separação por domínio:

db/
  migrations/
  queries/
    users.sql
    accounts.sql
    invoices.sql
internal/
  db/
  users/
  billing/

Evite transformar o pacote gerado em seu domínio. Os tipos gerados representam linhas e parâmetros de queries, não necessariamente entidades ricas. Uma struct db.User pode ser suficiente para CRUD simples, mas um fluxo de negócio mais complexo talvez mereça um tipo próprio em internal/users.

A fronteira saudável é: sqlc cuida do contrato SQL; o serviço cuida de regra de negócio; o handler cuida de HTTP; o domínio não depende de detalhes acidentais de uma query.

Testes com sqlc

O sqlc reduz erros de compilação, mas não elimina testes de integração. Você ainda precisa validar migrations, constraints, índices e comportamento real do PostgreSQL. Para queries críticas, rode testes contra um Postgres de verdade em container ou ambiente efêmero.

Um teste útil cria schema limpo, roda migrations, insere dados e chama o método gerado:

func TestCreateUser(t *testing.T) {
    ctx := context.Background()
    pool := testPostgresPool(t)
    queries := db.New(pool)

    user, err := queries.CreateUser(ctx, db.CreateUserParams{
        ID:    uuid.New(),
        Email: "[email protected]",
        Name:  "Ana",
    })
    require.NoError(t, err)
    require.Equal(t, "[email protected]", user.Email)
}

Para lógica de serviço, você pode usar a interface emitida pelo sqlc e criar fake em testes unitários. Só não use fake como substituto total de teste real de query. O bug mais caro geralmente está na combinação de SQL, dados e constraint.

Quando sqlc não é a melhor escolha

sqlc não é obrigatório para todo projeto. Se você tem uma ferramenta pequena com duas queries, database/sql manual pode ser suficiente. Se o produto exige query builder dinâmico com dezenas de filtros opcionais, você pode combinar sqlc com SQL montado de forma controlada ou usar outra ferramenta em pontos específicos.

Também existe custo operacional: alguém precisa entender migrations, revisar SQL e manter o código gerado atualizado. Em times que querem fingir que banco relacional não existe, sqlc vai parecer mais explícito do que gostariam. Para sistemas Go sérios, isso é uma vantagem.

Use sqlc quando:

  • O banco relacional é parte central do produto.
  • Você quer SQL revisável e otimizado.
  • O time valoriza feedback de compilação.
  • Refactors de schema precisam ser seguros.
  • Queries críticas merecem testes e contratos claros.

Evite depender só dele quando:

  • A maior parte das queries é gerada dinamicamente.
  • O schema ainda muda de forma caótica sem migration disciplinada.
  • O time não roda CI com geração e testes de banco.

Checklist de produção

Antes de adotar sqlc em uma API Go, valide estes pontos:

  • Migrations são a fonte de verdade do schema.
  • sqlc generate roda no CI.
  • Código gerado é commitado ou gerado de forma reprodutível no build.
  • Queries recebem context.Context e respeitam timeout.
  • Transações são curtas e explícitas.
  • NULL, UUID, JSONB e timestamps têm convenção definida.
  • Testes de integração rodam contra PostgreSQL real.
  • Índices acompanham queries de listagem e filtros.
  • Erros de constraint são mapeados para respostas de domínio ou HTTP.
  • O pacote gerado não vaza regra de negócio para handlers.

Conclusão

sqlc é uma boa escolha para times Go que gostam de SQL e querem menos surpresa em produção. Ele não tenta transformar banco relacional em objeto mágico. Ele aceita que SQL é uma linguagem poderosa, valida esse SQL contra o schema e gera uma camada Go previsível para o resto da aplicação.

A combinação mais forte é simples: migrations disciplinadas, PostgreSQL bem modelado, queries sqlc pequenas e nomeadas, serviços com context.Context, testes de integração e observabilidade. Com isso, você mantém a clareza do SQL sem abrir mão da segurança de tipos que faz Go brilhar em sistemas de backend.

Se você está comparando stacks backend no mercado brasileiro, use este guia junto com vagas Go no Brasil e com o material de entrevista técnica Go. Para acompanhar oportunidades em outras linguagens e entender onde SQL, PostgreSQL e engenharia de dados aparecem fora do ecossistema Go, o portal Python Dev Brasil reúne vagas Python e dados no Brasil.