---
title: "PostgreSQL em Zig: libpq, Pool, Transações, Prepared Statements e COPY em Produção"
url: "https://ziglang.com.br/artigos/zig-postgresql-libpq-producao/"
markdown_url: "https://ziglang.com.br/artigos/zig-postgresql-libpq-producao.MD"
description: "Como integrar Zig ao PostgreSQL via libpq em produção: pool de conexões, prepared statements, transações com SAVEPOINT, COPY rápido, LISTEN/NOTIFY, erros e testes com um banco real."
date: "2026-06-21"
author: ""
---

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

Como integrar Zig ao PostgreSQL via libpq em produção: pool de conexões, prepared statements, transações com SAVEPOINT, COPY rápido, LISTEN/NOTIFY, erros e testes com um banco real.


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](/artigos/zig-banco-dados-integracoes/) e complementa [cache LRU com TTL em Zig](/artigos/zig-cache-lru-ttl-producao/), [Redis em Zig](/artigos/zig-redis-cache-rate-limit-lock/), [JWT e autenticação em API Zig](/artigos/zig-jwt-autenticacao-api/), [tratamento de erros em Zig](/artigos/zig-error-handling-boas-praticas/) e [observabilidade em Zig](/artigos/zig-observabilidade/). 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:

```zig
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:

```zig
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](/artigos/zig-configuracao-segura-segredos-env/).
- 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:

```zig
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`:

```zig
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:

```zig
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:

```zig
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](/artigos/zig-circuit-breaker-timeout-retry/).

## 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:

- `23505` — **unique_violation**. Conflito de chave única/primary. Normalmente esperado em upsert ou idempotência; trate como negócio, não como erro 500.
- `23503` — **foreign_key_violation**. Referência quebrada. Geralmente erro de dados inconsistentes ou ordem errada de inserção.
- `40001` — **serialization_failure**. Conflito de serialização em `SERIALIZABLE`. É o sinal para retry da transação inteira.
- `40P01` — **deadlock_detected**. Deadlock; o Postgres matou uma das transações. Retrabalhe com backoff.
- `57P03` — **cannot_connect_now**. Banco em startup; conexão caiu. Renova conexão e tenta de novo.
- `08006` — **connection_failure**. Conexão perdida em flight. Renova e decide política (fail-fast ou retry).
- `22001` — **string_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:

```zig
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](/artigos/zig-etl-csv-jsonl-migracao-dados/) cobre o lado da leitura; aqui vai o lado Postgres.

```zig
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:

```zig
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:

```bash
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](/artigos/zig-observabilidade/) e [OpenTelemetry](/artigos/zig-opentelemetry-traces-metricas-logs/). 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 <a href="https://golang.com.br/" target="_blank" rel="noopener noreferrer" onclick="umami.track('portfolio-site-click', { destination: 'golang.com.br' })">Golang Brasil</a> 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.
