PostgreSQL em Zig: libpq, Pool, Transações, Prepared Statements e COPY em Produção

PostgreSQL é o banco relacional que mais aparece em serviços modernos escritos em Zig. Não por acaso: ele é maduro, estável, tem um protocolo binário bem documentado e expõe uma biblioteca C (libpq) que integra naturalmente com a FFI do Zig. O que costuma quebrar em produção não é o Zig e raramente é o Postgres — é a forma como a aplicação gerencia conexões, trata erros e controla transações. Conexão aberta e nunca devolvida, query montada por concatenação de string, transação que não fecha quando dá erro, prepared statement que vaza: qualquer um desses basta para transformar um serviço estável em incidente às 3h da manhã.

A proposta deste guia é mostrar como integrar Zig ao PostgreSQL via libpq com o desenho que se sustenta em produção: um pool pequeno e honesto, prepared statements reais, transações com rollback explícito, COPY para volume, LISTEN/NOTIFY para reatividade leve, tratamento de erro por SQLSTATE e testes contra um banco de verdade. O foco não é reimplementar um driver do zero nem competir com ORMs. É apresentar a fronteira que se repete quando um binário Zig precisa falar com Postgres de forma previsível.

Este artigo aprofunda o tópico de Postgres do guia geral de integração com bancos de dados em Zig e complementa cache LRU com TTL em Zig, Redis em Zig, JWT e autenticação em API Zig, tratamento de erros em Zig e observabilidade em Zig. A mentalidade é a mesma: fronteira pequena, comportamento explícito, dependências reduzidas.

Por que libpq e não um driver Zig puro

Existem drivers PostgreSQL escritos em Zig. Alguns são bons. Mas, para um serviço que precisa entrar em produção e ficar estável por meses, três fatores pesam a favor da libpq:

  1. Maturidade do protocolo: libpq implementa o protocolo v3 (e o caminho para v4), negociação de SSL, autenticação SCRAM/MD5, cancelamento de query em flight, pipeline e notificações assíncronas. Reescrever isso em Zig dá conta recorrente de bugs sutis em corner cases de rede e autenticação.
  2. Manutenção fora do ecossistema Zig: qualquer correção de segurança ou compatibilidade de protocolo vem da equipe do Postgres, independentemente da saúde do ecossistema Zig. Isso reduz risco de suprimentos.
  3. Interop limpa com Zig: libpq é uma API C estável, com tipos claros e ciclo de vida bem definido. A FFI do Zig chama C sem custo extra e sem camadas frágeis.

A desvantagem é a dependência binária: você precisa linkar libpq no build e, idealmente, a mesma versão do cliente no momento da conexão. Em container, isso significa instalar libpq-dev (ou o runtime libpq5) na imagem. Em build estático cruzado, há trabalho extra. Para a maioria dos serviços backend em container Linux, o custo é baixo e o ganho em estabilidade é alto.

O build.zig mínimo

O ponto de partida é um build.zig que declara a dependência C e linka a libpq. A estrutura básica:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "app",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    exe.linkLibC();
    exe.linkSystemLibrary("pq");
    exe.linkSystemLibrary("ssl"); // se o Postgres exigir TLS

    b.installArtifact(exe);
}

Em produção, recomenda-se ler o caminho da libpq e os includes via variáveis do sistema de build ou do ambiente (PKG_CONFIG_PATH), para que o mesmo build.zig funcione em dev local e na pipeline de CI. Evite hardcode de /usr/include/postgresql — ele varia entre Debian, Ubuntu, Alpine e Fedora.

Conexão via PQconnectdb

A função de entrada é PQconnectdb, que recebe uma connection string e devolve um *PGconn. O detalhe importante: PQconnectdb é bloqueante até a conexão estar pronta ou falhar. Para serviços com pool pequeno, isso é aceitável e previsível. Para casos que precisam de centenas de conexões concorrentes não bloqueantes, o caminho é PQconnectStart + PQconnectPoll, que mantém o estado da máquina no loop de eventos — mas isso é complexidade que a maioria dos serviços não precisa.

Um wrapper mínimo e seguro:

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

pub const PgError = error{
    ConnectionFailed,
    BadStatus,
};

