Zig com Banco de Dados: SQLite, PostgreSQL, Redis e Pooling

Zig com Banco de Dados: SQLite, PostgreSQL, Redis e Pooling

Zig ainda não tem um ecossistema de ORMs comparável ao de Java, Python ou Node.js. Isso não é necessariamente um problema. Em projetos Zig, a integração com banco de dados costuma seguir uma linha mais explícita: SQL escrito à mão, tipos pequenos para representar linhas, erros tratados no retorno e bibliotecas C maduras chamadas diretamente por @cImport.

A decisão prática é simples:

Caso de usoCaminho recomendado em Zig
Aplicação local, CLI, desktop, cache persistenteSQLite via sqlite3
API, SaaS, serviço multiusuárioPostgreSQL via libpq ou driver Zig maduro
Cache, rate limit, fila leve, sessãoRedis via cliente RESP ou hiredis
Protótipo rápidoSQLite primeiro, migração para PostgreSQL quando necessário
Domínio críticoSQL explícito, migrations versionadas, testes de integração

O ganho é previsibilidade. Zig deixa claro quem aloca memória, quem fecha conexão, quando a query falhou e onde a conversão de tipos acontece.

Modelo mental: banco como borda do sistema

Em Zig, trate banco de dados como uma borda externa, não como magia no centro da aplicação. Uma arquitetura comum fica assim:

HTTP/CLI/worker
    -> validação de entrada
    -> serviço de domínio
    -> repositório Zig pequeno
    -> sqlite3/libpq/redis

O repositório expõe funções específicas:

pub fn buscarUsuarioPorEmail(repo: *UserRepo, email: []const u8) !?Usuario
pub fn criarUsuario(repo: *UserRepo, input: CriarUsuarioInput) !UsuarioId
pub fn marcarLogin(repo: *UserRepo, id: UsuarioId, agora: i64) !void

Evite espalhar SQL por handlers HTTP. Isso facilita testes, revisão de segurança e troca de SQLite para PostgreSQL no futuro.

SQLite: a integração mais natural

SQLite combina muito bem com Zig porque é uma biblioteca C pequena, estável e embutível. Para CLIs, ferramentas internas, aplicativos locais e jobs de ingestão, ele costuma ser suficiente.

const std = @import("std");

const c = @cImport({
    @cInclude("sqlite3.h");
});

const DbError = error{
    OpenFailed,
    PrepareFailed,
    StepFailed,
    BindFailed,
};

const Database = struct {
    db: *c.sqlite3,

    pub fn open(path: [:0]const u8) !Database {
        var db: ?*c.sqlite3 = null;
        if (c.sqlite3_open(path.ptr, &db) != c.SQLITE_OK) {
            return DbError.OpenFailed;
        }
        return .{ .db = db.? };
    }

    pub fn close(self: *Database) void {
        _ = c.sqlite3_close(self.db);
    }

    pub fn exec(self: *Database, sql: [:0]const u8) !void {
        var err_msg: ?[*:0]u8 = null;
        defer if (err_msg) |msg| c.sqlite3_free(msg);

        if (c.sqlite3_exec(self.db, sql.ptr, null, null, &err_msg) != c.SQLITE_OK) {
            return DbError.StepFailed;
        }
    }
};

O ponto importante é o defer: toda conexão aberta precisa ser fechada. Todo erro retornado por C precisa virar erro Zig controlado. Não deixe ponteiros C soltos em camadas altas da aplicação.

Queries parametrizadas em SQLite

Nunca monte SQL concatenando entrada de usuário. Mesmo em ferramenta interna, use prepare, bind e step.

pub fn inserirUsuario(db: *Database, nome: []const u8, email: []const u8) !void {
    const sql =
        \\INSERT INTO usuarios (nome, email)
        \\VALUES (?1, ?2)
    ;

    var stmt: ?*c.sqlite3_stmt = null;
    if (c.sqlite3_prepare_v2(db.db, sql, -1, &stmt, null) != c.SQLITE_OK) {
        return DbError.PrepareFailed;
    }
    defer _ = c.sqlite3_finalize(stmt);

    if (c.sqlite3_bind_text(stmt, 1, nome.ptr, @intCast(nome.len), c.SQLITE_TRANSIENT) != c.SQLITE_OK) {
        return DbError.BindFailed;
    }
    if (c.sqlite3_bind_text(stmt, 2, email.ptr, @intCast(email.len), c.SQLITE_TRANSIENT) != c.SQLITE_OK) {
        return DbError.BindFailed;
    }

    if (c.sqlite3_step(stmt) != c.SQLITE_DONE) {
        return DbError.StepFailed;
    }
}

Esse padrão é repetitivo, mas é revisável. Em Zig, vale criar helpers pequenos para bind de string, inteiro e timestamp, sem esconder completamente a query.

PostgreSQL com libpq

Para serviços web e dados compartilhados, PostgreSQL é a escolha mais comum. A rota conservadora em Zig é usar libpq, a biblioteca C oficial do Postgres.

