Pular para o conteúdo

Trilha de Go · parte 44 de 65

SQLX em Go: mapeamento, named queries e transações

Como o sqlx preenche structs pela tag db, usa consultas com parâmetros nomeados e sqlx.In, e fecha transações com defer tx.Rollback, testado em SQLite.

intermediárioPublicada em · 26 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Imagine a recepção de um consultório. Cada paciente chega com uma ficha, e a secretária copia os campos para o prontuário. Ela pode trabalhar de dois jeitos: contar os campos pela posição (“o terceiro item vai para o terceiro quadrado”) ou ler o nome escrito em cada campo e procurar o quadrado com esse mesmo nome. O primeiro jeito quebra quando alguém acrescenta um campo na ficha. O segundo aguenta a mudança sem reclamar. O database/sql puro funciona como o primeiro jeito, e o sqlx é o segundo.

Esta é a aula 6.6 da trilha de Go, na fase de bancos de dados e persistência. Ela vem depois de database/sql, pgx, sqlc e GORM, e antes das migrations. Os pré-requisitos são database-sql e Connection Pooling, que explica o pool e as transações no nível do driver, e PostgreSQL com pgx, que mostra o driver mais usado com PostgreSQL. Se você já sabe o que é um *sql.DB e o que faz o Scan, pode pular para a seção 3.

Mapa da aula

ParteSeçõesO que você vai dominar
Problema e instalação1 e 2Por que o Scan manual cansa, e a versão da biblioteca usada aqui
Mapeamento3, 4 e 5Tags db, Get, Select, StructScan e consultas com :nome
Listas e transações6 e 7sqlx.In, Rebind, Beginx, Commit e Rollback com defer
Exemplo rodado8Programa completo em SQLite em memória, com a saída real
Segurança e decisão9, 10 e 11 de SQL, quando usar sqlx, sqlc ou pgx puro, erros comuns
Prática e referências12 a 16Exercícios com resposta, o que não foi confirmado, glossário, fontes e relacionados

1. O problema que o sqlx resolve

Para entender o sqlx, primeiro é preciso ver a dor que ele alivia. Com database/sql puro, uma consulta que devolve linhas exige que você leia cada coluna e entregue um ponteiro para cada campo, na ordem exata em que as colunas aparecem no SELECT:

// Versão com database/sql puro, para comparar
func listarPuro(db *sql.DB) ([]P, error) {
	rows, err := db.Query("SELECT id, nome FROM p ORDER BY id")
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var ps []P
	for rows.Next() {
		var p P
		if err := rows.Scan(&p.ID, &p.Nome); err != nil {
			return nil, err
		}
		ps = append(ps, p)
	}
	return ps, rows.Err()
}

Repare em três coisas. O rows.Scan recebe os ponteiros na mesma ordem do SELECT, então, se alguém trocar a ordem das colunas na consulta e esquecer o Scan, os valores vão parar no campo errado sem erro algum quando os tipos forem compatíveis. Acrescentar uma coluna ao SELECT sem acrescentar o ponteiro correspondente também quebra o código, mas o aviso só aparece quando a consulta roda. E cada struct novo pede mais uma função dessas. Escrever Scan à mão é um trabalho que ninguém cita no currículo e que quase todo mundo faz.

O sqlx não substitui o database/sql. Segundo o README do projeto, os tipos dele envolvem os originais sem alterar as interfaces. Na prática, sqlx.Open devolve um *sqlx.DB que guarda um *sql.DB no campo DB, e é por isso que a função de comparação acima recebe db.DB na chamada. Dá para adotar o sqlx em um projeto existente trocando função por função.

O que ele acrescenta, em resumo, aparece na tabela abaixo. Cada linha é explicada na seção indicada.

RecursoO que fazSeção
GetTraz uma linha e preenche um struct (ou um valor simples)4
SelectTraz várias linhas e preenche um slice4 e 6
NamedExec e NamedQueryUsam parâmetros com nome, tirados de um struct ou de um map5
StructScanPreenche um struct a partir da linha atual de um Rows5
sqlx.In e RebindExpandem listas em vários ? e convertem o marcador para o formato do driver6
Beginx e BeginTxxAbrem uma que devolve um *sqlx.Tx7

2. Versão e instalação

A versão atual da biblioteca é a v1.4.0. Consultei em 2026-11-03 o endereço @latest do proxy de módulos do Go, que devolve a v1.4.0, e a página de releases do GitHub mostra a mesma versão marcada como “Latest”. As referências de API usadas nas seções seguintes vêm do código do módulo v1.4.0, lido com go doc. O README também diz que o projeto garante compatibilidade só com as duas versões mais recentes do Go, então confira isso antes de fixar a toolchain do seu projeto.

go get github.com/jmoiron/sqlx@v1.4.0
go get modernc.org/sqlite@v1.61.1

