O PostgreSQL JSONB no Node.js permite armazenar documentos flexíveis sem abandonar transações, SQL, constraints e joins. Ele é útil para metadados, configurações, respostas de integrações, atributos variáveis e dados cuja estrutura muda com frequência.
JSONB, porém, não transforma PostgreSQL em um banco sem schema. Documentos grandes e imprevisíveis dificultam consultas, índices e migrations. O melhor resultado surge quando colunas relacionais guardam campos estáveis e JSONB guarda somente a parte realmente flexível.
Neste guia, você aprenderá a inserir e consultar JSONB com o pacote pg, usar operadores, GIN, expression indexes, jsonpath, updates parciais, validação e práticas para evitar tabelas que se tornam um depósito de objetos sem contrato.
JSON ou JSONB?
A documentação oficial de tipos JSON do PostgreSQL explica que json preserva o texto original, enquanto jsonb armazena uma representação binária processável e indexável.
Na maioria das aplicações, prefira JSONB porque:
- valida JSON na entrada;
- permite índices GIN;
- oferece containment e jsonpath;
- não precisa reparsar texto em cada consulta;
- suporta updates por caminho.
Use json somente quando a representação textual exata, ordem de chaves ou espaços precisa ser preservada.
Modelagem híbrida
CREATE TABLE products (
id uuid PRIMARY KEY,
name text NOT NULL,
price_cents integer NOT NULL CHECK (price_cents >= 0),
attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);Nome e preço são campos estáveis, portanto permanecem relacionais. Cor, material e dimensões variáveis podem ficar em attributes.
Inserindo com Node.js
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
const attributes = {
color: 'blue',
dimensions: {
width: 20,
height: 10
},
tags: ['sale', 'summer']
};
await pool.query(
`INSERT INTO products
(id, name, price_cents, attributes)
VALUES ($1, $2, $3, $4::jsonb)`,
[crypto.randomUUID(), 'Produto', 15990, JSON.stringify(attributes)]
);O pacote pg também serializa objetos em vários casos, mas converter explicitamente deixa a intenção clara.
Consultando uma chave
SELECT
id,
name,
attributes->>'color' AS color
FROM products
WHERE attributes->>'color' = 'blue';-> retorna JSONB. ->> retorna texto.
Caminhos aninhados
SELECT
attributes #>> '{dimensions,width}' AS width
FROM products;Para comparar como número:
SELECT *
FROM products
WHERE (attributes #>> '{dimensions,width}')::numeric > 15;Conversões repetidas em grandes volumes podem exigir uma coluna gerada ou expression index.
Containment
SELECT *
FROM products
WHERE attributes @> '{"color":"blue"}'::jsonb;O operador @> verifica se o documento contém a estrutura da direita.
Existência de chave
SELECT *
FROM products
WHERE attributes ? 'dimensions';Outros operadores verificam várias chaves:
attributes ?| array['color', 'size']
attributes ?& array['color', 'size']GIN index
CREATE INDEX products_attributes_gin
ON products USING GIN (attributes);O índice padrão suporta containment, existência e consultas jsonpath. Ele pode ficar grande porque indexa muitas chaves e valores.
jsonb_path_ops
CREATE INDEX products_attributes_path_gin
ON products USING GIN (attributes jsonb_path_ops);jsonb_path_ops costuma ser menor e eficiente para @> e jsonpath, mas não suporta operadores de existência. Escolha conforme as consultas reais.
Expression index
Quando uma chave é consultada com frequência:
CREATE INDEX products_color_idx
ON products ((attributes->>'color'));Agora a consulta por cor pode usar um índice B-tree menor.
Índice numérico
CREATE INDEX products_width_idx
ON products (((attributes #>> '{dimensions,width}')::numeric));A expressão na consulta precisa corresponder à expressão do índice.
Atualizando uma chave
UPDATE products
SET attributes = jsonb_set(
attributes,
'{color}',
'"green"'::jsonb,
true
)
WHERE id = $1;O quarto argumento cria a chave quando ela não existe.
Atualização aninhada
UPDATE products
SET attributes = jsonb_set(
attributes,
'{dimensions,width}',
to_jsonb($2::numeric),
true
)
WHERE id = $1;Removendo uma chave
UPDATE products
SET attributes = attributes - 'legacyField'
WHERE id = $1;Para um caminho:
UPDATE products
SET attributes = attributes #- '{dimensions,depth}'
WHERE id = $1;Mesclando objetos
UPDATE products
SET attributes = attributes || $2::jsonb
WHERE id = $1;O operador || faz merge no nível superior. Objetos aninhados não são mesclados recursivamente.
Concorrência
Mesmo alterando uma chave, PostgreSQL atualiza a linha inteira e adquire row lock. Dois updates concorrentes no mesmo documento podem sobrescrever alterações se a aplicação lê, modifica e grava todo o objeto.
Prefira updates atômicos em SQL e use controle otimista:
UPDATE products
SET attributes = jsonb_set(attributes, '{color}', $2::jsonb),
version = version + 1
WHERE id = $1
AND version = $3;Veja Lock Otimista no Node.js.
jsonpath
SELECT *
FROM products
WHERE attributes @? '$.tags[*] ? (@ == "sale")';Ou:
SELECT *
FROM products
WHERE attributes @@ '$.dimensions.width > 15';jsonpath é poderoso, mas consultas complexas podem ser difíceis de manter. Use views ou funções quando a regra é central.
Arrays
SELECT *
FROM products
WHERE attributes @> '{"tags":["sale"]}'::jsonb;Evite arrays enormes dentro da linha. Relações muitos-para-muitos e listas que mudam constantemente costumam funcionar melhor em tabelas.
Agregações
SELECT
attributes->>'color' AS color,
count(*)
FROM products
GROUP BY attributes->>'color';Se esse relatório é frequente, considere uma coluna gerada ou materialized view.
Coluna gerada
ALTER TABLE products
ADD COLUMN color text
GENERATED ALWAYS AS (attributes->>'color') STORED;
CREATE INDEX products_color_generated_idx
ON products(color);Isso facilita constraints e consultas sem abandonar o documento original.
Validação na aplicação
const ProductAttributesSchema = z.object({
color: z.string().optional(),
dimensions: z.object({
width: z.number().positive(),
height: z.number().positive()
}).optional(),
tags: z.array(z.string()).default([])
});
const attributes = ProductAttributesSchema.parse(input.attributes);A validação impede estruturas imprevisíveis, mas não protege escritas feitas fora da aplicação.
Constraints no banco
ALTER TABLE products
ADD CONSTRAINT attributes_is_object
CHECK (jsonb_typeof(attributes) = 'object');Uma regra específica:
ALTER TABLE products
ADD CONSTRAINT color_is_string
CHECK (
NOT attributes ? 'color'
OR jsonb_typeof(attributes->'color') = 'string'
);Schema version
{
"schemaVersion": 2,
"color": "blue",
"dimensions": {}
}Quando a estrutura evolui, use uma versão e faça backfill gradual. Não deixe o código depender de formatos históricos sem estratégia.
Migrations
UPDATE products
SET attributes = jsonb_set(
attributes - 'colour',
'{color}',
attributes->'colour',
true
)
WHERE attributes ? 'colour';Execute em lotes para evitar locks longos. Consulte Migrações de Banco no Node.js.
JSONB e Prisma ou ORMs
ORMs expõem campos JSON, mas operadores avançados podem exigir SQL raw. Sempre use parâmetros, nunca concatene JSONPath ou chaves vindas do usuário sem allowlist.
Veja SQL Injection no Node.js.
Tamanho do documento
Documentos grandes aumentam:
- custo de update;
- WAL;
- replicação;
- contenção por linha;
- uso de memória;
- tempo de transferência.
Separe blobs e coleções grandes em tabelas ou object storage.
Observabilidade
Monitore:
- tamanho médio do JSONB;
- queries lentas;
- uso dos índices;
- full scans;
- crescimento do GIN;
- frequência de updates;
- erros de cast;
- versões de schema.
EXPLAIN
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM products
WHERE attributes @> '{"color":"blue"}'::jsonb;Não crie índice por intuição. Confirme plano, seletividade e tamanho.
Quando usar JSONB
- metadados variáveis;
- integrações externas;
- configurações versionadas;
- atributos opcionais;
- eventos imutáveis;
- dados consultados de forma flexível.
Quando usar colunas normais
- campo obrigatório;
- join frequente;
- constraint importante;
- ordenação e agregação constantes;
- foreign key;
- update independente de alta frequência.
Erros comuns
- Tudo em JSONB: perde constraints e clareza.
- Sem schema: formatos incompatíveis proliferam.
- GIN em documento enorme: índice cresce demais.
- Cast em toda consulta: performance cai.
- Read-modify-write: updates concorrentes se perdem.
- Array gigante: cada alteração reescreve a linha.
- SQL raw inseguro: risco de injection.
- Sem EXPLAIN: índice inútil permanece.
Conclusão
O PostgreSQL JSONB no Node.js combina flexibilidade de documentos com transações e SQL. Operadores, GIN, expression indexes e jsonpath permitem consultas eficientes quando o modelo é planejado.
Mantenha campos estáveis em colunas relacionais, valide documentos, versione estruturas e crie índices a partir das queries reais. JSONB funciona melhor como complemento do modelo relacional, não como substituto de todo o schema.



