SQLite é o banco de dados mais implantado do mundo. Está presente em navegadores, sistemas operacionais, dispositivos móveis, aplicativos desktop e em bilhões de dispositivos embarcados. Diferente de MySQL e PostgreSQL, ele não é um servidor separado — é uma biblioteca que a aplicação carrega diretamente, armazenando tudo em um único arquivo no disco.
Para Go, isso significa zero infraestrutura: sem Docker, sem processo separado, sem configuração de rede. O banco sobe junto com a aplicação e encerra junto com ela. Essa característica o torna ideal para CLIs, ferramentas de desenvolvimento, aplicações desktop, testes de integração e serviços com volume moderado de dados.
Os dois drivers disponíveis
Go tem dois drivers principais para SQLite, com filosofias diferentes:
mattn/go-sqlite3 — usa CGo para vincular a biblioteca C do SQLite. Performance excelente e total compatibilidade com a biblioteca oficial. Requer um compilador C (GCC ou Clang) na máquina de build e no ambiente de CI.
modernc.org/sqlite — port puro de Go do SQLite, transpilado automaticamente do código C original. Não requer CGo nem compilador C. Ligeiramente mais lento, mas sem dependências externas de build.
Para a maioria dos projetos, modernc.org/sqlite é a escolha mais prática por eliminar a dependência do compilador C:
# Driver puro Go — recomendado para a maioria dos casos
go get modernc.org/sqlite
# Driver CGo — melhor performance, requer GCC
go get github.com/mattn/go-sqlite3
Conexão e configuração
package main
import (
"context"
"database/sql"
"fmt"
"log"
"time"
_ "modernc.org/sqlite"
)
func abrirSQLite(caminho string) (*sql.DB, error) {
// DSN do SQLite: caminho do arquivo ou ":memory:" para banco em memória
dsn := fmt.Sprintf("%s?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000", caminho)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("abrir SQLite: %w", err)
}
// SQLite não suporta múltiplas conexões de escrita simultâneas
// Uma única conexão de escrita é o padrão recomendado
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0) // conexões vivem para sempre
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)
}
log.Printf("SQLite aberto: %s", caminho)
return db, nil
}
func main() {
// Banco em arquivo
db, err := abrirSQLite("dados.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
fmt.Println("SQLite pronto")
}
Os parâmetros do DSN merecem atenção:
_journal_mode=WAL— Write-Ahead Logging permite leituras concorrentes enquanto uma escrita está em andamento, melhorando significativamente a performance_foreign_keys=on— chaves estrangeiras são desabilitadas por padrão no SQLite; este parâmetro as ativa_busy_timeout=5000— espera até 5 segundos quando o banco está locked, em vez de retornar erro imediatamente
Banco em memória para testes
Uma das vantagens mais valiosas do SQLite é a possibilidade de criar bancos completamente em memória — perfeitos para testes de integração que precisam de um banco real sem infraestrutura:
func abrirSQLiteMemoria() (*sql.DB, error) {
// ":memory:" cria um banco que existe apenas na RAM
// "file::memory:?cache=shared" permite múltiplas conexões ao mesmo banco em memória
db, err := sql.Open("sqlite", "file::memory:?cache=shared&_foreign_keys=on")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
return db, db.Ping()
}
Schema e migrações
package main
import (
"context"
"database/sql"
"fmt"
)
const schemaPrincipal = `
CREATE TABLE IF NOT EXISTS categorias (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tarefas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
categoria_id INTEGER REFERENCES categorias(id),
titulo TEXT NOT NULL,
descricao TEXT,
concluida BOOLEAN NOT NULL DEFAULT 0,
prioridade INTEGER NOT NULL DEFAULT 0 CHECK(prioridade BETWEEN 0 AND 3),
prazo DATETIME,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS tarefas_tags (
tarefa_id INTEGER NOT NULL REFERENCES tarefas(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (tarefa_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_tarefas_categoria ON tarefas(categoria_id);
CREATE INDEX IF NOT EXISTS idx_tarefas_concluida ON tarefas(concluida);
CREATE INDEX IF NOT EXISTS idx_tarefas_prazo ON tarefas(prazo);
`
func inicializarSchema(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, schemaPrincipal)
if err != nil {
return fmt.Errorf("inicializar schema: %w", err)
}
return nil
}
Tipos em SQLite: o sistema de afinidades
SQLite usa um sistema de tipos diferente dos demais bancos. Em vez de tipos rígidos, usa afinidades de tipo:
| Afinidade SQLite | Tipos declarados | Tipo Go |
|---|---|---|
| INTEGER | INT, INTEGER, BIGINT | int64, bool |
| REAL | REAL, FLOAT, DOUBLE | float64 |
| TEXT | TEXT, VARCHAR, CHAR | string, time.Time |
| BLOB | BLOB | []byte |
| NUMERIC | DECIMAL, NUMERIC | string (para precisão) |
time.Time é armazenado como TEXT no formato RFC3339 ou como INTEGER (Unix timestamp). A conversão precisa ser manual:
type Tarefa struct {
ID int64
CategoriaID sql.NullInt64
Titulo string
Descricao sql.NullString
Concluida bool
Prioridade int
Prazo sql.NullTime
CriadoEm time.Time
AtualizadoEm time.Time
}
Repositório completo de tarefas
package main
import (
"context"
"database/sql"
"fmt"
"time"
)
type TarefaInput struct {
CategoriaID *int64
Titulo string
Descricao string
Prioridade int
Prazo *time.Time
}
type RepoTarefas struct {
db *sql.DB
}
func NovoRepoTarefas(db *sql.DB) *RepoTarefas {
return &RepoTarefas{db: db}
}
func (r *RepoTarefas) Criar(ctx context.Context, input TarefaInput) (*Tarefa, error) {
query := `
INSERT INTO tarefas (categoria_id, titulo, descricao, prioridade, prazo)
VALUES (?, ?, ?, ?, ?)
RETURNING id, categoria_id, titulo, descricao, concluida,
prioridade, prazo, criado_em, atualizado_em
`
var catID sql.NullInt64
if input.CategoriaID != nil {
catID = sql.NullInt64{Int64: *input.CategoriaID, Valid: true}
}
var desc sql.NullString
if input.Descricao != "" {
desc = sql.NullString{String: input.Descricao, Valid: true}
}
var prazo sql.NullTime
if input.Prazo != nil {
prazo = sql.NullTime{Time: *input.Prazo, Valid: true}
}
var t Tarefa
err := r.db.QueryRowContext(ctx, query,
catID, input.Titulo, desc, input.Prioridade, prazo,
).Scan(
&t.ID, &t.CategoriaID, &t.Titulo, &t.Descricao,
&t.Concluida, &t.Prioridade, &t.Prazo,
&t.CriadoEm, &t.AtualizadoEm,
)
if err != nil {
return nil, fmt.Errorf("criar tarefa: %w", err)
}
return &t, nil
}
func (r *RepoTarefas) BuscarPorID(ctx context.Context, id int64) (*Tarefa, error) {
query := `
SELECT id, categoria_id, titulo, descricao, concluida,
prioridade, prazo, criado_em, atualizado_em
FROM tarefas
WHERE id = ?
`
var t Tarefa
err := r.db.QueryRowContext(ctx, query, id).Scan(
&t.ID, &t.CategoriaID, &t.Titulo, &t.Descricao,
&t.Concluida, &t.Prioridade, &t.Prazo,
&t.CriadoEm, &t.AtualizadoEm,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("tarefa %d não encontrada", id)
}
if err != nil {
return nil, fmt.Errorf("buscar tarefa: %w", err)
}
return &t, nil
}
type FiltroTarefas struct {
Concluida *bool
CategoriaID *int64
Prioridade *int
Limite int
Offset int
}
func (r *RepoTarefas) Listar(ctx context.Context, filtro FiltroTarefas) ([]Tarefa, error) {
// Construção dinâmica de query com filtros opcionais
base := `
SELECT id, categoria_id, titulo, descricao, concluida,
prioridade, prazo, criado_em, atualizado_em
FROM tarefas
WHERE 1=1
`
args := []any{}
if filtro.Concluida != nil {
base += " AND concluida = ?"
args = append(args, *filtro.Concluida)
}
if filtro.CategoriaID != nil {
base += " AND categoria_id = ?"
args = append(args, *filtro.CategoriaID)
}
if filtro.Prioridade != nil {
base += " AND prioridade = ?"
args = append(args, *filtro.Prioridade)
}
base += " ORDER BY prioridade DESC, criado_em DESC"
if filtro.Limite > 0 {
base += " LIMIT ?"
args = append(args, filtro.Limite)
}
if filtro.Offset > 0 {
base += " OFFSET ?"
args = append(args, filtro.Offset)
}
rows, err := r.db.QueryContext(ctx, base, args...)
if err != nil {
return nil, fmt.Errorf("listar tarefas: %w", err)
}
defer rows.Close()
var tarefas []Tarefa
for rows.Next() {
var t Tarefa
if err := rows.Scan(
&t.ID, &t.CategoriaID, &t.Titulo, &t.Descricao,
&t.Concluida, &t.Prioridade, &t.Prazo,
&t.CriadoEm, &t.AtualizadoEm,
); err != nil {
return nil, fmt.Errorf("scan tarefa: %w", err)
}
tarefas = append(tarefas, t)
}
return tarefas, rows.Err()
}
func (r *RepoTarefas) Concluir(ctx context.Context, id int64) error {
resultado, err := r.db.ExecContext(ctx,
"UPDATE tarefas SET concluida = 1, atualizado_em = CURRENT_TIMESTAMP WHERE id = ?",
id,
)
if err != nil {
return fmt.Errorf("concluir tarefa: %w", err)
}
if n, _ := resultado.RowsAffected(); n == 0 {
return fmt.Errorf("tarefa %d não encontrada", id)
}
return nil
}
func (r *RepoTarefas) Deletar(ctx context.Context, id int64) error {
resultado, err := r.db.ExecContext(ctx,
"DELETE FROM tarefas WHERE id = ?", id)
if err != nil {
return fmt.Errorf("deletar tarefa: %w", err)
}
if n, _ := resultado.RowsAffected(); n == 0 {
return fmt.Errorf("tarefa %d não encontrada", id)
}
return nil
}
Relacionamento muitos-para-muitos: tags
func (r *RepoTarefas) AdicionarTag(ctx context.Context, tarefaID int64, nomeTag string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Insere a tag se não existir (INSERT OR IGNORE)
_, err = tx.ExecContext(ctx,
"INSERT OR IGNORE INTO tags (nome) VALUES (?)", nomeTag)
if err != nil {
return fmt.Errorf("inserir tag: %w", err)
}
// Obtém o ID da tag
var tagID int64
err = tx.QueryRowContext(ctx,
"SELECT id FROM tags WHERE nome = ?", nomeTag).Scan(&tagID)
if err != nil {
return fmt.Errorf("buscar tag: %w", err)
}
// Associa tag à tarefa (INSERT OR IGNORE evita duplicatas)
_, err = tx.ExecContext(ctx,
"INSERT OR IGNORE INTO tarefas_tags (tarefa_id, tag_id) VALUES (?, ?)",
tarefaID, tagID,
)
if err != nil {
return fmt.Errorf("associar tag: %w", err)
}
return tx.Commit()
}
type TarefaComTags struct {
Tarefa
Tags []string
}
func (r *RepoTarefas) BuscarComTags(ctx context.Context, id int64) (*TarefaComTags, error) {
tarefa, err := r.BuscarPorID(ctx, id)
if err != nil {
return nil, err
}
rows, err := r.db.QueryContext(ctx, `
SELECT t.nome
FROM tags t
INNER JOIN tarefas_tags tt ON tt.tag_id = t.id
WHERE tt.tarefa_id = ?
ORDER BY t.nome
`, id)
if err != nil {
return nil, fmt.Errorf("buscar tags: %w", err)
}
defer rows.Close()
resultado := &TarefaComTags{Tarefa: *tarefa}
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
return nil, err
}
resultado.Tags = append(resultado.Tags, tag)
}
return resultado, rows.Err()
}
Testes de integração com banco em memória
package main
import (
"context"
"testing"
_ "modernc.org/sqlite"
)
func configurarBancoDeTeste(t *testing.T) *sql.DB {
t.Helper()
db, err := abrirSQLiteMemoria()
if err != nil {
t.Fatalf("abrir banco de teste: %v", err)
}
ctx := context.Background()
if err := inicializarSchema(ctx, db); err != nil {
db.Close()
t.Fatalf("inicializar schema: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestRepoTarefas_CriarEBuscar(t *testing.T) {
db := configurarBancoDeTeste(t)
repo := NovoRepoTarefas(db)
ctx := context.Background()
input := TarefaInput{
Titulo: "Estudar Go",
Descricao: "Completar o módulo de banco de dados",
Prioridade: 2,
}
criada, err := repo.Criar(ctx, input)
if err != nil {
t.Fatalf("criar tarefa: %v", err)
}
if criada.ID == 0 {
t.Error("ID não foi gerado")
}
if criada.Titulo != input.Titulo {
t.Errorf("título esperado '%s', obtido '%s'", input.Titulo, criada.Titulo)
}
buscada, err := repo.BuscarPorID(ctx, criada.ID)
if err != nil {
t.Fatalf("buscar tarefa: %v", err)
}
if buscada.Titulo != criada.Titulo {
t.Errorf("títulos não conferem: '%s' vs '%s'",
criada.Titulo, buscada.Titulo)
}
}
func TestRepoTarefas_Concluir(t *testing.T) {
db := configurarBancoDeTeste(t)
repo := NovoRepoTarefas(db)
ctx := context.Background()
tarefa, _ := repo.Criar(ctx, TarefaInput{Titulo: "Tarefa de teste"})
if err := repo.Concluir(ctx, tarefa.ID); err != nil {
t.Fatalf("concluir tarefa: %v", err)
}
atualizada, _ := repo.BuscarPorID(ctx, tarefa.ID)
if !atualizada.Concluida {
t.Error("tarefa deveria estar marcada como concluída")
}
}
func TestRepoTarefas_NaoEncontrada(t *testing.T) {
db := configurarBancoDeTeste(t)
repo := NovoRepoTarefas(db)
ctx := context.Background()
_, err := repo.BuscarPorID(ctx, 99999)
if err == nil {
t.Error("esperava erro para ID inexistente")
}
}
Backup e manutenção
func fazerBackup(ctx context.Context, db *sql.DB, destino string) error {
// O método mais simples: usar o comando VACUUM INTO
_, err := db.ExecContext(ctx, "VACUUM INTO ?", destino)
if err != nil {
return fmt.Errorf("backup: %w", err)
}
return nil
}
func otimizarBanco(ctx context.Context, db *sql.DB) error {
comandos := []string{
"PRAGMA optimize", // atualiza estatísticas do query planner
"PRAGMA wal_checkpoint(TRUNCATE)", // consolida WAL
"VACUUM", // compacta o arquivo do banco
"ANALYZE", // atualiza estatísticas de índices
}
for _, cmd := range comandos {
if _, err := db.ExecContext(ctx, cmd); err != nil {
return fmt.Errorf("otimizar (%s): %w", cmd, err)
}
}
return nil
}
Limitações do SQLite a conhecer
SQLite é excelente para muitos casos, mas tem limitações importantes:
Concorrência de escrita. SQLite suporta múltiplos leitores simultâneos, mas apenas um escritor por vez. Para aplicações com alta carga de escrita concorrente, MySQL ou PostgreSQL são mais adequados.
Tipos de dados. O sistema de afinidades é flexível, mas pode surpreender. Um campo INTEGER aceita texto sem erro — a responsabilidade da integridade de tipos é mais da aplicação.
ALTER TABLE limitado. SQLite não suporta todas as formas de ALTER TABLE. Adicionar colunas funciona, mas renomear ou remover colunas exige recriar a tabela.
Sem suporte a funções de janela em versões antigas. A partir do SQLite 3.25 (2018), funções de janela são suportadas — mas versões antigas do sistema operacional podem ter versões mais antigas do SQLite.
Resumo do que foi coberto
Este artigo apresentou SQLite com Go em profundidade: a escolha entre mattn/go-sqlite3 e modernc.org/sqlite, conexão com DSN e parâmetros essenciais como WAL e chaves estrangeiras, banco em memória para testes, o sistema de afinidades de tipos, um repositório completo de tarefas com filtros dinâmicos, relacionamento muitos-para-muitos com tags, testes de integração rápidos sem infraestrutura, backup com VACUUM INTO e as limitações a considerar na escolha do banco. O próximo artigo explora PostgreSQL e seus recursos avançados.
Referências e leituras complementares
-
modernc.org/sqlite — Driver SQLite puro Go, sem CGo. https://pkg.go.dev/modernc.org/sqlite
-
mattn/go-sqlite3 — Driver SQLite com CGo, máxima compatibilidade. https://github.com/mattn/go-sqlite3
-
Documentação oficial SQLite — Referência completa da linguagem e pragmas. https://www.sqlite.org/docs.html
-
SQLite WAL mode — Explicação detalhada do Write-Ahead Logging. https://www.sqlite.org/wal.html
-
SQLite em aplicações de produção — Artigo de Litestream sobre uso em produção. https://litestream.io/why-i-migrate-to-litestream/
-
Litestream — Ferramenta de replicação contínua para SQLite em produção. https://litestream.io