O exemplo desta aula roda em SQLite em memória, com o driver modernc.org/sqlite. Ele é escrito em Go puro, então não exige cgo nem compilador C, e se registra com o nome "sqlite". Para PostgreSQL, o caminho comum seria o driver github.com/jackc/pgx/v5/stdlib. Não subi um servidor de PostgreSQL para esta aula, então os trechos que falam de PostgreSQL são explicações e não rodaram.

3. Mapeamento de struct pela

O sqlx descobre quais campos do struct recebem quais colunas lendo a tag db. A tag é o texto entre crases depois do tipo do campo. O struct usado no exemplo é este:

type Produto struct {
	ID        int    `db:"id"`
	Nome      string `db:"nome"`
	Categoria string `db:"categoria"`
	Estoque   int    `db:"estoque"`
}

Quando um campo não tem tag, o sqlx usa o nome do campo em minúsculas, porque o NameMapper padrão é strings.ToLower, conforme a documentação do pacote. Por isso Nome string sem tag casaria com uma coluna nome. A tag só se torna necessária quando o nome da coluna e o do campo não coincidem, como tutor_id e TutorID, ou quando você quer deixar explícito o contrato entre o banco e o código. Eu prefiro sempre escrever a tag: quem lê o struct dez meses depois não precisa adivinhar o nome da coluna.

A descoberta do mapa de campos é feita por reflexão. O código do sqlx guarda o resultado num cache indexado pelo tipo, então o trabalho de olhar as tags acontece uma vez por tipo, e não a cada linha lida. Isso explica por que o sqlx é bem mais leve do que parece à primeira vista: ele não reconstrói o mapa a cada consulta.

Cada coluna do SELECT vai para o campo do struct com a mesma tag db; uma coluna sem campo correspondente vira erro do sqlx ao mapear a linha.
figura 1 · Mapeamento de colunas do SELECT para os campos do struct pela tag db

A figura mostra o caminho de uma linha. As colunas do SELECT à esquerda são casadas com os campos do struct à direita pelo nome da tag, e a coluna extra não tem campo correspondente. Quando isso acontece, o banco já devolveu a linha, e o sqlx recusa o mapeamento com um erro que diz qual coluna sobrou. No teste que rodei, o erro foi missing destination name extra in *[]main.P.

Existe uma saída para esse erro: Unsafe(), que faz o Select ignorar colunas sem campo. No teste, a consulta passou e o primeiro produto veio com o campo Nome vazio. Essa opção parece confortável, mas ela desliga justamente o aviso que você mais precisa ver cedo. Ligue-a só quando a consulta trouxer colunas auxiliares de propósito, e deixe um comentário dizendo por quê. O aviso do sqlx é como a luz do painel do carro: ignorá-la não faz o problema sumir.

4. Get e Select

Get traz uma linha. A função abaixo usa o GetContext, que é a versão com contexto, e preenche um struct:

// Get: uma linha só, preenchida em um struct.
func buscarProduto(ctx context.Context, db *sqlx.DB, id int) (Produto, error) {
	var p Produto
	err := db.GetContext(ctx, &p,
		"SELECT id, nome, categoria, estoque FROM produtos WHERE id = ?", id)
	return p, err
}

Se a consulta achar a linha, o struct volta preenchido. Se não achar, o Get devolve sql.ErrNoRows, a mesma sentinela usada pelo Row.Scan do database/sql. Por isso o teste correto é errors.Is(err, sql.ErrNoRows), que separa “esse registro não existe” de “o banco caiu”. Confundir os dois é um erro comum, e ele aparece na seção 11. No exemplo, o produto 1 existe e o produto 99 não existe, e a saída mostra a diferença:

produto 1: {ID:1 Nome:Caneca Categoria:casa Estoque:5} (erro: <nil>)
produto 99: erro=sql: no rows in result set, é sql.ErrNoRows=true

O Get também serve para um valor simples. Quando o destino é um tipo escalar, como um int, a consulta precisa devolver uma coluna só. O exemplo usa isso para contar os pedidos com SELECT COUNT(*).

Select faz o mesmo papel para várias linhas, preenchendo um slice. A consulta com lista de IDs, na seção 6, mostra o Select em uso junto com sqlx.In.

Repare também nos marcadores ?. O SQLite aceita ?. O PostgreSQL aceita $1, $2 e assim por diante. A seção 6 explica como o sqlx lida com essa diferença.

5. Consultas nomeadas com :nome

Uma troca ? por nomes como :nome, e o sqlx busca o valor de cada nome no struct, usando a tag db. O exemplo cadastra os produtos assim:

func semearProdutos(db *sqlx.DB) error {
	produtos := []Produto{
		{Nome: "Caneca", Categoria: "casa", Estoque: 5},
		{Nome: "Camiseta", Categoria: "roupa", Estoque: 10},
		{Nome: "Boné", Categoria: "roupa", Estoque: 0},
		{Nome: "Garrafa", Categoria: "casa", Estoque: 3},
	}
	for _, p := range produtos {
		// NamedExec troca :nome, :categoria e :estoque pelos campos do struct,
		// encontrados pela tag db. Não precisa montar a lista de argumentos.
		_, err := db.NamedExec(`INSERT INTO produtos (nome, categoria, estoque)
			VALUES (:nome, :categoria, :estoque)`, p)
		if err != nil {
			return err
		}
	}
	return nil
}