const c = @cImport({
    @cInclude("libpq-fe.h");
});

const PgError = error{
    ConnectionFailed,
    QueryFailed,
    UnexpectedRows,
};

const PgConn = struct {
    conn: *c.PGconn,

    pub fn connect(conninfo: [:0]const u8) !PgConn {
        const conn = c.PQconnectdb(conninfo.ptr) orelse return PgError.ConnectionFailed;
        if (c.PQstatus(conn) != c.CONNECTION_OK) {
            c.PQfinish(conn);
            return PgError.ConnectionFailed;
        }
        return .{ .conn = conn };
    }

    pub fn close(self: *PgConn) void {
        c.PQfinish(self.conn);
    }
};

Em produção, não deixe a string de conexão hardcoded. Leia de variável de ambiente ou arquivo de configuração injetado pelo deploy.

const conninfo = std.posix.getenv("DATABASE_URL") orelse return error.MissingDatabaseUrl;

Queries parametrizadas no PostgreSQL

Com libpq, use PQexecParams para separar SQL e valores.

pub fn buscarUsuario(conn: *PgConn, email: [:0]const u8) !?Usuario {
    const sql =
        \\SELECT id, nome, email
        \\FROM usuarios
        \\WHERE email = $1
        \\LIMIT 1
    ;

    const values = [_][*c]const u8{email.ptr};
    const lengths = [_]c_int{@intCast(email.len)};
    const formats = [_]c_int{0};

    const res = c.PQexecParams(
        conn.conn,
        sql,
        1,
        null,
        &values,
        &lengths,
        &formats,
        0,
    ) orelse return PgError.QueryFailed;
    defer c.PQclear(res);

    if (c.PQresultStatus(res) != c.PGRES_TUPLES_OK) {
        return PgError.QueryFailed;
    }

    const rows = c.PQntuples(res);
    if (rows == 0) return null;
    if (rows > 1) return PgError.UnexpectedRows;

    // Em código real, copie os valores para memória Zig controlada.
    return Usuario{
        .id = try parseInt(c.PQgetvalue(res, 0, 0)),
        .nome = try copyPgText(c.PQgetvalue(res, 0, 1)),
        .email = try copyPgText(c.PQgetvalue(res, 0, 2)),
    };
}

A parte crítica é propriedade de memória. Valores retornados por PQgetvalue vivem enquanto PGresult existir. Se você chama PQclear, precisa ter copiado os dados que serão usados depois.

Pool de conexões simples

Abrir uma conexão PostgreSQL por request é caro. Em um serviço Zig, um pool pequeno resolve a maior parte dos casos.

const Pool = struct {
    allocator: std.mem.Allocator,
    conns: std.ArrayList(*PgConn),
    mutex: std.Thread.Mutex = .{},

    pub fn acquire(self: *Pool) !*PgConn {
        self.mutex.lock();
        defer self.mutex.unlock();

        if (self.conns.items.len == 0) return error.PoolExhausted;
        return self.conns.pop();
    }

    pub fn release(self: *Pool, conn: *PgConn) !void {
        self.mutex.lock();
        defer self.mutex.unlock();
        try self.conns.append(conn);
    }
};

Esse exemplo é intencionalmente pequeno. Em produção, acrescente:

  • limite máximo de conexões;
  • health check antes de devolver conexão antiga;
  • timeout de aquisição;
  • métricas de conexões ocupadas e erros;
  • fechamento limpo no shutdown;
  • estratégia para transações longas.

Se o deploy roda em Kubernetes ou serverless, cuidado para não multiplicar conexões por réplica até derrubar o Postgres. Combine pool local com PgBouncer quando necessário.

Transações explícitas

Transação em Zig deve aparecer no fluxo do código. O padrão básico é BEGIN, COMMIT e ROLLBACK com errdefer.

pub fn transferirSaldo(conn: *PgConn, origem: i64, destino: i64, centavos: i64) !void {
    try conn.exec("BEGIN");
    errdefer conn.exec("ROLLBACK") catch {};

    try debitar(conn, origem, centavos);
    try creditar(conn, destino, centavos);

    try conn.exec("COMMIT");
}

errdefer é uma das melhores ferramentas de Zig para banco de dados. Ele documenta que qualquer falha no meio da operação desfaz a transação.

Redis: cache e coordenação leve

Redis entra bem em Zig quando o papel é limitado:

  • cache de resposta;
  • rate limit;
  • sessão curta;
  • fila simples;
  • lock com TTL;
  • contador de eventos.

Evite transformar Redis em banco principal de dados críticos. Use PostgreSQL ou SQLite como fonte de verdade e Redis como aceleração.

Para clientes, há dois caminhos: implementar RESP diretamente para comandos simples ou usar hiredis via C. Em ambos os casos, modele o retorno como erro Zig, não como strings soltas.

const CacheError = error{Unavailable, InvalidResponse};

pub fn cacheKeyUsuario(allocator: std.mem.Allocator, id: i64) ![]u8 {
    return std.fmt.allocPrint(allocator, "usuario:{d}", .{id});
}