pub const Conn = struct {
    raw: *c.PGconn,

    pub fn open(allocator: std.mem.Allocator, conninfo: []const u8) !Conn {
        const cstr = try allocator.dupeZ(u8, conninfo);
        defer allocator.free(cstr);

        const raw = c.PQconnectdb(cstr);
        if (c.PQstatus(raw) != c.CONNECTION_OK) {
            const msg = std.mem.span(c.PQerrorMessage(raw));
            std.log.err("postgres connect failed: {s}", .{msg});
            c.PQfinish(raw);
            return PgError.ConnectionFailed;
        }
        return .{ .raw = raw };
    }

    pub fn close(self: *Conn) void {
        c.PQfinish(self.raw);
    }
};

Pontos a observar nesse snippet:

  • PQconnectdb devolve sempre um ponteiro válido, mesmo em caso de falha. Por isso o PQfinish precisa ser chamado também no caminho de erro, senão vaza memória da libpq.
  • PQstatus é a única fonte de verdade sobre o sucesso da conexão. Não confie no texto de PQerrorMessage para decidir o fluxo — use-o só para log.
  • A connection string contém segredos (senha). Ela deve vir de variável de ambiente ou secret manager, nunca de arquivo versionado. O padrão detalhado está em configuração segura com segredos.
  • Em TLS, force sslmode=verify-full e configure sslrootcert. sslmode=require sem verificação aceita qualquer certificado no handshake e não protege contra MITM ativo.

Pool de conexões: por que e como

Abrir uma conexão por requisição é o erro mais caro em backend novato. O custo de PQconnectdb com TLS é dezenas de milissegundos (negociação TCP, handshake TLS, autenticação SCRAM, startup packet). Em um serviço com 500 req/s, isso vira dezenas de segundos de CPU desperdiçada por segundo só abrindo conexões. A solução é um pool: conjunto pequeno de conexões longas, reaproveitadas entre requisições.

O desenho mais simples que sobrevive em produção é um pool fixo com mutex, sem expansão dinâmica:

pub const Pool = struct {
    allocator: std.mem.Allocator,
    conns: []Conn,
    in_use: []bool,
    mutex: std.Thread.Mutex = .{},

    pub fn init(allocator: std.mem.Allocator, size: usize, conninfo: []const u8) !Pool {
        var conns = try allocator.alloc(Conn, size);
        errdefer allocator.free(conns);
        var in_use = try allocator.alloc(bool, size);
        @memset(in_use, false);

        var opened: usize = 0;
        errdefer {
            for (0..opened) |i| conns[i].close();
        }
        while (opened < size) : (opened += 1) {
            conns[opened] = try Conn.open(allocator, conninfo);
        }

        return .{ .allocator = allocator, .conns = conns, .in_use = in_use };
    }

    pub fn deinit(self: *Pool) void {
        for (self.conns) |*conn| conn.close();
        self.allocator.free(self.conns);
        self.allocator.free(self.in_use);
    }

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

        for (0..self.conns.len) |i| {
            if (!self.in_use[i]) {
                self.in_use[i] = true;
                return &self.conns[i];
            }
        }
        return error.PoolExhausted;
    }

    pub fn release(self: *Pool, conn: *Conn) void {
        self.mutex.lock();
        defer self.mutex.unlock();

        const idx = (@intFromPtr(conn) - @intFromPtr(&self.conns[0])) / @sizeOf(Conn);
        self.in_use[idx] = false;
    }
};

Decisões de desenho que importam em produção:

  • Pool fixo, sem crescimento dinâmico. Crescimento automático esconde estouro de carga e termina virando centenas de conexões que o Postgres rejeita (max_connections). Melhor falhar cedo com PoolExhausted e expor a métrica.
  • Tamanho do pool = limite de concorrência real. Se o pool tem 10 conexões, o serviço processa no máximo 10 queries em paralelo. Esse número precisa combinar com max_connections do Postgres multiplicado pelo número de réplicas da aplicação. Regra prática: (max_connections - margem) / número_de_réplícas_da_app.
  • Reset no release. Em produção, convém executar DISCARD ALL (ou ao menos RESET ALL e DEALLOCATE ALL) quando uma conexão volta ao pool, para evitar vazamento de estado entre locatários (temp tables, SET local, savepoints abertos).
  • Health check periódico. Conexões longas caem por idle de firewall, restart do Postgres, failover. Um pool sério faz SELECT 1 (ou PQping) antes de devolver a conexão ao chamador, e descarta conexões que falham.