O NamedExec lê os campos Nome, Categoria e Estoque pelas tags, então você não monta a lista de argumentos na mão. Se o nome na consulta não existir no struct, o erro aparece antes de a consulta ser enviada. O teste que fiz com :nao_existe devolveu could not find name nao_existe in main.P{ID:0, Nome:"x"}. Esse é um erro de programação, não de banco, e ele é uma boa razão para manter a consulta e o struct lado a lado no mesmo arquivo.

Para ler, NamedQueryContext devolve um *sqlx.Rows, e o StructScan preenche um struct a cada chamada de Next. O exemplo filtra por categoria e estoque mínimo, com parâmetros nomeados vindos de outro struct:

// NamedQuery: os parâmetros :categoria e :minimo vêm do struct filtro.
func listarProdutos(ctx context.Context, db *sqlx.DB, f FiltroProduto) ([]Produto, error) {
	rows, err := db.NamedQueryContext(ctx, `
		SELECT id, nome, categoria, estoque FROM produtos
		WHERE categoria = :categoria AND estoque >= :minimo
		ORDER BY id`, f)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var ps []Produto
	for rows.Next() {
		var p Produto
		if err := rows.StructScan(&p); err != nil {
			return nil, err
		}
		ps = append(ps, p)
	}
	return ps, rows.Err()
}

O filtro FiltroProduto usa a mesma regra: :categoria vai para o campo com a tag db:"categoria", e :minimo vai para o campo com a tag db:"minimo". O nome da tag não precisa ser igual ao nome do campo, e isso é útil quando a consulta usa um nome mais curto do que o da propriedade.

6. Listas com e

O database/sql não sabe expandir uma lista dentro de IN (?). Cada valor precisa de um marcador próprio, então é preciso gerar IN (?, ?, ?) com a quantidade certa. O sqlx.In faz essa expansão e devolve a consulta e a lista achatada de argumentos:

// Select com In: sqlx.In expande a lista em ?, ?, ?
func buscarProdutosPorIDs(ctx context.Context, db *sqlx.DB, ids []int) ([]Produto, error) {
	query, args, err := sqlx.In(
		"SELECT id, nome, categoria, estoque FROM produtos WHERE id IN (?) ORDER BY id", ids)
	if err != nil {
		return nil, err
	}
	// sqlx.In devolve ?. db.Rebind converte para o formato do driver
	// ($1, $2... no PostgreSQL). No SQLite, ? já é o formato certo.
	query = db.Rebind(query)

	var ps []Produto
	err = db.SelectContext(ctx, &ps, query, args...)
	return ps, err
}

O sqlx.In devolve sempre o marcador ?. Para o driver que usa outro formato, o Rebind faz a conversão. Testei a função de texto diretamente, sem servidor, e a saída foi esta:

In -> SELECT id FROM t WHERE id IN (?, ?, ?) AND nome = ? [1 3 4 x]
Rebind DOLLAR -> SELECT id FROM t WHERE id IN ($1, $2, $3) AND nome = $4
Rebind QUESTION -> SELECT id FROM t WHERE id IN (?, ?, ?) AND nome = ?

O sqlx decide o formato pelo nome do driver. sqlx.BindType("pgx") devolveu 2, que é DOLLAR, e sqlx.BindType("sqlite") devolveu 0, que é UNKNOWN. Como o sqlx não conhece o nome "sqlite", o Rebind deixa o ? como está, e a consulta funciona. Esse comportamento é o motivo de o exemplo chamar db.Rebind mesmo no SQLite: o código fica certo quando você trocar o driver. Se o seu driver não estiver na lista do sqlx, sqlx.BindDriver registra o formato usado.

Há uma armadilha aqui. Uma lista vazia não gera IN (), que é SQL inválido. O sqlx recusa a expansão com o erro empty slice passed to 'in' query, e é melhor checar o tamanho da lista antes de chamar a função.

7. Transações com Beginx,

Uma transação agrupa escritas que precisam acontecer juntas. Ou todas se confirmam, ou nenhuma fica gravada. No nível do driver, a transação reserva uma conexão do pool até o fim, como explica a aula database-sql e Connection Pooling. O sqlx abre a transação com BeginTxx, que recebe um contexto, ou com Beginx, que não recebe. Prefira BeginTxx, porque o contexto permite cancelar a operação inteira.

O diagrama abaixo mostra o caminho de um pedido com um produto com estoque insuficiente:

Transação com Beginx: pedido e itens são gravados numa só conexão, e uma recusa do CHECK no estoque leva ao Rollback, que desfaz tudo.
figura 2 · Transação no sqlx: três escritas em uma conexão, com Commit ou Rollback em defer

