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 uso | Caminho recomendado em Zig |
|---|---|
| Aplicação local, CLI, desktop, cache persistente | SQLite via sqlite3 |
| API, SaaS, serviço multiusuário | PostgreSQL via libpq ou driver Zig maduro |
| Cache, rate limit, fila leve, sessão | Redis via cliente RESP ou hiredis |
| Protótipo rápido | SQLite primeiro, migração para PostgreSQL quando necessário |
| Domínio crítico | SQL 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_URLfora 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:
- HTTP server em produção precisa de pool, timeout e shutdown limpo.
- Tratamento de erros define como falhas SQL sobem até a API.
- Containers Docker precisam incluir bibliotecas C quando o binário não é totalmente estático.
- OpenTelemetry em Zig ajuda a rastrear query lenta por request.
- Code review em Zig deve incluir SQL injection, ownership de memória C e rollback.
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.