Um ponto frequentemente esquecido: o Postgres tem PgBouncer. Se o tráfego cresceu a ponto de precisar de pools enormes por instância, o desenho correto é um pool pequeno por instância na aplicação e um PgBouncer na frente do Postgres em modo transaction pooling. Tentar gerenciar milhares de conexões direto na aplicação quase sempre acaba mal.

Prepared statements de verdade

A segunda fonte clássica de incidentes é query montada por concatenação de string. Além do risco de injeção de SQL, queries ad-hoc não têm plano de execução cacheado no Postgres, então cada execução paga parsing e planejamento. A solução são prepared statements: o parser e o planner rodam uma vez, a aplicação só envia os parâmetros.

Com libpq, o caminho é PQprepare seguido de PQexecPrepared:

pub fn prepare(conn: *Conn, name: []const u8, sql: []const u8) !void {
    const name_z = try std.heap.c_allocator.dupeZ(u8, name);
    defer std.heap.c_allocator.free(name_z);
    const sql_z = try std.heap.c_allocator.dupeZ(u8, sql);
    defer std.heap.c_allocator.free(sql_z);

    const res = c.PQprepare(conn.raw, name_z, sql_z, 0, null);
    defer c.PQclear(res);

    if (c.PQresultStatus(res) != c.PGRES_COMMAND_OK) {
        return PgError.BadStatus;
    }
}

pub fn execPrepared(conn: *Conn, name: []const u8, params: []const [:0]const u8) !void {
    const name_z = try std.heap.c_allocator.dupeZ(u8, name);
    defer std.heap.c_allocator.free(name_z);

    const res = c.PQexecPrepared(
        conn.raw,
        name_z,
        @intCast(params.len),
        params.ptr,
        null,
        null,
        0,
    );
    defer c.PQclear(res);

    if (c.PQresultStatus(res) != c.PGRES_COMMAND_OK and
        c.PQresultStatus(res) != c.PGRES_TUPLES_OK)
    {
        const sqlstate = std.mem.span(c.PQresultErrorField(res, c.PG_DIAG_SQLSTATE) orelse "");
        std.log.err("exec failed sqlstate={s} msg={s}", .{
            sqlstate,
            std.mem.span(c.PQresultErrorMessage(res)),
        });
        return mapError(sqlstate);
    }
}

Pontos críticos:

  • Sempre PQclear. Cada PGresult é um objeto alocado pela libpq que precisa ser liberado. Esquecer PQclear é o memory leak mais comum em código que usa libpq.
  • Use PQexecParams/PQexecPrepared, nunca PQexec com string interpolada. A versão com ?/$1 separa parâmetros do SQL no protocolo, eliminando injeção e escapamento manual.
  • Não reutilize nomes de prepared statement entre conexões. Prepared statements são por conexão. Se o pool reaproveita conexões entre goroutines/threads, o nome precisa ser estável dentro da conexão, mas não entra em conflito entre conexões — usar nomes únicos por query resolve.
  • DEALLOCATE em pools longos. Em conexões que vivem horas, prepared statements podem se acumular se a aplicação cria nomes dinâmicos. Convencione um conjunto fixo de statements preparados no warmup da conexão e nunca crie nomes ad-hoc depois.

Transações com rollback explícito

Transação não fecha sozinha. Se a aplicação começa uma transação (BEGIN), executa duas queries, a segunda falha e o código retorna sem ROLLBACK, a conexão volta para o pool dentro de uma transação abortada. Qualquer comando seguinte nela vai falhar com current transaction is aborted, commands ignored until end of transaction block. É uma das armadilhas mais comuns.

A solução é modelar a transação como um tipo que faz rollback no defer se não foi explicitamente committed:

pub const Tx = struct {
    conn: *Conn,
    committed: bool = false,

    pub fn begin(conn: *Conn) !Tx {
        try execSimple(conn, "BEGIN");
        return .{ .conn = conn };
    }

    pub fn commit(self: *Tx) !void {
        try execSimple(self.conn, "COMMIT");
        self.committed = true;
    }

    pub fn rollback(self: *Tx) void {
        if (self.committed) return;
        execSimple(self.conn, "ROLLBACK") catch {};
        self.committed = true;
    }

    pub fn deinit(self: *Tx) void {
        self.rollback();
    }
};

Uso:

var tx = try Tx.begin(conn);
defer tx.deinit();

try execPrepared(conn, "insert_order", &.{ customer_id });
try execPrepared(conn, "insert_order_item", &.{ product_id, qty });