A linha de cima é a transação. O pedido e cada item entram na mesma conexão, e o UPDATE do estoque passa pelo CHECK do schema. Quando o CHECK recusa a linha, a função retorna o erro, e o defer chama Rollback, que desfaz o pedido e os itens que já tinham sido gravados. Quando tudo dá certo, o Commit confirma as escritas, e o Rollback que o defer chama depois não faz nada. Ele devolve sql.ErrTxDone, o que o código ignora de propósito.

O código abaixo implementa esse caminho:

// criarPedido grava o pedido e baixa o estoque de cada item, tudo ou nada.
func criarPedido(ctx context.Context, db *sqlx.DB, cliente string, itens []ItemPedido) (int, error) {
	tx, err := db.BeginTxx(ctx, nil)
	if err != nil {
		return 0, fmt.Errorf("abrir transação: %w", err)
	}
	// Se o Commit der certo, este Rollback devolve sql.ErrTxDone e não faz nada.
	// Se qualquer passo falhar e a função retornar, ele desfaz tudo.
	defer tx.Rollback()

	var pedidoID int
	err = tx.GetContext(ctx, &pedidoID,
		"INSERT INTO pedidos (cliente) VALUES (?) RETURNING id", cliente)
	if err != nil {
		return 0, fmt.Errorf("inserir pedido: %w", err)
	}

	for _, item := range itens {
		_, err := tx.ExecContext(ctx,
			"INSERT INTO itens_pedido (pedido_id, produto_id, quantidade) VALUES (?, ?, ?)",
			pedidoID, item.ProdutoID, item.Quantidade)
		if err != nil {
			return 0, fmt.Errorf("inserir item do produto %d: %w", item.ProdutoID, err)
		}

		// Se o estoque ficar negativo, o CHECK do schema recusa o UPDATE.
		_, err = tx.ExecContext(ctx,
			"UPDATE produtos SET estoque = estoque - ? WHERE id = ?",
			item.Quantidade, item.ProdutoID)
		if err != nil {
			return 0, fmt.Errorf("baixar estoque do produto %d: %w", item.ProdutoID, err)
		}
	}

	if err := tx.Commit(); err != nil {
		return 0, fmt.Errorf("confirmar pedido: %w", err)
	}
	return pedidoID, nil
}

O padrão defer tx.Rollback() logo depois do BeginTxx é a parte que mais se copia e a que mais se esquece. Ele cobre todas as saídas da função, inclusive as que você não escreveu pensando em erro, como um return esquecido no meio de um laço. Eu testei o comportamento de Rollback depois de Commit: o retorno foi sql: transaction has already been committed or rolled back, que é o sql.ErrTxDone documentado pelo pacote database/sql.

O contexto também participa da transação. A documentação do BeginTxx diz que, se o contexto for cancelado antes do Commit, o pacote database/sql desfaz a transação, e o Commit devolve erro. O exemplo passa ctx para todas as chamadas, então o cancelamento de quem chamou encerra a operação inteira.

Essa última regra tem consequência prática. Dentro de uma transação, toda operação precisa passar por tx. Quando o pool tem só uma conexão, usar db no meio da transação faz a consulta esperar uma conexão que a própria transação segura. O teste que fiz deixou essa espera até o contexto expirar, com o erro context deadline exceeded depois de 500 ms. Em produção, esse tipo de bloqueio costuma aparecer como requisições penduradas, sem nenhuma mensagem clara no log.

8. Exemplo completo rodado

O programa abaixo é o arquivo exato que rodei. Usei go1.27.2 (darwin/arm64), sqlx v1.4.0 e modernc.org/sqlite v1.61.1. Para rodar, crie um módulo com go mod init e as duas instalações da seção 2, grave o código como main.go e execute go run .. A saída vem logo depois do código.

package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"
	"time"

	"github.com/jmoiron/sqlx"
	_ "modernc.org/sqlite" // driver "sqlite": puro Go, sem cgo
)

const schema = `
CREATE TABLE produtos (
    id        INTEGER PRIMARY KEY,
    nome      TEXT    NOT NULL,
    categoria TEXT    NOT NULL,
    estoque   INTEGER NOT NULL CHECK (estoque >= 0)
);
CREATE TABLE pedidos (
    id      INTEGER PRIMARY KEY,
    cliente TEXT NOT NULL
);
CREATE TABLE itens_pedido (
    pedido_id  INTEGER NOT NULL REFERENCES pedidos(id),
    produto_id INTEGER NOT NULL REFERENCES produtos(id),
    quantidade INTEGER NOT NULL CHECK (quantidade > 0)
);
`

type Produto struct {
	ID        int    `db:"id"`
	Nome      string `db:"nome"`
	Categoria string `db:"categoria"`
	Estoque   int    `db:"estoque"`
}

type FiltroProduto struct {
	Categoria     string `db:"categoria"`
	EstoqueMinimo int    `db:"minimo"`
}

