Golang

Go com SQLite: banco de dados embutido para aplicações leves Já leu

13 min de leitura

Go com SQLite: banco de dados embutido para aplicações leves
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,

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

Comentários

Mais em Golang

Goroutines: concorrência leve e o modelo de execução do Go
Goroutines: concorrência leve e o modelo de execução do Go

Se existe um recurso que mais distingue Go de outras linguagens modernas, é s...

Go com PostgreSQL: recursos avançados e o driver pgx
Go com PostgreSQL: recursos avançados e o driver pgx

PostgreSQL é frequentemente descrito como o banco de dados relacional mais co...

Artigo 47 — Design Patterns em Go: Repository, Factory e Strategy
Artigo 47 — Design Patterns em Go: Repository, Factory e Strategy

Artigo 47 — Design Patterns em Go: Repository, Factory e Strategy Curs...