try tx.commit();

Detalhes que importam:

  • defer tx.deinit() sempre. Mesmo no caminho feliz, ele é inofensivo (vira no-op depois do commit). No caminho de erro, garante rollback.
  • Erro dentro da transação aborta tudo. Após o primeiro erro, qualquer statement seguinte falha até ROLLBACK. Não tente “recuperar” dentro da mesma transação — faça rollback e comece outra.
  • SAVEPOINT para unidades menores. Se dentro de uma transação maior existe uma operação que pode falhar isoladamente (ex: inserir item opcional), use SAVEPOINT sp1; ... ROLLBACK TO sp1; para descartar só aquele pedaço sem abortar a transação inteira.
  • Não segure transação através de I/O externo. Uma transação aberta segura locks e um slot no pool. Se dentro dela a aplicação chama uma API HTTP lenta, o tempo do Postgres explode. Padrão seguro: colete tudo que precisa, abra a transação, faça as queries, feche. O pattern detalhado está em circuit breaker e timeout em Zig.

Erros por SQLSTATE

Erros do Postgres vêm com um código SQLSTATE de 5 caracteres no campo PG_DIAG_SQLSTATE. Esse código é estável entre versões e é a forma correta de classificar o erro. Os mais comuns em backend:

  • 23505unique_violation. Conflito de chave única/primary. Normalmente esperado em upsert ou idempotência; trate como negócio, não como erro 500.
  • 23503foreign_key_violation. Referência quebrada. Geralmente erro de dados inconsistentes ou ordem errada de inserção.
  • 40001serialization_failure. Conflito de serialização em SERIALIZABLE. É o sinal para retry da transação inteira.
  • 40P01deadlock_detected. Deadlock; o Postgres matou uma das transações. Retrabalhe com backoff.
  • 57P03cannot_connect_now. Banco em startup; conexão caiu. Renova conexão e tenta de novo.
  • 08006connection_failure. Conexão perdida em flight. Renova e decide política (fail-fast ou retry).
  • 22001string_data_right_truncation. Tentou gravar string maior que a coluna. Erro de validação de schema.

Mapear isso para um error set Zig dá clareza:

pub const PgSqlState = error{
    UniqueViolation,
    ForeignKeyViolation,
    SerializationFailure,
    DeadlockDetected,
    ConnectionLost,
};

pub fn mapError(sqlstate: []const u8) PgSqlState {
    if (std.mem.eql(u8, sqlstate, "23505")) return error.UniqueViolation;
    if (std.mem.eql(u8, sqlstate, "23503")) return error.ForeignKeyViolation;
    if (std.mem.eql(u8, sqlstate, "40001")) return error.SerializationFailure;
    if (std.mem.eql(u8, sqlstate, "40P01")) return error.DeadlockDetected;
    if (std.mem.eql(u8, sqlstate, "57P03") or std.mem.eql(u8, sqlstate, "08006"))
        return error.ConnectionLost;
    return error.GenericPgError;
}

A decisão de retry não deve ser aleatória. SerializationFailure e DeadlockDetected justificam retry com backoff; UniqueViolation normalmente não. ConnectionLost exige fechar a conexão e abrir outra. Sem essa classificação, o serviço ou morre no primeiro erro ou entra em loop de retry inútil.

COPY: a forma rápida de inserir volume

Para carregar muitos registros (importação, ETL, ingestão), o comando COPY é ordens de magnitude mais rápido que INSERT em lote. Ele usa um protocolo binário ou texto direto, sem parsing por linha, sem planning por linha. Em um serviço que recebe arquivos grandes (log, CSV, JSONL), o padrão é COPY streaming. O guia ETL com CSV e JSONL em Zig cobre o lado da leitura; aqui vai o lado Postgres.

pub fn copyFromStdin(conn: *Conn, copy_cmd: []const u8) !void {
    const cmd_z = try std.heap.c_allocator.dupeZ(u8, copy_cmd);
    defer std.heap.c_allocator.free(cmd_z);

    const res = c.PQexec(conn.raw, cmd_z);
    defer c.PQclear(res);
    if (c.PQresultStatus(res) != c.PGRES_COPY_IN) {
        return PgError.BadStatus;
    }
}

pub fn copyData(conn: *Conn, data: []const u8) !void {
    const ok = c.PQputCopyData(conn.raw, data.ptr, @intCast(data.len));
    if (ok != 1) return PgError.BadStatus;
}