type ItemPedido struct {
	ProdutoID  int `db:"produto_id"`
	Quantidade int `db:"quantidade"`
}

func abrirBanco() (*sqlx.DB, error) {
	db, err := sqlx.Open("sqlite", ":memory:")
	if err != nil {
		return nil, err
	}
	// Cada conexão a ":memory:" enxerga um banco separado e vazio.
	// Com uma conexão só no pool, todas as operações veem os mesmos dados.
	db.SetMaxOpenConns(1)

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	if err := db.PingContext(ctx); err != nil {
		db.Close()
		return nil, err
	}
	if _, err := db.Exec(schema); err != nil {
		db.Close()
		return nil, err
	}
	return db, nil
}

func semearProdutos(db *sqlx.DB) error {
	produtos := []Produto{
		{Nome: "Caneca", Categoria: "casa", Estoque: 5},
		{Nome: "Camiseta", Categoria: "roupa", Estoque: 10},
		{Nome: "Boné", Categoria: "roupa", Estoque: 0},
		{Nome: "Garrafa", Categoria: "casa", Estoque: 3},
	}
	for _, p := range produtos {
		// NamedExec troca :nome, :categoria e :estoque pelos campos do struct,
		// encontrados pela tag db. Não precisa montar a lista de argumentos.
		_, err := db.NamedExec(`INSERT INTO produtos (nome, categoria, estoque)
			VALUES (:nome, :categoria, :estoque)`, p)
		if err != nil {
			return err
		}
	}
	return nil
}

// Get: uma linha só, preenchida em um struct.
func buscarProduto(ctx context.Context, db *sqlx.DB, id int) (Produto, error) {
	var p Produto
	err := db.GetContext(ctx, &p,
		"SELECT id, nome, categoria, estoque FROM produtos WHERE id = ?", id)
	return p, err
}

// Select com In: sqlx.In expande a lista em ?, ?, ?
func buscarProdutosPorIDs(ctx context.Context, db *sqlx.DB, ids []int) ([]Produto, error) {
	query, args, err := sqlx.In(
		"SELECT id, nome, categoria, estoque FROM produtos WHERE id IN (?) ORDER BY id", ids)
	if err != nil {
		return nil, err
	}
	// sqlx.In devolve ?. db.Rebind converte para o formato do driver
	// ($1, $2... no PostgreSQL). No SQLite, ? já é o formato certo.
	query = db.Rebind(query)

	var ps []Produto
	err = db.SelectContext(ctx, &ps, query, args...)
	return ps, err
}

// NamedQuery: os parâmetros :categoria e :minimo vêm do struct filtro.
func listarProdutos(ctx context.Context, db *sqlx.DB, f FiltroProduto) ([]Produto, error) {
	rows, err := db.NamedQueryContext(ctx, `
		SELECT id, nome, categoria, estoque FROM produtos
		WHERE categoria = :categoria AND estoque >= :minimo
		ORDER BY id`, f)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var ps []Produto
	for rows.Next() {
		var p Produto
		if err := rows.StructScan(&p); err != nil {
			return nil, err
		}
		ps = append(ps, p)
	}
	return ps, rows.Err()
}

// criarPedido grava o pedido e baixa o estoque de cada item, tudo ou nada.
func criarPedido(ctx context.Context, db *sqlx.DB, cliente string, itens []ItemPedido) (int, error) {
	tx, err := db.BeginTxx(ctx, nil)
	if err != nil {
		return 0, fmt.Errorf("abrir transação: %w", err)
	}
	// Se o Commit der certo, este Rollback devolve sql.ErrTxDone e não faz nada.
	// Se qualquer passo falhar e a função retornar, ele desfaz tudo.
	defer tx.Rollback()

	var pedidoID int
	err = tx.GetContext(ctx, &pedidoID,
		"INSERT INTO pedidos (cliente) VALUES (?) RETURNING id", cliente)
	if err != nil {
		return 0, fmt.Errorf("inserir pedido: %w", err)
	}

	for _, item := range itens {
		_, err := tx.ExecContext(ctx,
			"INSERT INTO itens_pedido (pedido_id, produto_id, quantidade) VALUES (?, ?, ?)",
			pedidoID, item.ProdutoID, item.Quantidade)
		if err != nil {
			return 0, fmt.Errorf("inserir item do produto %d: %w", item.ProdutoID, err)
		}

		// Se o estoque ficar negativo, o CHECK do schema recusa o UPDATE.
		_, err = tx.ExecContext(ctx,
			"UPDATE produtos SET estoque = estoque - ? WHERE id = ?",
			item.Quantidade, item.ProdutoID)
		if err != nil {
			return 0, fmt.Errorf("baixar estoque do produto %d: %w", item.ProdutoID, err)
		}
	}

	if err := tx.Commit(); err != nil {
		return 0, fmt.Errorf("confirmar pedido: %w", err)
	}
	return pedidoID, nil
}