Chaves previsíveis e TTL explícito importam mais do que abstrações grandes.

Migrations: não deixe para depois

Projetos Zig pequenos costumam começar com SQL manual. Tudo bem. O erro é não versionar schema.

Uma estrutura simples funciona:

migrations/
  001_create_users.sql
  002_add_login_audit.sql
  003_create_api_tokens.sql

Na inicialização ou em um comando administrativo, rode as migrations pendentes dentro de uma transação e grave o histórico:

CREATE TABLE IF NOT EXISTS schema_migrations (
  version TEXT PRIMARY KEY,
  applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Para SQLite, a mesma ideia vale com TEXT e timestamp ISO. O importante é que deploy e banco avancem juntos.

Testes de integração

Banco de dados precisa de teste real. Mock ajuda pouco quando o risco está em SQL, índice, constraint e transação.

Para SQLite:

  • use banco em arquivo temporário para testar persistência;
  • rode migrations antes do teste;
  • apague o arquivo no fim;
  • teste constraints e casos de erro.

Para PostgreSQL:

  • use container local ou banco dedicado de teste;
  • crie schema isolado por execução;
  • rode migrations;
  • faça rollback ou drop no final.
test "cria e busca usuario por email" {
    var db = try Database.open(":memory:");
    defer db.close();

    try db.exec("CREATE TABLE usuarios (id INTEGER PRIMARY KEY, nome TEXT, email TEXT UNIQUE)");
    try inserirUsuario(&db, "Ana", "[email protected]");

    const usuario = try buscarUsuario(&db, "[email protected]");
    try std.testing.expect(usuario != null);
}

Observabilidade e erros

Banco lento vira sistema lento. Instrumente desde cedo:

  • tempo por query;
  • quantidade de queries por request;
  • erros por tipo;
  • pool esgotado;
  • tentativas de reconnect;
  • transações abertas por muito tempo.

Se você já usa Zig para observabilidade, trate queries como spans ou eventos estruturados. Logue nome lógico da query, não dados sensíveis.

query=user.find_by_email duration_ms=12 rows=1 status=ok
query=invoice.create duration_ms=91 status=error error=unique_violation

Nunca logue senha, token, documento, cartão ou payload privado.

Segurança: o checklist mínimo

Antes de colocar Zig com banco em produção, confira:

  • queries parametrizadas em toda entrada externa;
  • usuário do banco com permissões mínimas;
  • DATABASE_URL fora do repositório;
  • TLS quando o banco está fora da rede privada;
  • timeouts de conexão e query;
  • migrations revisadas;
  • backups testados;
  • índices para consultas críticas;
  • logs sem dados sensíveis;
  • limites de pool compatíveis com o banco.

A vantagem de Zig é que muita coisa fica explícita. A desvantagem é que você precisa construir as proteções que um framework grande traria por padrão.

Quando usar driver Zig nativo?

Drivers nativos podem ser bons quando o projeto está ativo, cobre o protocolo necessário e tem manutenção compatível com sua exigência de produção. Para sistemas críticos, avalie:

  • suporte à versão atual do Zig;
  • autenticação e TLS;
  • queries parametrizadas;
  • transações;
  • testes de integração;
  • tratamento de reconnect;
  • licença e atividade do mantenedor.

Se a resposta for incerta, sqlite3, libpq e hiredis continuam sendo escolhas conservadoras.

Relação com outros tópicos Zig

Banco de dados cruza vários assuntos do ecossistema:

FAQ

Zig tem ORM oficial?

Não. O caminho mais comum em Zig é usar SQL explícito, bibliotecas C maduras como sqlite3 e libpq, ou adaptar clientes Zig menores quando o projeto aceita a maturidade deles.

SQLite combina com Zig?

Sim. SQLite é uma das integrações mais naturais porque é uma biblioteca C estável, pequena e fácil de embutir junto de binários Zig. Para CLI, desktop, edge e ferramentas internas, costuma ser a primeira opção.

Como usar PostgreSQL em Zig hoje?

A rota mais conservadora é libpq via @cImport, com queries parametrizadas, tratamento explícito de erros, timeout e um pool simples no lado da aplicação. Drivers Zig nativos podem ser avaliados caso a maturidade atenda ao projeto.

Vale usar Redis com Zig?

Vale quando Redis é cache, fila simples, rate limit ou sessão. Para dados críticos, mantenha PostgreSQL ou SQLite como fonte de verdade e use Redis como camada auxiliar com TTL e falhas toleráveis.

Conclusão

Integrar Zig com banco de dados é menos sobre escolher uma abstração famosa e mais sobre assumir controle: SQL claro, memória controlada, conexões fechadas, erros explícitos e testes reais. Para produção, comece conservador: SQLite quando o banco é local, PostgreSQL via libpq quando há múltiplos usuários, Redis apenas como camada auxiliar.

Esse estilo combina com a filosofia da linguagem. Zig não promete esconder a complexidade do banco; ele dá ferramentas para deixar essa complexidade visível, revisável e rápida.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.