pub fn copyEnd(conn: *Conn, err_msg: ?[]const u8) !void {
    const msg_z: ?[*:0]const u8 = if (err_msg) |m|
        std.heap.c_allocator.dupeZ(u8, m).ptr
    else
        null;
    defer if (msg_z) |z| std.heap.c_allocator.free(std.mem.span(z));

    const end_ok = c.PQputCopyEnd(conn.raw, msg_z);
    if (end_ok != 1) return PgError.BadStatus;

    const res = c.PQgetResult(conn.raw);
    defer c.PQclear(res);
    if (c.PQresultStatus(res) != c.PGRES_COMMAND_OK) {
        return PgError.BadStatus;
    }
}

O fluxo completo: COPY tabela (col1, col2) FROM STDIN WITH (FORMAT csv) → loop de PQputCopyData com pedaços do arquivo → PQputCopyEnd(NULL) em sucesso ou PQputCopyEnd("motivo do erro") em falha. O detalhe: PQputCopyEnd com mensagem não nula aborta o COPY e o Postgres faz rollback da inserção parcial — útil para descartar tudo quando detecta linha inválida.

Em volume real (milhões de linhas), COPY vs INSERT batch é a diferença entre 8 segundos e 6 minutos. Quem inventa ETL sem COPY normalmente descobre isso em produção.

LISTEN/NOTIFY: reatividade barata

Para cenários em que a aplicação precisa reagir a mudanças no banco sem polling (ex: invalidar cache quando uma linha muda, disparar job quando um registro é criado), o Postgres oferece LISTEN/NOTIFY. É leve, sem broker externo, e suficiente para volumes pequenos/médios (até algumas centenas de eventos por segundo).

Com libpq, o padrão é LISTEN canal na conexão e depois ler notificações via PQnotifies. Como PQnotifies é não bloqueante, ele encaixa em um loop de eventos:

pub fn listen(conn: *Conn, channel: []const u8) !void {
    const sql = try std.fmt.allocPrintZ(
        std.heap.c_allocator,
        "LISTEN {s}",
        .{channel},
    );
    defer std.heap.c_allocator.free(sql);
    const res = c.PQexec(conn.raw, sql);
    defer c.PQclear(res);
    if (c.PQresultStatus(res) != c.PGRES_COMMAND_OK) {
        return PgError.BadStatus;
    }
}

pub fn pollNotify(conn: *Conn) ?Notify {
    if (c.PQnotifies(conn.raw)) |n| {
        return .{
            .channel = std.mem.span(n.relname),
            .payload = std.mem.span(n.extra),
            .pid = n.be_pid,
        };
    }
    return null;
}

Atenção: o nome do canal em LISTEN não aceita parâmetro bind — é um identificador, não uma string. Se o canal vem de input externo, valide como identificador antes; nunca interpole direto. Para múltiplos canais dinâmicos, prefira um canal fixo e carregue o tópico real no payload do NOTIFY.

LISTEN/NOTIFY não substitui Kafka ou RabbitMQ para volume alto ou durabilidade essencial (se a conexão cai durante o NOTIFY, o evento é perdido). Mas substitui polling de tabela com vantagem em muitos serviços pequenos, reduzindo carga no banco.

Testes com banco de verdade

Testar código de banco com mocks quase sempre dá falsa confiança. O comportamento que importa — transação abortada, deadlock, ON CONFLICT, SQLSTATE — só aparece com um Postgres real. O padrão estável é um container Postgres efêmero por suíte de teste, criado e destruído a cada execução:

docker run --rm -d -p 5433:5432 \
  -e POSTGRES_PASSWORD=test \
  -e POSTGRES_DB=app_test \
  postgres:16

Os testes sobem o schema (psql -f schema.sql), executam queries e desmontam. Em CI, isso roda como service container. O custo é de 1 a 2 segundos por suíte; o ganho é confiança real nas transações, no COPY e no tratamento de erro.

Padrões de teste que se sustentam:

  • Cada teste em transação própria que faz rollback no fim. Evita poluir o banco entre testes e mantém a suíte determinística.
  • Teste os caminhos de erro explícitos: unique violation, foreign key, conexão derrubada (feche o PGconn no meio), COPY com linha inválida.
  • Teste o pool sob exaustão: adquira todas as conexões, tente adquirir mais uma, confirme PoolExhausted.
  • Não mocke libpq. Se o teste precisa mockar a FFI para passar, é sinal de que a fronteira está mal desenhada — extraia a lógica de domínio para fora do wrapper de libpq e teste essa lógica isolada.