// NUNCA faça isto: a entrada do usuário vira parte do texto do SQL.
func buscarPorNomeInseguro(db *sqlx.DB, nome string) ([]Produto, error) {
	query := "SELECT id, nome, categoria, estoque FROM produtos WHERE nome = '" + nome + "'"
	var ps []Produto
	err := db.Select(&ps, query)
	return ps, err
}

// Forma correta: o valor vai como parâmetro, separado do texto do SQL.
func buscarPorNomeSeguro(ctx context.Context, db *sqlx.DB, nome string) ([]Produto, error) {
	var ps []Produto
	err := db.SelectContext(ctx, &ps,
		"SELECT id, nome, categoria, estoque FROM produtos WHERE nome = ?", nome)
	return ps, err
}

func main() {
	ctx := context.Background()

	db, err := abrirBanco()
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	if err := semearProdutos(db); err != nil {
		log.Fatal(err)
	}

	fmt.Println("== Get")
	p, err := buscarProduto(ctx, db, 1)
	fmt.Printf("produto 1: %+v (erro: %v)\n", p, err)
	_, err = buscarProduto(ctx, db, 99)
	fmt.Printf("produto 99: erro=%v, é sql.ErrNoRows=%t\n", err, errors.Is(err, sql.ErrNoRows))

	fmt.Println("== Select com In")
	ps, err := buscarProdutosPorIDs(ctx, db, []int{1, 3, 4})
	if err != nil {
		log.Fatal(err)
	}
	for _, p := range ps {
		fmt.Printf("  %+v\n", p)
	}

	fmt.Println("== NamedQuery com StructScan")
	roupas, err := listarProdutos(ctx, db, FiltroProduto{Categoria: "roupa", EstoqueMinimo: 1})
	if err != nil {
		log.Fatal(err)
	}
	for _, p := range roupas {
		fmt.Printf("  %+v\n", p)
	}

	fmt.Println("== Transação que falha no segundo item")
	// A Caneca (produto 1) tem estoque 5. A Garrafa (produto 4) tem 3, e o pedido pede 99.
	itensRuins := []ItemPedido{{ProdutoID: 1, Quantidade: 2}, {ProdutoID: 4, Quantidade: 99}}
	_, err = criarPedido(ctx, db, "Ana", itensRuins)
	fmt.Printf("erro: %v\n", err)

	var pedidos int
	if err := db.GetContext(ctx, &pedidos, "SELECT COUNT(*) FROM pedidos"); err != nil {
		log.Fatal(err)
	}
	var caneca Produto
	if err := db.GetContext(ctx, &caneca, "SELECT id, nome, categoria, estoque FROM produtos WHERE id = 1"); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("pedidos gravados: %d; estoque da Caneca: %d (o rollback desfez o primeiro item)\n", pedidos, caneca.Estoque)

	fmt.Println("== Transação que dá certo")
	itensBons := []ItemPedido{{ProdutoID: 1, Quantidade: 2}, {ProdutoID: 2, Quantidade: 1}}
	id, err := criarPedido(ctx, db, "Bruno", itensBons)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("pedido %d criado\n", id)
	if err := db.GetContext(ctx, &pedidos, "SELECT COUNT(*) FROM pedidos"); err != nil {
		log.Fatal(err)
	}
	if err := db.GetContext(ctx, &caneca, "SELECT id, nome, categoria, estoque FROM produtos WHERE id = 1"); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("pedidos gravados: %d; estoque da Caneca: %d\n", pedidos, caneca.Estoque)

	fmt.Println("== Entrada maliciosa")
	entrada := "x' OR '1'='1"
	inseguros, _ := buscarPorNomeInseguro(db, entrada)
	seguros, _ := buscarPorNomeSeguro(ctx, db, entrada)
	fmt.Printf("concatenado: %d linhas\n", len(inseguros))
	fmt.Printf("parâmetro:   %d linhas\n", len(seguros))
}

Saída real do programa:

== Get
produto 1: {ID:1 Nome:Caneca Categoria:casa Estoque:5} (erro: <nil>)
produto 99: erro=sql: no rows in result set, é sql.ErrNoRows=true
== Select com In
  {ID:1 Nome:Caneca Categoria:casa Estoque:5}
  {ID:3 Nome:Boné Categoria:roupa Estoque:0}
  {ID:4 Nome:Garrafa Categoria:casa Estoque:3}
== NamedQuery com StructScan
  {ID:2 Nome:Camiseta Categoria:roupa Estoque:10}
== Transação que falha no segundo item
erro: baixar estoque do produto 4: constraint failed: CHECK constraint failed: estoque >= 0 (275)
pedidos gravados: 0; estoque da Caneca: 5 (o rollback desfez o primeiro item)
== Transação que dá certo
pedido 1 criado
pedidos gravados: 1; estoque da Caneca: 3
== Entrada maliciosa
concatenado: 4 linhas
parâmetro:   0 linhas

