Go oferece o pacote database/sql como interface universal para bancos de dados relacionais. Ele define um conjunto de tipos e métodos que funcionam da mesma forma independentemente do banco usado — MySQL, PostgreSQL, SQLite ou qualquer outro que tenha um driver compatível.
Essa separação entre interface e implementação é elegante: o código da aplicação interage apenas com database/sql, enquanto o driver específico do banco fica isolado em uma dependência registrada via init(). Trocar de banco de dados, em teoria, exige mudar apenas o driver e o DSN de conexão.
Arquitetura: interface e drivers
Aplicação Go
│
▼
database/sql ← interface unificada (biblioteca padrão)
│
▼
Driver ← implementação específica (dependência externa)
│
▼
Banco de Dados ← MySQL, PostgreSQL, SQLite, etc.
Os drivers mais usados:
| Banco | Driver | Módulo |
|---|---|---|
| MySQL / MariaDB | go-sql-driver/mysql | github.com/go-sql-driver/mysql |
| PostgreSQL | lib/pq (em modo de manutenção; o próprio projeto recomenda o pgx) | github.com/lib/pq |
| PostgreSQL | pgx | github.com/jackc/pgx/v5/stdlib |
| SQLite | mattn/go-sqlite3 | github.com/mattn/go-sqlite3 |
| SQLite (puro Go) | modernc/sqlite | modernc.org/sqlite |
Registrando um driver
Drivers se registram automaticamente através de seu init() quando importados com _:
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql" // registra o driver MySQL
)
func main() {
// DSN: usuario:senha@protocolo(host:porta)/banco?parametros
dsn := "root:senha@tcp(localhost:3306)/minha_aplicacao?parseTime=true&charset=utf8mb4"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("Erro ao abrir conexão:", err)
}
defer db.Close()
// sql.Open NÃO estabelece conexão — apenas valida o DSN
// Ping verifica a conexão real
if err := db.Ping(); err != nil {
log.Fatal("Banco inacessível:", err)
}
fmt.Println("Conexão estabelecida com sucesso")
}
O pool de conexões
sql.DB não é uma única conexão — é um pool de conexões gerenciado automaticamente. Configurar o pool corretamente é essencial para performance e estabilidade:
func configurarPool(db *sql.DB) {
// Máximo de conexões abertas simultâneas
// Padrão: 0 (ilimitado) — sempre configure explicitamente
db.SetMaxOpenConns(25)
// Máximo de conexões ociosas no pool
// Padrão: 2 — aumentar reduz overhead de criação de conexões
db.SetMaxIdleConns(10)
// Tempo máximo de vida de uma conexão
// Útil quando o banco tem timeout de conexão ociosa
db.SetConnMaxLifetime(5 * time.Minute)
// Tempo máximo que uma conexão ociosa pode ficar no pool
db.SetConnMaxIdleTime(2 * time.Minute)
}
Valores recomendados dependem da carga, mas um ponto de partida razoável para um serviço web:
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(2 * time.Minute)
Inicialização completa e verificada
Uma função de inicialização robusta para qualquer banco:
package main
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/go-sql-driver/mysql"
)
type ConfigDB struct {
Driver string
DSN string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
}
func abrirBancoDados(config ConfigDB) (*sql.DB, error) {
db, err := sql.Open(config.Driver, config.DSN)
if err != nil {
return nil, fmt.Errorf("abrir banco: %w", err)
}
db.SetMaxOpenConns(config.MaxOpenConns)
db.SetMaxIdleConns(config.MaxIdleConns)
db.SetConnMaxLifetime(config.ConnMaxLifetime)
db.SetConnMaxIdleTime(config.ConnMaxIdleTime)
// Verifica conectividade com timeout
ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelar()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("verificar conexão: %w", err)
}
return db, nil
}
func main() {
config := ConfigDB{
Driver: "mysql",
DSN: "root:senha@tcp(localhost:3306)/app?parseTime=true",
MaxOpenConns: 25,
MaxIdleConns: 10,
ConnMaxLifetime: 5 * time.Minute,
ConnMaxIdleTime: 2 * time.Minute,
}
db, err := abrirBancoDados(config)
if err != nil {
fmt.Println("Erro:", err)
return
}
defer db.Close()
fmt.Println("Banco de dados pronto")
}
QueryRow: buscando um único registro
type Usuario struct {
ID int
Nome string
Email string
CriadoEm time.Time
}
func buscarUsuarioPorID(ctx context.Context, db *sql.DB, id int) (*Usuario, error) {
query := `
SELECT id, nome, email, criado_em
FROM usuarios
WHERE id = ?
`
var u Usuario
err := db.QueryRowContext(ctx, query, id).Scan(
&u.ID,
&u.Nome,
&u.Email,
&u.CriadoEm,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("usuário %d não encontrado", id)
}
if err != nil {
return nil, fmt.Errorf("buscarUsuarioPorID: %w", err)
}
return &u, nil
}
sql.ErrNoRows é o erro retornado quando nenhuma linha é encontrada — é uma condição esperada, não um erro de banco de dados. Sempre trate-o separadamente.
Query: buscando múltiplos registros
func listarUsuariosAtivos(ctx context.Context, db *sql.DB, limite int) ([]Usuario, error) {
query := `
SELECT id, nome, email, criado_em
FROM usuarios
WHERE ativo = true
ORDER BY nome
LIMIT ?
`
rows, err := db.QueryContext(ctx, query, limite)
if err != nil {
return nil, fmt.Errorf("listarUsuarios: %w", err)
}
defer rows.Close() // CRÍTICO: sempre fechar rows
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, fmt.Errorf("listarUsuarios scan: %w", err)
}
usuarios = append(usuarios, u)
}
// Verificar erro após iteração — pode indicar problema de rede ou cursor
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("listarUsuarios rows: %w", err)
}
return usuarios, nil
}
O defer rows.Close() é obrigatório — Rows ocupa uma conexão do pool enquanto não for fechado. Esquecer o Close em código de alta carga esgota o pool.
Exec: inserindo, atualizando e deletando
func criarUsuario(ctx context.Context, db *sql.DB, nome, email, senhaHash string) (int64, error) {
query := `
INSERT INTO usuarios (nome, email, senha_hash, ativo, criado_em)
VALUES (?, ?, ?, true, NOW())
`
resultado, err := db.ExecContext(ctx, query, nome, email, senhaHash)
if err != nil {
return 0, fmt.Errorf("criarUsuario: %w", err)
}
// ID gerado pelo banco para o novo registro
id, err := resultado.LastInsertId()
if err != nil {
return 0, fmt.Errorf("obter ID inserido: %w", err)
}
return id, nil
}
func atualizarEmail(ctx context.Context, db *sql.DB, id int, novoEmail string) error {
query := `UPDATE usuarios SET email = ?, atualizado_em = NOW() WHERE id = ?`
resultado, err := db.ExecContext(ctx, query, novoEmail, id)
if err != nil {
return fmt.Errorf("atualizarEmail: %w", err)
}
linhasAfetadas, err := resultado.RowsAffected()
if err != nil {
return fmt.Errorf("verificar linhas afetadas: %w", err)
}
if linhasAfetadas == 0 {
return fmt.Errorf("usuário %d não encontrado", id)
}
return nil
}
func deletarUsuario(ctx context.Context, db *sql.DB, id int) error {
resultado, err := db.ExecContext(ctx,
"DELETE FROM usuarios WHERE id = ?", id)
if err != nil {
return fmt.Errorf("deletarUsuario: %w", err)
}
if n, _ := resultado.RowsAffected(); n == 0 {
return fmt.Errorf("usuário %d não encontrado", id)
}
return nil
}
Transações
Transações garantem que um conjunto de operações seja executado atomicamente — ou todas têm sucesso, ou nenhuma persiste:
func transferirSaldo(
ctx context.Context,
db *sql.DB,
contaOrigemID,
contaDestinoID int,
valor float64,
) error {
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
ReadOnly: false,
})
if err != nil {
return fmt.Errorf("iniciar transação: %w", err)
}
// Garante rollback em caso de erro ou pânico
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // repropaga o pânico
}
}()
// Verifica saldo da conta de origem com lock
var saldo float64
err = tx.QueryRowContext(ctx,
"SELECT saldo FROM contas WHERE id = ? FOR UPDATE",
contaOrigemID,
).Scan(&saldo)
if err != nil {
tx.Rollback()
return fmt.Errorf("verificar saldo: %w", err)
}
if saldo < valor {
tx.Rollback()
return fmt.Errorf("saldo insuficiente: %.2f < %.2f", saldo, valor)
}
// Debita da origem
_, err = tx.ExecContext(ctx,
"UPDATE contas SET saldo = saldo - ? WHERE id = ?",
valor, contaOrigemID,
)
if err != nil {
tx.Rollback()
return fmt.Errorf("debitar origem: %w", err)
}
// Credita no destino
_, err = tx.ExecContext(ctx,
"UPDATE contas SET saldo = saldo + ? WHERE id = ?",
valor, contaDestinoID,
)
if err != nil {
tx.Rollback()
return fmt.Errorf("creditar destino: %w", err)
}
// Registra a transferência
_, err = tx.ExecContext(ctx,
`INSERT INTO transferencias (origem_id, destino_id, valor, realizada_em)
VALUES (?, ?, ?, NOW())`,
contaOrigemID, contaDestinoID, valor,
)
if err != nil {
tx.Rollback()
return fmt.Errorf("registrar transferência: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("confirmar transação: %w", err)
}
return nil
}
Repetir tx.Rollback() em cada ramo de erro funciona, mas é fácil esquecer um. A forma idiomática é registrar defer tx.Rollback() logo depois do BeginTx e remover todas as chamadas avulsas, inclusive o bloco com recover: se a função retornar antes do Commit, por erro ou pânico, a transação é desfeita; se o Commit já aconteceu, o Rollback adiado não faz nada e apenas devolve sql.ErrTxDone, como um teste com SQLite confirmou. Um detalhe à parte: saldos em float64 herdam os problemas de arredondamento do ponto flutuante — em sistemas financeiros, guarde centavos em inteiros ou use o tipo DECIMAL do banco com uma biblioteca decimal.
Prepared statements
Para queries executadas repetidamente, prepared statements melhoram performance e segurança:
type RepositorioUsuarios struct {
db *sql.DB
stmtBuscar *sql.Stmt
stmtListar *sql.Stmt
stmtCriar *sql.Stmt
}
func NovoRepositorioUsuarios(db *sql.DB) (*RepositorioUsuarios, error) {
stmtBuscar, err := db.Prepare(
"SELECT id, nome, email FROM usuarios WHERE id = ?")
if err != nil {
return nil, fmt.Errorf("preparar buscar: %w", err)
}
stmtListar, err := db.Prepare(
"SELECT id, nome, email FROM usuarios ORDER BY nome LIMIT ?")
if err != nil {
stmtBuscar.Close()
return nil, fmt.Errorf("preparar listar: %w", err)
}
stmtCriar, err := db.Prepare(
"INSERT INTO usuarios (nome, email, criado_em) VALUES (?, ?, NOW())")
if err != nil {
stmtBuscar.Close()
stmtListar.Close()
return nil, fmt.Errorf("preparar criar: %w", err)
}
return &RepositorioUsuarios{
db: db,
stmtBuscar: stmtBuscar,
stmtListar: stmtListar,
stmtCriar: stmtCriar,
}, nil
}
func (r *RepositorioUsuarios) Fechar() {
r.stmtBuscar.Close()
r.stmtListar.Close()
r.stmtCriar.Close()
}
func (r *RepositorioUsuarios) BuscarPorID(ctx context.Context, id int) (*Usuario, error) {
var u Usuario
err := r.stmtBuscar.QueryRowContext(ctx, id).Scan(&u.ID, &u.Nome, &u.Email)
if err == sql.ErrNoRows {
return nil, nil
}
return &u, err
}
SQL injection: nunca interpolação de strings
O placeholder ? (MySQL) ou $1 (PostgreSQL) é a proteção contra SQL injection. Nunca construa queries concatenando strings com dados do usuário:
// NUNCA FAÇA ISSO
query := fmt.Sprintf("SELECT * FROM usuarios WHERE nome = '%s'", nome)
// nome = "'; DROP TABLE usuarios; --" ← SQL injection
// SEMPRE USE PLACEHOLDERS
query := "SELECT * FROM usuarios WHERE nome = ?"
db.QueryContext(ctx, query, nome) // o valor viaja separado do comando SQL e nunca é interpretado como código
Monitorando o pool com sql.DBStats
func logEstatisticasPool(db *sql.DB, logger *slog.Logger) {
stats := db.Stats()
logger.Info("pool de conexões",
"abertas", stats.OpenConnections,
"em_uso", stats.InUse,
"ociosas", stats.Idle,
"esperas", stats.WaitCount,
"tempo_espera", stats.WaitDuration,
"max_ociosas", stats.MaxIdleClosed,
"max_vida", stats.MaxLifetimeClosed,
)
}
O database/sql separa o que o código pede do que cada banco faz, e o que ele entrega em troca é um pool de conexões que precisa ser tratado como recurso escasso. Quase todos os problemas de produção vêm de conexões que não voltam ao pool — o rows não fechado, a transação sem Rollback num caminho de erro — ou de limites que ignoram quantas réplicas existem. Com defer rows.Close() e defer tx.Rollback() escritos por hábito, placeholders em todos os valores e db.Stats() no monitoramento, o resto são detalhes de cada banco, tema dos próximos artigos.
Fontes e leituras recomendadas
-
Documentação do pacote database/sql — Referência completa e oficial. https://pkg.go.dev/database/sql
-
Go Blog: Accessing databases — Tutorial oficial de acesso a bancos de dados. https://go.dev/doc/database/
-
Go Blog: Prepared statements — Quando e como usar prepared statements. https://go.dev/doc/database/prepared-statements
-
go-sql-driver/mysql — Driver MySQL mais usado no ecossistema Go. https://github.com/go-sql-driver/mysql
-
lib/pq — Driver PostgreSQL clássico compatível com database/sql. https://github.com/lib/pq
-
OWASP SQL Injection — Referência sobre SQL injection e prevenção. https://owasp.org/www-community/attacks/SQL_Injection
Exercícios
Exercício 1
Uma função de listagem faz db.QueryContext, lê só a primeira linha para montar um resumo e retorna, sem chamar rows.Close(). Com pouco tráfego, nada acontece. Num pico, o serviço inteiro para de responder, com as requisições esperando o banco, embora o banco esteja ocioso. O que está preso, e como confirmar?
Ver resposta
✓ Resposta: Estão presas as conexões do pool. Enquanto um *sql.Rows não é fechado, nem lido até o fim, ele segura a conexão que o produziu, e a conexão não volta ao pool. Cada chamada da função "empresta" uma conexão para sempre, e, quando o limite de SetMaxOpenConns é atingido, toda consulta seguinte fica esperando uma conexão livre que não vai aparecer. O banco está ocioso porque, para ele, as conexões estão abertas e paradas. Num teste com SQLite e pool de uma conexão, um rows lido pela metade e não fechado fez a consulta seguinte esperar até o prazo e falhar com context deadline exceeded; depois do rows.Close(), tudo voltou ao normal. A confirmação em produção vem de db.Stats(): InUse igual ao máximo, Idle em zero e WaitCount subindo sem parar. A correção é o defer rows.Close() na linha seguinte ao QueryContext, como o artigo insiste, e, quando só uma linha interessa, usar QueryRowContext, que libera a conexão sozinho ao chamar Scan.
Exercício 2
A função transferirSaldo do artigo chama tx.Rollback() em cada um dos cinco caminhos de erro e ainda tem um defer com recover. Um novo desenvolvedor acrescenta uma sexta etapa e esquece o Rollback no erro dela. O que acontece com a transação e com a conexão? Mostre a forma que torna esse esquecimento impossível.
Ver resposta
✓ Resposta: A função retorna com a transação aberta. A conexão continua reservada para ela, e os locks que ela obteve, como o SELECT ... FOR UPDATE sobre a conta de origem, continuam valendo. Outras transferências da mesma conta ficam bloqueadas até o banco ou o driver desistirem, e o pool perde uma conexão, com os mesmos sintomas do exercício anterior. A forma robusta é defer tx.Rollback() logo depois do BeginTx, sem nenhuma outra chamada de Rollback e sem o recover. Em qualquer saída antes do Commit, seja por erro, retorno antecipado ou pânico, o defer desfaz a transação. Depois de um Commit bem-sucedido, o Rollback adiado não faz nada: num teste com SQLite, ele devolveu sql: transaction has already been committed or rolled back, que é o sql.ErrTxDone, e o dado gravado continuou lá. É o mesmo padrão do defer mu.Unlock(): a liberação fica declarada uma única vez, junto da aquisição.
Exercício 3
O método BuscarPorID do repositório com prepared statements devolve nil, nil quando o usuário não existe. Um handler faz u, err := repo.BuscarPorID(ctx, id), confere só o erro e acessa u.Nome. O que acontece com um ID inexistente, e qual contrato é melhor?
Ver resposta
✓ Resposta: O handler entra em pânico com nil pointer dereference ao ler u.Nome. O contrato nil, nil obriga todo chamador a lembrar de uma segunda verificação, u == nil, que o compilador não exige e que o padrão if err != nil não cobre. Basta um chamador esquecer. Há contratos melhores. O mais comum em Go é devolver um erro que o chamador reconhece: o próprio sql.ErrNoRows embrulhado, ou um erro de domínio, como var ErrNaoEncontrado = errors.New("usuário não encontrado"), testado com errors.Is. Num teste com SQLite, a consulta sem linha devolveu sql: no rows in result set, e errors.Is(err, sql.ErrNoRows) deu true. Com um erro sentinela do próprio repositório, o handler traduz "não encontrado" em 404 sem saber que existe um banco SQL por trás. Outra forma é devolver (Usuario, bool, error), deixando a ausência explícita na assinatura. Qualquer um dos dois é melhor que um nil que parece sucesso.
Exercício 4
Um serviço roda em 20 réplicas, cada uma com db.SetMaxOpenConns(25), e o banco MySQL está configurado com max_connections = 300. Num pico, parte das réplicas passa a receber Error 1040: Too many connections. O artigo chama 25 de ponto de partida razoável. O que ficou faltando na conta?
Ver resposta
✓ Resposta: Faltou multiplicar. O limite do pool é por processo: 20 réplicas com até 25 conexões cada podem abrir até 500 conexões, e o banco aceita 300. No tráfego normal, cada réplica usa poucas conexões e nada acontece; no pico, todas crescem ao mesmo tempo até o máximo, e as que chegam por último são recusadas. Somam-se ainda as conexões de migrações, jobs, ferramentas de administração e monitoramento. A conta certa parte do banco: reserve uma folga para administração e divida o resto pelo número máximo de réplicas que o autoescalonamento pode criar, e não pelo número atual. Com 300 e uma folga de 50, cabem 12 conexões por réplica para 20 réplicas. Se o número for baixo demais para a carga, a solução é um pooler de conexões na frente do banco, como o ProxySQL para MySQL ou o PgBouncer para PostgreSQL, ou rever quanto tempo cada requisição segura a conexão. Um WaitCount alto em db.Stats() é o sinal de que o pool local ficou pequeno demais para aquela réplica.
Exercício 5
A seção sobre SQL injection diz que, com placeholder, o valor "é escapado automaticamente pelo driver" (foi a redação original). Por que "escapar" não descreve bem o que acontece, e em que situação um placeholder não protege, obrigando outra solução?
Ver resposta
✓ Resposta: Com placeholders, o caminho normal é o do comando preparado: o texto SQL, com os ? ou $1, vai ao banco num passo, e os valores vão separados, em outro. O banco nunca interpreta o valor como parte do comando, e por isso '; DROP TABLE usuarios; -- é tratado como um nome estranho, e não como SQL. Não há escape de aspas no meio do texto, que é a técnica frágil das bibliotecas antigas. Alguns drivers oferecem, como otimização, interpolar os valores no cliente, e aí sim fazem o escape; o interpolateParams do driver MySQL é um exemplo, e ele é desligado por padrão. A situação em que o placeholder não protege é quando a parte variável não é um valor, e sim um identificador ou palavra-chave: o nome de uma coluna em ORDER BY, a direção ASC ou DESC, o nome de uma tabela. Placeholders só substituem valores. Para esses casos, a solução é uma lista fechada: um map[string]string de "campo que o cliente pede" para "coluna real", e recusar qualquer coisa fora dela, nunca concatenando o texto recebido.