Observabilidade da camada de banco

Banco é a dependência que mais causa incidentes. Métrica específica é obrigatória:

  • Tempo de query por operação (p50/p95/p99). Não agregue tudo; separe por nome de prepared statement.
  • Taxa de erro por SQLSTATE. 23505 esperado não é incidente; 40P01 crescente é.
  • Uso do pool: conexões livres, tempo de acquire, contagem de PoolExhausted.
  • Tempo de acquire. Se sobe, o pool está pequeno ou alguma query está segurando conexão demais.
  • Falha de health check do Postgres. Precisa de alerta antes que a aplicação perceba.

Essas métricas entram na mesma malha de observabilidade e OpenTelemetry. Em particular, o span do trace deve cobrir desde o acquire do pool até o release, incluindo o tempo da query em si — isso diferencia “banco lento” de “pool esgotado”.

Quando NÃO usar libpq direto

libpq direto é a ferramenta certa para serviços em que o controle explícito compensa a verbosidade. Mas nem sempre:

  • Domínio com muito CRUD repetitivo e baixo risco: um driver Zig de nível mais alto com query builder reduz boilerplate sem custo real. O ganho de manutenção supera a perda de controle.
  • Migrações: use uma ferramenta dedicada (migrate, sqlx, golang-migrate). Não tente gerenciar schema_migrations na mão.
  • Volume extremo de conexões concorrentes não bloqueantes: libpq bloqueante em pool fixo tem limite. Para milhares de conexões simultâneas (cada uma esperando I/O), o caminho é o protocolo binário assíncrono ou um PgBouncer na frente — não escalar o pool da aplicação.
  • Múltiplas fontes de dados com consistência entre elas: a saga / outbox / CDC normalmente vive fora do código de banco. Não tente resolver distribuição com transação local.

A regra é a de sempre: comece com a fronteira mais simples que cobre o caso, adicione camada só quando o custo de manutenção da simplicidade aparece.

Checklist de produção

Antes de considerar a integração Postgres pronta para produção:

  • pool de conexões com tamanho definido, health check e reset no release;
  • prepared statements para todas as queries parametrizadas, preparados no warmup;
  • transações modeladas com rollback no defer, nunca soltas;
  • mapeamento de SQLSTATE para error set, com retry apenas para os códigos certos;
  • COPY para volume, nunca INSERT em loop para milhares de linhas;
  • sslmode=verify-full com root cert configurado;
  • connection string fora do repositório, vinda de env ou secret manager;
  • PgBouncer na frente se o número de réplicas da aplicação × tamanho do pool ameaça max_connections;
  • testes contra Postgres real em CI, inclusive caminhos de erro e exaustão de pool;
  • métricas de latência por query, erro por SQLSTATE e uso do pool exportadas;
  • alerta para PoolExhausted crescente e para latência p95 acima do SLO;
  • documentação das queries críticas com dono, expected p95 e plano de rollback.

Onde Zig contribui para essa integração

A vantagem de Zig aqui não é velocidade bruta de execução de query — o tempo de rede e do Postgres domina. A vantagem é clareza operacional. O pool é visível, os limites são explícitos, o caminho de erro é nomeado, o defer garante rollback. Não há “contexto mágico” que esconde conexão aberta, não há ORM que decide fazer N+1 silenciosamente, não há pool que cresce sem limite. Cada decisão de desenho fica no código, e decisões visíveis são o que diferencia um serviço estável de um que vira incidente recorrente.

Para times que operam serviços em Go, a comparação é direta. Há material de referência em Golang Brasil sobre pools de conexão, prepared statements e transações que traduz para o mesmo desenho apresentado aqui — a escolha entre as linguagens cai para o domínio do controle de memória, tamanho do binário e dependências, não para a arquitetura de acesso a dados.

Comece pequeno: uma única query parametrizada com prepared statement e um pool de cinco conexões. Quando isso estiver estável, adicione transações com rollback explícito. Só depois introduza COPY e LISTEN/NOTIFY para os casos que justificam. Cada camada adiciona poder e complexidade operacional; o melhor desenho é aquele em que cada peça tem um motivo claro para existir.

Continue aprendendo Zig

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