A saída mostra três coisas que as seções anteriores prometeram. A primeira é a transação que falhou: o pedido da Ana não ficou gravado, e a Caneca continua com estoque 5, o que prova que o primeiro item também foi desfeito. A segunda é a transação que deu certo, com o pedido 1 e o estoque da Caneca baixando de 5 para 3. A terceira são as duas últimas linhas, que mostram a diferença entre concatenar a entrada e passá-la como parâmetro. A seção seguinte explica essa última parte.

9. Cuidado com SQL montado por interpolação

Interpolar é montar o SQL colando um valor de fora dentro do texto da consulta. Com uma entrada comum, como Caneca, o resultado parece correto. Com uma entrada escrita por alguém querendo mexer na consulta, o texto final muda de significado. A função insegura do exemplo faz isso:

// NUNCA faça isto: a entrada do usuário vira parte do texto do SQL.
func buscarPorNomeInseguro(db *sqlx.DB, nome string) ([]Produto, error) {
	query := "SELECT id, nome, categoria, estoque FROM produtos WHERE nome = '" + nome + "'"
	var ps []Produto
	err := db.Select(&ps, query)
	return ps, err
}

A figura compara a mesma entrada nas duas formas:

Entrada maliciosa concatenada ao SQL devolve todos os produtos; a mesma entrada passada como parâmetro não casa com nenhum nome.
figura 3 · Entrada maliciosa: consulta concatenada vaza todos os produtos, parâmetro não ca

Na forma insegura, o banco recebe um texto só, e não consegue distinguir a parte que você escreveu da parte que veio do usuário. A aspa da entrada fecha o literal, o OR '1'='1' vira uma condição que sempre é verdadeira, e a consulta devolve os quatro produtos. Na forma segura, o texto do SQL é fixo, e o valor viaja separado, como dado. O banco nunca interpreta a entrada como comando, então nenhum produto tem o nome literal x' OR '1'='1, e a consulta devolve zero linhas. No teste, a saída foi concatenado: 4 linhas e parâmetro: 0 linhas.

A recomendação da OWASP vai na mesma direção. A folha de referência sobre prevenção de SQL injection pede que a estrutura da consulta seja definida separadamente dos valores, e que os valores sejam ligados pela API do banco. Ela também diz que montar a consulta com valores não confiáveis antes de prepará-la anula essa proteção. Ou seja, não adianta escapar a string à mão e achar que está seguro: a forma correta é deixar o driver fazer o trabalho.

Há um limite importante. O parâmetro serve para valores, não para nomes de tabela ou de coluna. Se a aplicação precisar escolher a coluna de ordenação a partir de uma entrada, a lista de colunas permitidas tem de estar fixada no código ou validada contra um conjunto conhecido, e a entrada só escolhe um item dessa lista.

10. Quando usar sqlx, sqlc ou pgx puro

O sqlx é uma boa escolha quando você quer manter o SQL escrito à mão e tirar do caminho o trabalho repetitivo de preencher structs. Ele continua usando a interface database/sql, então qualquer código que já a use pode migrar aos poucos. O custo é que a checagem entre consulta e struct acontece em tempo de execução. O erro missing destination name que vimos na seção 3 aparece quando a consulta roda, e não quando o código compila.

O sqlc vai por outro caminho. Ele lê arquivos .sql e gera código Go tipado a partir deles, de modo que a incompatibilidade entre consulta e schema aparece na geração do código, antes de o programa rodar. A aula SQLC - Type-Safe SQL mostra esse fluxo. Se a equipe quer o máximo de verificação antes do deploy e aceita um passo de geração no build, o sqlc costuma ser a escolha mais segura. Não medi a diferença de desempenho entre as duas abordagens nesta aula, então não vou afirmar um número.

O pgx puro, usado com a interface própria do driver, dá acesso a recursos específicos do PostgreSQL, como lotes de comandos e tipos que o database/sql não representa bem. A aula PostgreSQL com pgx cobre esse caminho. Quando você precisa desses recursos, ou quando o desempenho do PostgreSQL é um requisito real do projeto, usar o pgx direto costuma compensar a perda de conveniência. Se o projeto só precisa de consultas comuns e quer a escrita do dia a dia enxuta, o sqlx basta.

11. Erros comuns e boas práticas

O erro mais frequente é concatenar a entrada do usuário no SQL. Ele já foi discutido na seção 9, e vale repetir o motivo: a consulta deixa de ser a que você escreveu. Quando isso acontece com dados sensíveis, o problema deixa de ser de estilo.

O segundo erro é tratar sql.ErrNoRows como falha do banco. Quando a linha não existe, a resposta correta da aplicação costuma ser 404, e não 500. Separe os dois casos com errors.Is e deixe o erro de conexão seguir como erro de verdade.

O terceiro é esquecer o defer tx.Rollback(), ou colocar o Rollback só no caminho de erro explícito. Sem o defer, um return esquecido no meio de uma função longa deixa a transação aberta, com as linhas travadas e a conexão presa no pool. A regra simples é colocar o defer logo depois do BeginTxx, sempre.

