Muita gente associa Zig a sistemas embarcados, runtimes, bancos de dados e ferramentas de baixo nível. Mas existe um uso menos glamouroso e muito útil: ETL pequeno e confiável. Aquele importador que lê CSV de fornecedor, normaliza campos, valida linhas, gera JSONL, carrega uma base SQLite ou prepara uma migração única costuma nascer como script rápido. Depois ele cresce, vira job recorrente, quebra em produção e fica difícil descobrir qual linha corrompeu o lote.
Zig entra bem nesse espaço quando você quer um binário único, consumo previsível de memória e controle explícito de erro. Não é a melhor escolha para toda análise exploratória: Python, DuckDB e ferramentas declarativas continuam excelentes. O ponto é outro. Quando a transformação já está entendida e precisa rodar muitas vezes, em CI, em um container mínimo, em máquinas diferentes ou dentro de um pipeline operacional, Zig permite empacotar a regra como ferramenta robusta.
Este guia mostra uma arquitetura prática para ETL em Zig: leitura em streaming, parser de CSV, normalização, validação linha a linha, saída JSONL, checkpoint e relatório de rejeições. Ele complementa a receita de parsear CSV em Zig, o artigo sobre processamento de dados e serialização, o guia de ferramentas internas em Zig, File I/O em Zig e SQLite com Zig.
Quando usar Zig para ETL
Use Zig quando o pipeline precisa ser reproduzível, distribuível e chato de um jeito bom. Exemplos reais:
- importar catálogo de produtos recebido em CSV;
- converter exportações antigas para JSONL;
- validar dados antes de uma migração de banco;
- transformar logs grandes em relatórios resumidos;
- gerar arquivo intermediário para outro serviço consumir;
- criar um job recorrente sem depender de runtime Node ou Python instalado.
Evite Zig para exploração inicial, notebooks, consultas ad hoc e transformações que mudam a cada hora. Nesses casos, a velocidade de iteração de SQL, Python ou ferramentas de linha de comando vence. Uma boa prática é prototipar a regra em algo flexível e só depois congelar o caminho crítico em Zig.
Modelo mental: pipeline em estágios
Um ETL confiável não é um for gigante com lógica misturada. Separe os estágios:
- Source: abre o arquivo, stream, stdin ou consulta.
- Decode: transforma bytes em registros brutos.
- Normalize: limpa espaços, datas, números e encoding esperado.
- Validate: decide se o registro pode seguir.
- Transform: monta o formato de saída.
- Sink: escreve JSONL, SQLite, API ou outro destino.
- Report: conta processados, rejeitados e motivos.
Essa separação parece burocrática, mas paga na primeira divergência. Quando uma linha falha, você sabe se foi problema de leitura, formato, regra de negócio ou escrita.
Comece com contratos simples
Suponha um CSV de usuários:
id,email,nome,criado_em,plano
123,[email protected],Maria Silva,2026-05-01,pro
124,[email protected],João Souza,2026-05-02,free
A saída JSONL desejada pode ser:
{"id":"123","email":"[email protected]","name":"Maria Silva","created_at":"2026-05-01","plan":"pro"}
{"id":"124","email":"[email protected]","name":"João Souza","created_at":"2026-05-02","plan":"free"}
JSONL é uma boa escolha para migração porque cada linha é um documento independente. Se a escrita para banco falhar depois, você consegue retomar, dividir arquivo, inspecionar com head, tail, jq e processar em lotes. Para dados grandes, isso costuma ser mais operacional do que gerar um JSON único enorme.
Estruturas de domínio pequenas
Não tente representar o CSV inteiro como mapa dinâmico se o formato é conhecido. Defina structs explícitas:
const std = @import("std");
const RawUser = struct {
id: []const u8,
email: []const u8,
nome: []const u8,
criado_em: []const u8,
plano: []const u8,
};
const User = struct {
id: []const u8,
email: []const u8,
name: []const u8,
created_at: []const u8,
plan: Plan,
};
const Plan = enum { free, pro, enterprise };
const RowError = error{
MissingId,
InvalidEmail,
InvalidDate,
InvalidPlan,
WrongColumnCount,
};
A struct RawUser ainda aponta para slices da linha lida. A struct User representa o dado validado. Essa diferença ajuda a evitar uma armadilha comum: usar dado cru como se já fosse confiável.
Leitura em streaming
Para arquivos grandes, não carregue tudo em memória. Use buffer e processe linha por linha. O padrão abaixo é suficiente para muitos importadores:
pub fn runImport(allocator: std.mem.Allocator, input_path: []const u8, output_path: []const u8) !void {
const input = try std.fs.cwd().openFile(input_path, .{});
defer input.close();
const output = try std.fs.cwd().createFile(output_path, .{ .truncate = true });
defer output.close();
var reader_buffer: [64 * 1024]u8 = undefined;
var file_reader = input.reader(&reader_buffer);
const reader = &file_reader.interface;
var writer_buffer: [64 * 1024]u8 = undefined;
var file_writer = output.writer(&writer_buffer);
const writer = &file_writer.interface;
defer writer.flush() catch {};
var line_no: usize = 0;
while (try reader.takeDelimiterExclusive('\n')) |line| {
line_no += 1;
if (line_no == 1) continue; // cabeçalho
try processLine(allocator, writer, line_no, line);
}
}
A API exata de std.Io muda entre versões do Zig, então ajuste ao release usado pelo projeto. A ideia é o que importa: buffer fixo, leitura delimitada e escrita incremental. Se uma linha pode passar de 64 KiB, trate isso como decisão explícita, não acidente.
Parsing: simples primeiro, correto quando necessário
CSV é traiçoeiro. Se o fornecedor nunca usa aspas, vírgulas dentro de campo ou quebras de linha escapadas, std.mem.splitScalar resolve. Se usa, implemente ou adote parser que respeite RFC 4180. O erro caro é aceitar um parser simples sem validar a hipótese.
Para CSV controlado, uma função de mapeamento já ajuda:
fn parseRawUser(line: []const u8) RowError!RawUser {
var cols: [5][]const u8 = undefined;
var it = std.mem.splitScalar(u8, line, ',');
var i: usize = 0;
while (it.next()) |part| {
if (i >= cols.len) return RowError.WrongColumnCount;
cols[i] = std.mem.trim(u8, part, " \t\r");
i += 1;
}
if (i != cols.len) return RowError.WrongColumnCount;
return .{
.id = cols[0],
.email = cols[1],
.nome = cols[2],
.criado_em = cols[3],
.plano = cols[4],
};
}
Para migração crítica, registre no README do importador qual dialeto CSV foi assumido: delimitador, encoding, aspas, escape e política de linha vazia. Isso evita que alguém rode o binário em outro arquivo achando que todo CSV é igual.
Validação deve explicar o motivo
Um ETL bom não para no primeiro dado ruim, a menos que o contrato exija. Normalmente você quer processar o máximo possível e gerar arquivo de rejeições.
fn normalize(raw: RawUser) RowError!User {
if (raw.id.len == 0) return RowError.MissingId;
if (!std.mem.containsAtLeast(u8, raw.email, 1, "@")) return RowError.InvalidEmail;
if (!looksLikeIsoDate(raw.criado_em)) return RowError.InvalidDate;
const plan: Plan = if (std.mem.eql(u8, raw.plano, "free"))
.free
else if (std.mem.eql(u8, raw.plano, "pro"))
.pro
else if (std.mem.eql(u8, raw.plano, "enterprise"))
.enterprise
else
return RowError.InvalidPlan;
return .{
.id = raw.id,
.email = raw.email,
.name = raw.nome,
.created_at = raw.criado_em,
.plan = plan,
};
}
A função looksLikeIsoDate não precisa fingir ser calendário completo se o contrato só pede YYYY-MM-DD, mas precisa deixar claro o que valida. Se datas inválidas como 2026-99-99 são perigosas, faça parsing real. Para migrações financeiras, de acesso ou compliance, validação superficial não basta.
Escrevendo JSONL sem montar strings gigantes
Evite concatenar strings manualmente. Use std.json.stringify quando possível, ou escreva campo a campo com escape correto. Um padrão direto:
fn writeUserJsonl(writer: anytype, user: User) !void {
try writer.writeByte('{');
try writeJsonField(writer, "id", user.id, true);
try writeJsonField(writer, "email", user.email, true);
try writeJsonField(writer, "name", user.name, true);
try writeJsonField(writer, "created_at", user.created_at, true);
try writeJsonField(writer, "plan", @tagName(user.plan), false);
try writer.writeAll("}\n");
}
fn writeJsonField(writer: anytype, key: []const u8, value: []const u8, comma: bool) !void {
try writer.print("\"{s}\":", .{key});
try std.json.stringify(value, .{}, writer);
if (comma) try writer.writeByte(',');
}
O detalhe importante é escapar valores. Nome de usuário pode conter aspas, barra, acento, emoji ou caracteres de controle. Se você escreve "name":" manualmente e cola bytes crus, uma linha malformada quebra o arquivo inteiro.
Rejeições: trate como produto, não lixo
Crie um arquivo .rejects.jsonl com linha, motivo e amostra segura:
{"line":17,"error":"InvalidEmail","raw":"17,sem-email,Maria,2026-05-01,pro"}
Em alguns domínios, o dado bruto contém informação pessoal ou segredo. Nesse caso, grave hash, ID e motivo, não a linha completa. O objetivo é permitir correção sem vazar dados.
O fluxo de processamento pode ficar assim:
fn processLine(allocator: std.mem.Allocator, writer: anytype, line_no: usize, line: []const u8) !void {
_ = allocator;
const raw = parseRawUser(line) catch |err| {
try writeReject(line_no, err, line);
return;
};
const user = normalize(raw) catch |err| {
try writeReject(line_no, err, line);
return;
};
try writeUserJsonl(writer, user);
}
Em produção, writeReject deve receber um writer próprio para rejeições, não usar global. O exemplo está curto para destacar a política: rejeição não é exceção fatal, é resultado esperado de uma fronteira de dados.
Checkpoint e retomada
Para arquivos pequenos, reprocessar do zero é aceitável. Para milhões de linhas ou destino remoto, checkpoint vira obrigatório. A forma mais simples é gravar um arquivo lateral:
input_sha256=...
last_committed_line=250000
output_path=users.normalized.jsonl
started_at=2026-06-05T10:00:00Z
Só avance last_committed_line depois que o destino confirmar escrita. Se o destino é JSONL local, flush periódico basta. Se é banco, avance depois do commit da transação. Se é API, avance depois de resposta confirmada e idempotente.
A regra: checkpoint não registra “linha lida”; registra “efeito seguro”. Essa diferença evita pular dados quando o processo cai entre leitura e escrita.
Migração para banco: lotes pequenos e idempotentes
Quando a saída final é banco de dados, prefira lotes. Um lote de 500 a 5.000 registros costuma equilibrar throughput e risco, mas depende do destino. Use transação, chave única e operação idempotente:
insert ... on conflict do updatequando a migração pode ser reexecutada;- tabela temporária mais merge quando há validação relacional;
dry-runque conta mudanças sem aplicar;--limitpara ensaiar em produção com fatia pequena.
Mesmo que Zig faça a transformação, nada impede usar SQLite como staging. Muitas migrações ficam melhores em duas fases: Zig normaliza CSV para JSONL/SQLite; SQL faz joins, constraints e relatórios.
Métricas mínimas do job
Todo importador deve imprimir um resumo final em formato copiável:
processed=120000 ok=119420 rejected=580 duration_ms=8421 output=users.normalized.jsonl rejects=users.rejects.jsonl
Se o job roda em CI ou cron, esse resumo vira evidência. Se roda manualmente, evita a pergunta “será que deu certo?”. Para jobs recorrentes, adicione também hash do arquivo de entrada, versão do binário e nome da regra aplicada.
Testes com fixtures pequenas
ETL sem fixture vira superstição. Crie arquivos em tests/fixtures:
users.valid.csvcom 3 linhas boas;users.invalid-email.csvcom erro esperado;users.extra-column.csvpara contrato quebrado;users.unicode.csvcom acentos brasileiros;users.empty-lines.csvse linha vazia é permitida.
Teste o binário ou as funções puras. O mais importante é travar comportamento de rejeição. Mudança que transforma rejeição em dado aceito precisa ser consciente.
Segurança e privacidade
Importador de dados costuma lidar com PII, tokens, IDs internos e informações comerciais. Algumas práticas simples reduzem risco:
- não logar linha completa por padrão;
- mascarar e-mail, CPF, telefone e tokens nos rejeitados;
- aceitar segredo por variável de ambiente ou arquivo protegido, não por argumento visível em
ps; - escrever saída com permissões restritas quando o ambiente exige;
- apagar arquivos temporários depois de validação;
- documentar retenção dos rejects.
O fato de ser “só um script” não diminui o impacto de vazar uma exportação inteira em logs.
Checklist de produção
Antes de confiar em um ETL Zig, passe por esta lista:
- O formato de entrada está documentado?
- O parser lida com aspas, escape e encoding conforme necessário?
- Há limite de tamanho de linha e arquivo?
- Rejeições têm motivo rastreável?
- O job pode rodar de novo sem duplicar efeito?
- Existe
--dry-runou modo de amostra? - O resumo final mostra processados, aceitos e rejeitados?
- Fixtures cobrem Unicode, coluna faltante e coluna extra?
- Dados sensíveis não aparecem em logs?
- Checkpoint registra efeito confirmado, não apenas leitura?
Conclusão
Zig não substitui todo stack de dados, mas é uma excelente ferramenta para a parte operacional do ETL: binários pequenos, execução previsível, validação explícita e baixo atrito de distribuição. O segredo é não transformar o importador em um bloco opaco. Separe estágios, faça streaming, registre rejeições, escreva JSONL corretamente, teste fixtures e desenhe retomada desde cedo.
Para projetos brasileiros que recebem planilhas, integrações legadas e exportações de sistemas diferentes, esse tipo de ferramenta paga rápido. O primeiro ganho é performance; o segundo, mais importante, é confiança. Quando a migração falha, você sabe exatamente em qual linha, por qual motivo e qual efeito já foi confirmado.