96/100 Dias de Golang - SQL sem ORM com database/sql e sqlc
Table of Contents
#
SQL sem ORM com database/sql e sqlc
Até agora, sempre que precisamos de banco de dados usamos o GORM (dia 31 e no Mini CI/CD do dia 78). O GORM é prático: definimos structs, chamamos db.Create, db.Find e ele escreve o SQL por nós. Mas isso tem um preço: o SQL fica escondido, queries mais complexas viram uma sequência de métodos encadeados difícil de ler, e às vezes o ORM gera algo bem diferente do que imaginávamos.
Na comunidade Go é muito comum o caminho oposto: escrever SQL de verdade. Hoje vamos ver duas formas de fazer isso: o pacote database/sql da biblioteca padrão e o sqlc, uma ferramenta que gera código Go a partir de arquivos .sql.
#
Preparando o banco
Vamos usar PostgreSQL com Docker:
docker run -d --name loja-db -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=loja -p 5432:5432 postgres:17
E o schema, escrito como uma migração do Goose, que vimos no dia 87. O arquivo db/migrations/00001_criar_tabelas.sql:
-- +goose Up
CREATE TABLE usuarios (
id BIGSERIAL PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE pedidos (
id BIGSERIAL PRIMARY KEY,
usuario_id BIGINT NOT NULL REFERENCES usuarios(id),
valor_centavos BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pendente'
);
-- +goose Down
DROP TABLE pedidos;
DROP TABLE usuarios;
Aplicando com o Goose:
goose -dir ./db/migrations postgres "postgres://postgres:postgres@localhost:5432/loja?sslmode=disable" up
#
database/sql
O pacote database/sql define uma interface comum para bancos relacionais. Ele não sabe falar com nenhum banco específico: para isso precisamos de um driver. Para PostgreSQL, o mais usado é o pgx:
go get github.com/jackc/pgx/v5@v5.7.6
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
type Usuario struct {
ID int64
Nome string
Email string
CriadoEm time.Time
}
func criarUsuario(ctx context.Context, db *sql.DB, nome, email string) (Usuario, error) {
var u Usuario
err := db.QueryRowContext(ctx,
`INSERT INTO usuarios (nome, email) VALUES ($1, $2)
RETURNING id, nome, email, criado_em`,
nome, email,
).Scan(&u.ID, &u.Nome, &u.Email, &u.CriadoEm)
return u, err
}
func buscarUsuario(ctx context.Context, db *sql.DB, id int64) (Usuario, error) {
var u Usuario
err := db.QueryRowContext(ctx,
`SELECT id, nome, email, criado_em FROM usuarios WHERE id = $1`, id,
).Scan(&u.ID, &u.Nome, &u.Email, &u.CriadoEm)
return u, err
}
func listarUsuarios(ctx context.Context, db *sql.DB) ([]Usuario, error) {
rows, err := db.QueryContext(ctx, `SELECT id, nome, email, criado_em FROM usuarios ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var usuarios []Usuario
for rows.Next() {
var u Usuario
if err := rows.Scan(&u.ID, &u.Nome, &u.Email, &u.CriadoEm); err != nil {
return nil, err
}
usuarios = append(usuarios, u)
}
return usuarios, rows.Err()
}
func main() {
ctx := context.Background()
db, err := sql.Open("pgx", "postgres://postgres:postgres@localhost:5432/loja")
if err != nil {
log.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
if err := db.PingContext(ctx); err != nil {
log.Fatal("banco indisponível: ", err)
}
for _, u := range []struct{ nome, email string }{
{"Victor", "victor@exemplo.com"},
{"Ana", "ana@exemplo.com"},
} {
criado, err := criarUsuario(ctx, db, u.nome, u.email)
if err != nil {
log.Fatal(err)
}
fmt.Printf("criado: id=%d nome=%s\n", criado.ID, criado.Nome)
}
usuarios, err := listarUsuarios(ctx, db)
if err != nil {
log.Fatal(err)
}
fmt.Println("total de usuários:", len(usuarios))
_, err = buscarUsuario(ctx, db, 999)
if errors.Is(err, sql.ErrNoRows) {
fmt.Println("usuário 999 não encontrado")
}
_, err = criarUsuario(ctx, db, "Outro Victor", "victor@exemplo.com")
fmt.Println("erro ao repetir email:", err)
}
Saída:
criado: id=1 nome=Victor
criado: id=2 nome=Ana
total de usuários: 2
usuário 999 não encontrado
erro ao repetir email: ERROR: duplicate key value violates unique constraint "usuarios_email_key" (SQLSTATE 23505)
Vamos por partes.
O driver. O _ "github.com/jackc/pgx/v5/stdlib" importa o pacote só pelo efeito colateral, igual fizemos com o net/http/pprof no dia 92: ao ser carregado, ele registra o driver "pgx" no database/sql. Daí em diante usamos só a API padrão.
sql.Open não conecta. Ele só valida os argumentos e prepara o objeto. A primeira conexão acontece quando ela é necessária, por isso o PingContext logo no início: se o banco estiver fora, descobrimos na hora em vez de na primeira requisição.
*sql.DB é um pool de conexões, e não uma conexão única. Ele deve ser criado uma vez e compartilhado pela aplicação inteira (é seguro usar de várias goroutines). O SetMaxOpenConns limita quantas conexões abertas o pool pode ter, o SetMaxIdleConns quantas ficam paradas esperando e o SetConnMaxLifetime recicla conexões antigas.
Uma linha ou várias. QueryRowContext + Scan para uma linha. QueryContext para várias, percorrendo com rows.Next(). Três detalhes que é fácil esquecer:
defer rows.Close(): sem ele, a conexão não volta para o pool;rows.Err()depois do loop: orows.Next()retornafalsetanto quando as linhas acabam quanto quando dá erro no meio do caminho. Só orows.Err()diferencia os dois casos;- A ordem das variáveis no
Scanprecisa bater exatamente com a ordem das colunas noSELECT.
sql.ErrNoRows: quando o QueryRow não encontra nada, o Scan retorna esse erro. Usamos errors.Is para tratar como “não encontrado” em vez de erro genérico.
Sempre o context: todas as funções têm uma versão ...Context. Se a requisição HTTP for cancelada, a query é cancelada no banco também, como vimos no dia 89.
##
Placeholders e SQL injection
Repare que os valores nunca são colocados direto na string do SQL. Usamos $1, $2 (no PostgreSQL; no MySQL e SQLite é ?) e passamos os valores separados. Assim o banco recebe a query e os dados separadamente, e nunca confunde um com o outro.
Nunca faça isso:
// NUNCA: SQL injection
query := fmt.Sprintf("SELECT * FROM usuarios WHERE email = '%s'", email)
Se alguém enviar como email ' OR '1'='1, a query vira WHERE email = '' OR '1'='1' e retorna todos os usuários. Com placeholders, esse texto é tratado só como um email esquisito.
##
O problema
O database/sql funciona muito bem e não esconde nada. Mas olhando o código, dá para ver o incômodo: as três funções repetem a lista de colunas e o Scan campo por campo. Com 30 tabelas e 150 queries, isso vira muito código repetitivo. E, pior, os erros só aparecem em tempo de execução: um nome de coluna errado, um Scan com variáveis a menos ou fora de ordem… tudo compila normalmente e só explode quando a query roda.
#
sqlc
O sqlc resolve exatamente isso. A ideia é inversa à do ORM: em vez de escrever Go e gerar SQL, escrevemos SQL e ele gera Go. Ele lê o schema do banco e as queries, valida tudo e gera funções Go com tipos corretos, fazendo os Scan por nós.
Instalando:
go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
##
Configuração
Um arquivo sqlc.yaml na raiz do projeto:
version: "2"
sql:
- engine: "postgresql"
schema: "db/migrations"
queries: "db/queries"
gen:
go:
package: "db"
out: "internal/db"
emit_json_tags: true
schemaaponta para a pasta das migrações. O sqlc entende o formato do Goose: ele lê só a parte-- +goose Upe ignora oDown. Ou seja, a mesma migração serve para criar o banco e para o sqlc conhecer as tabelas;queriesé onde ficam os nossos arquivos.sql;outé onde o código Go vai ser gerado;emit_json_tagsadiciona tagsjsonnas structs (útil para devolver direto na API).
##
Escrevendo as queries
db/queries/usuarios.sql:
-- name: CriarUsuario :one
INSERT INTO usuarios (nome, email)
VALUES ($1, $2)
RETURNING *;
-- name: BuscarUsuario :one
SELECT * FROM usuarios
WHERE id = $1;
-- name: ListarUsuarios :many
SELECT * FROM usuarios
ORDER BY id;
-- name: BuscarUsuariosPorNome :many
SELECT * FROM usuarios
WHERE nome ILIKE '%' || sqlc.arg(busca)::text || '%'
ORDER BY nome;
db/queries/pedidos.sql:
-- name: CriarPedido :one
INSERT INTO pedidos (usuario_id, valor_centavos)
VALUES ($1, $2)
RETURNING *;
-- name: AtualizarStatusPedido :execrows
UPDATE pedidos SET status = $2
WHERE id = $1;
-- name: TotalPorUsuario :many
SELECT u.nome,
count(p.id) AS qtd_pedidos,
coalesce(sum(p.valor_centavos), 0)::bigint AS total_centavos
FROM usuarios u
LEFT JOIN pedidos p ON p.usuario_id = u.id
GROUP BY u.id, u.nome
ORDER BY total_centavos DESC;
É SQL normal, com um comentário em cima de cada query dizendo o nome da função que vai ser gerada e o que ela retorna:
:one: uma linha (retorna a struct);:many: várias linhas (retorna um slice);:exec: não retorna nada além do erro;:execrows: retorna quantas linhas foram afetadas.
O sqlc.arg(busca) dá um nome ao parâmetro. Sem ele, o sqlc tenta adivinhar o nome pela coluna (no WHERE id = $1, o parâmetro vira id). Numa expressão como essa ele não tem de onde tirar o nome, e a função seria gerada como BuscarUsuariosPorNome(ctx, dollar_1 string).
##
Gerando o código
sqlc generate
São criados quatro arquivos em internal/db. O models.go tem uma struct para cada tabela, com os tipos convertidos do schema:
type Pedido struct {
ID int64 `json:"id"`
UsuarioID int64 `json:"usuario_id"`
ValorCentavos int64 `json:"valor_centavos"`
Status string `json:"status"`
}
type Usuario struct {
ID int64 `json:"id"`
Nome string `json:"nome"`
Email string `json:"email"`
CriadoEm time.Time `json:"criado_em"`
}
E cada query vira um método. Olha o CriarUsuario gerado:
const criarUsuario = `-- name: CriarUsuario :one
INSERT INTO usuarios (nome, email)
VALUES ($1, $2)
RETURNING id, nome, email, criado_em
`
type CriarUsuarioParams struct {
Nome string `json:"nome"`
Email string `json:"email"`
}
func (q *Queries) CriarUsuario(ctx context.Context, arg CriarUsuarioParams) (Usuario, error) {
row := q.db.QueryRowContext(ctx, criarUsuario, arg.Nome, arg.Email)
var i Usuario
err := row.Scan(
&i.ID,
&i.Nome,
&i.Email,
&i.CriadoEm,
)
return i, err
}
É exatamente o código que escrevemos à mão com o database/sql! O RETURNING * foi expandido para a lista de colunas, os parâmetros viraram uma struct CriarUsuarioParams e o Scan está na ordem certa. Sem mágica e sem reflection em tempo de execução: é só código Go comum, que dá para ler e debugar.
Para queries que não retornam uma tabela inteira, como o TotalPorUsuario, ele cria uma struct só para o resultado, com os tipos deduzidos do SQL:
type TotalPorUsuarioRow struct {
Nome string `json:"nome"`
QtdPedidos int64 `json:"qtd_pedidos"`
TotalCentavos int64 `json:"total_centavos"`
}
O ::bigint no SQL é importante: sem ele o sum retornaria um numeric e o sqlc geraria um tipo menos conveniente.
E o db.go define a interface DBTX, que tanto o *sql.DB quanto o *sql.Tx implementam. É isso que permite usar as mesmas queries dentro e fora de transações:
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
Nos testes, essa interface também facilita trocar o banco por um mock, como vimos nos dias 61 e 62.
##
Usando o código gerado
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib"
"loja/internal/db"
)
var ErrEmailEmUso = errors.New("email já cadastrado")
func cadastrar(ctx context.Context, q *db.Queries, nome, email string) (db.Usuario, error) {
u, err := q.CriarUsuario(ctx, db.CriarUsuarioParams{Nome: nome, Email: email})
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation
return db.Usuario{}, ErrEmailEmUso
}
return u, err
}
// criarUsuarioComPedido cria o usuário e o primeiro pedido na mesma transação
func criarUsuarioComPedido(ctx context.Context, conn *sql.DB, nome, email string, valor int64) error {
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // não faz nada se o Commit já tiver acontecido
q := db.New(conn).WithTx(tx)
u, err := q.CriarUsuario(ctx, db.CriarUsuarioParams{Nome: nome, Email: email})
if err != nil {
return fmt.Errorf("criando usuário: %w", err)
}
if valor <= 0 {
return errors.New("valor do pedido inválido")
}
if _, err := q.CriarPedido(ctx, db.CriarPedidoParams{UsuarioID: u.ID, ValorCentavos: valor}); err != nil {
return fmt.Errorf("criando pedido: %w", err)
}
return tx.Commit()
}
func main() {
ctx := context.Background()
conn, err := sql.Open("pgx", "postgres://postgres:postgres@localhost:5432/loja")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
q := db.New(conn)
victor, err := cadastrar(ctx, q, "Victor", "victor@exemplo.com")
if err != nil {
log.Fatal(err)
}
ana, err := cadastrar(ctx, q, "Ana", "ana@exemplo.com")
if err != nil {
log.Fatal(err)
}
fmt.Printf("criados: %+v\n", victor)
_, err = cadastrar(ctx, q, "Outro Victor", "victor@exemplo.com")
fmt.Println("repetindo email:", err)
for _, p := range []db.CriarPedidoParams{
{UsuarioID: victor.ID, ValorCentavos: 15990},
{UsuarioID: victor.ID, ValorCentavos: 4500},
{UsuarioID: ana.ID, ValorCentavos: 89900},
} {
if _, err := q.CriarPedido(ctx, p); err != nil {
log.Fatal(err)
}
}
linhas, err := q.AtualizarStatusPedido(ctx, db.AtualizarStatusPedidoParams{ID: 1, Status: "pago"})
fmt.Println("pedidos atualizados:", linhas, err)
if err := criarUsuarioComPedido(ctx, conn, "Bruno", "bruno@exemplo.com", 0); err != nil {
fmt.Println("transação desfeita:", err)
}
if err := criarUsuarioComPedido(ctx, conn, "Carla", "carla@exemplo.com", 12000); err != nil {
log.Fatal(err)
}
encontrados, _ := q.BuscarUsuariosPorNome(ctx, "bru")
fmt.Println("usuários com 'bru' no nome:", len(encontrados))
totais, err := q.TotalPorUsuario(ctx)
if err != nil {
log.Fatal(err)
}
for _, t := range totais {
fmt.Printf("%-7s pedidos=%d total=R$ %.2f\n", t.Nome, t.QtdPedidos, float64(t.TotalCentavos)/100)
}
_, err = q.BuscarUsuario(ctx, 999)
if errors.Is(err, sql.ErrNoRows) {
fmt.Println("usuário 999 não encontrado")
}
}
Saída (com as tabelas vazias):
criados: {ID:1 Nome:Victor Email:victor@exemplo.com CriadoEm:2025-12-13 10:00:00.924541 -0300 -03}
repetindo email: email já cadastrado
pedidos atualizados: 1 <nil>
transação desfeita: valor do pedido inválido
usuários com 'bru' no nome: 0
Ana pedidos=1 total=R$ 899.00
Victor pedidos=2 total=R$ 204.90
Carla pedidos=1 total=R$ 120.00
usuário 999 não encontrado
O código ficou bem mais limpo: nada de Scan, nada de lista de colunas, e tudo tipado. Alguns pontos que valem destacar:
Tratando erros do banco. O erro de email duplicado vem do PostgreSQL com o código 23505 (unique_violation). Com errors.As pegamos o *pgconn.PgError de dentro do erro e o transformamos num erro da nossa aplicação, o ErrEmailEmUso. Assim quem chama o cadastrar não precisa conhecer códigos do PostgreSQL. É o padrão de erros sentinela do dia 35.
Transações. O criarUsuarioComPedido cria o usuário e o pedido juntos: ou os dois são salvos, ou nenhum. O truque é o defer tx.Rollback() logo depois do BeginTx: se a função retornar por qualquer erro, a transação é desfeita; se chegar no Commit, o Rollback depois dele não faz nada. O WithTx faz as queries geradas rodarem dentro da transação.
O Bruno tinha um pedido de valor 0, então o usuário dele chegou a ser inserido, mas a transação foi desfeita. Por isso a busca por “bru” não encontrou ninguém. Consultando a tabela direto no banco:
id | nome
----+--------
1 | Victor
2 | Ana
5 | Carla
Repare que a Carla ficou com o id 5, e não 3. As sequences do PostgreSQL (o que está por trás do BIGSERIAL) não voltam atrás em rollback ou em erro: o id 3 foi consumido pela tentativa de cadastrar o email repetido, e o id 4 pelo Bruno. Buracos nos IDs são normais e não indicam problema nenhum.
##
Erros em tempo de geração
A maior vantagem do sqlc é validar as queries antes do programa rodar. Se eu digitar errado o nome de uma coluna:
-- name: BuscarUsuario :one
SELECT * FROM usuarios
WHERE emial = $1;
$ sqlc generate
# package db
db/queries/usuarios.sql:8:7: column "emial" does not exist
Com o database/sql puro, esse erro só apareceria quando alguém chamasse essa função em produção. E se uma migração renomear uma coluna, o sqlc generate aponta todas as queries que quebraram, e o compilador do Go aponta todo o código que usava o campo antigo.
Uma prática comum é rodar o sqlc generate no CI e falhar o build se o código gerado estiver diferente do que foi commitado, ou rodar o sqlc diff, que faz exatamente essa verificação.
#
GORM ou sqlc?
- GORM: produtividade em CRUDs simples, associações automáticas,
AutoMigrate. Bom para protótipos e quando o time não quer escrever SQL; - database/sql puro: zero dependências além do driver. Bom para poucas queries ou queries montadas dinamicamente;
- sqlc: SQL explícito com segurança de tipos e validação antes de rodar. Fica melhor conforme as queries ficam mais complexas.
O ponto fraco do sqlc são as queries dinâmicas, como uma tela de busca com filtros opcionais, em que o WHERE muda dependendo do que o usuário preencheu. Para esses casos dá para usar truques no SQL (WHERE ($1::text IS NULL OR nome = $1)) ou um query builder como o squirrel só naquela parte.
Também existe a opção de usar o pgx direto, sem o database/sql: ele tem uma API própria, um pouco mais rápida e com mais recursos do PostgreSQL. O sqlc suporta isso com sql_package: "pgx/v5" no sqlc.yaml. Fiquei com o database/sql aqui porque é a interface padrão, que funciona com qualquer banco.