O quarto é usar db dentro de uma transação, como vimos na seção 7. Com o pool no limite, a consulta espera uma conexão que nunca será liberada, enquanto a transação espera a consulta. Passe tx para todas as operações que pertencem à transação.

O quinto é esquecer o Rebind ao trocar de driver. Uma consulta com ? enviada ao PostgreSQL chega com um marcador que o servidor não reconhece, e a falha só aparece na primeira execução real. Como o Rebind não muda nada no SQLite, use-o desde o começo, mesmo quando o driver de hoje não precisa dele.

O sexto é usar Unsafe() para calar um erro de mapeamento. Ele funciona, mas esconde a coluna que você esqueceu de declarar. Quando a consulta muda, o struct fica para trás e o problema some do log. Deixe o aviso ligado e corrija a consulta ou o struct.

O sétimo é usar :memory: com um pool de conexões maior que um. Cada conexão a :memory: abre um banco separado. O teste com duas conexões mostrou no such table: t na segunda conexão, porque ela enxergava um banco vazio. A documentação do SQLite diz que cada banco :memory: é distinto dos outros. Por isso o exemplo usa SetMaxOpenConns(1), e a saída mostra que a tabela criada na primeira conexão continua lá para as operações seguintes.

12. Exercícios com resposta de referência

Faça os exercícios em um módulo separado, com o mesmo schema do exemplo. As respostas abaixo são referências, e não as rodei separadamente.

  1. Escreva buscarProdutosPorCategorias(ctx context.Context, db *sqlx.DB, categorias []string) ([]Produto, error), que devolve os produtos cujas categorias estão na lista. Resposta de referência: use sqlx.In sobre WHERE categoria IN (?), passe o resultado por db.Rebind e chame db.SelectContext. Antes de tudo, retorne um slice vazio se categorias estiver vazia, porque o sqlx.In recusa a lista vazia.

  2. Escreva transferirUnidade(ctx context.Context, db *sqlx.DB, origemID, destinoID int) error, que tira uma unidade de estoque de um produto e põe no outro, em uma única transação. Resposta de referência: abra a transação com BeginTxx, coloque defer tx.Rollback() logo em seguida, faça dois UPDATE com tx.ExecContext e termine com tx.Commit(). Se o estoque da origem for zero, o CHECK recusa o primeiro UPDATE, e o Rollback desfaz tudo. Teste com um produto com estoque 0 e confira que o destino não mudou.

  3. Pegue a função buscarPorNomeInseguro e troque a concatenação por um parâmetro. Explique, sem rodar, por que o texto do SQL deixa de mudar com a entrada e por que a consulta com x' OR '1'='1 passa a devolver zero linhas. Resposta de referência: o valor vira um argumento separado, que o driver trata como dado. O texto do SQL fica idêntico para qualquer entrada, então a aspa não fecha literal nenhum.

13. O que não foi confirmado

Algumas afirmações desta aula ficaram fora do que consegui verificar, e prefiro declarar isso aqui a deixar uma frase sem fonte:

  • O exemplo rodou só com SQLite em memória. O Docker estava parado, então não rodei nada contra um servidor PostgreSQL. O BindType de pgx e o Rebind para $1 foram verificados como funções de texto, sem conexão.
  • Não medi desempenho. A comparação entre sqlx, sqlc e pgx puro é qualitativa, baseada na documentação de cada projeto.
  • A manutenção do sqlx não foi avaliada além da data da última versão. A v1.4.0 é de abril de 2024, e não encontrei release posterior em 2026-11-03. Não verifiquei a atividade recente do repositório.
  • As mensagens de erro citadas (CHECK constraint failed, missing destination name, could not find name) são as da versão de driver e de biblioteca desta aula. Elas podem mudar em outras versões.
  • As respostas dos exercícios da seção 12 não foram rodadas.

14. Glossário

  • Tag db: texto entre crases depois de um campo de struct, que diz a qual coluna o campo corresponde.
  • NameMapper: função que converte o nome de um campo sem tag no nome da coluna. Por padrão é strings.ToLower.
  • StructScan: preenche um struct com a linha atual de um Rows.
  • Consulta nomeada: consulta que usa :nome no lugar de ?, com valores vindos de um struct ou map.
  • sqlx.In: expande um slice em uma lista de marcadores ?, um para cada valor.
  • Rebind: converte o marcador ? para o formato que o driver espera, como $1 no PostgreSQL.
  • Transação: grupo de operações que são confirmadas juntas ou desfeitas juntas.
  • Commit e Rollback: confirmam ou desfazem a transação. Depois de um Commit, o Rollback devolve sql.ErrTxDone.
  • sql.ErrNoRows: erro devolvido quando uma consulta de uma linha não encontra nada.
  • Interpolação: montar o texto do SQL colando valores de fora, em vez de passá-los como parâmetros.
  • SetMaxOpenConns: limita quantas conexões o pool pode abrir ao mesmo tempo.

15. Fontes

Consultadas em 2026-11-03:

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?