
JSON vs JSONB
PostgreSQL ofrece dos tipos para datos semiestructurados:
JSON: guarda el texto exacto tal como se insertó. Preserva whitespace, duplicados de claves y orden. Bueno si necesitas fidelidad al original (p. ej. logs de API).JSONB: formato binario optimizado. Descarta whitespace, deduplica claves (la última gana), no preserva orden. Indexable con GIN. Acceso mucho más rápido.
Regla: usa JSONB salvo que necesites preservar el texto original. Para el 99% de casos es la opción correcta.
CREATE TABLE eventos (
id SERIAL PRIMARY KEY,
datos JSONB NOT NULL,
creado_en TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO eventos (datos) VALUES
('{"tipo": "login", "usuario": {"id": 42, "nombre": "alan"}, "tags": ["web", "chrome"]}'),
('{"tipo": "compra", "usuario": {"id": 42, "nombre": "alan"}, "importe": 99.5, "items": [{"sku": "ABC", "cant": 2}]}');
Operadores básicos
-> devuelve JSON, ->> devuelve texto
-- -> devuelve JSON (manteniendo el tipo)
SELECT datos->'usuario' FROM eventos;
-- {"id": 42, "nombre": "alan"}
-- ->> devuelve texto (string plano)
SELECT datos->>'tipo' FROM eventos;
-- login, compra
-- Encadenados: acceder a campos anidados
SELECT datos->'usuario'->>'nombre' FROM eventos;
-- alan, alan
#> y #>> con path
Alternativa para acceso anidado pasando un path como array:
SELECT datos #> '{usuario,nombre}' FROM eventos; -- JSON
SELECT datos #>> '{usuario,nombre}' FROM eventos; -- texto
@> contenencia
El operador @> (contains) es el más útil para filtrar por contenido:
-- Eventos donde el usuario.id es 42
SELECT * FROM eventos WHERE datos @> '{"usuario": {"id": 42}}';
-- Eventos con tag "web" en el array tags
SELECT * FROM eventos WHERE datos @> '{"tags": ["web"]}';
@> comprueba que el lado derecho es subconjunto del izquierdo. La mayoría de índices GIN están optimizados para este operador.
?, ?|, ?& para claves
-- Eventos que tienen la clave "importe"
SELECT * FROM eventos WHERE datos ? 'importe';
-- Eventos que tienen CUALQUIERA de estas claves (OR)
SELECT * FROM eventos WHERE datos ?| ARRAY['importe', 'tags'];
-- Eventos que tienen TODAS estas claves (AND)
SELECT * FROM eventos WHERE datos ?& ARRAY['tipo', 'usuario'];
Acceso a arrays con índice
-- Primer tag
SELECT datos->'tags'->>0 FROM eventos;
-- Longitud del array
SELECT jsonb_array_length(datos->'tags') FROM eventos;
-- Desensamblar array a filas
SELECT id, tag
FROM eventos, jsonb_array_elements_text(datos->'tags') AS tag;
Indexación con GIN
Sin índice, cualquier consulta que filtre por contenido de JSONB hace un Seq Scan. Con GIN, se vuelven instantáneas.
GIN default (jsonb_ops)
CREATE INDEX idx_eventos_datos ON eventos USING GIN(datos);
-- Ahora estas queries usan el indice:
SELECT * FROM eventos WHERE datos @> '{"usuario": {"id": 42}}';
SELECT * FROM eventos WHERE datos ? 'importe';
SELECT * FROM eventos WHERE datos @> '{"tags": ["web"]}';
Este índice soporta todos los operadores pero ocupa más espacio.
GIN con jsonb_path_ops
Solo soporta @>, pero es más pequeño y más rápido que el default:
CREATE INDEX idx_eventos_datos_path ON eventos USING GIN(datos jsonb_path_ops);
Regla: si solo usas @> (lo más común), jsonb_path_ops es mejor.
Índices sobre campos concretos
Si solo consultas campos específicos:
-- Indice B-tree sobre un campo extraido
CREATE INDEX idx_eventos_usuario_id
ON eventos((datos->'usuario'->>'id'));
SELECT * FROM eventos WHERE datos->'usuario'->>'id' = '42';
Útil cuando el campo es muy selectivo y las consultas no necesitan contenencia general.
Construir JSONB dinámicamente
jsonb_build_object
Equivalente a construir un diccionario:
SELECT jsonb_build_object(
'id', 1,
'nombre', 'Alan',
'roles', jsonb_build_array('admin', 'editor')
);
-- {"id": 1, "nombre": "Alan", "roles": ["admin", "editor"]}
jsonb_agg para agregar filas
Convierte filas en un array JSON:
SELECT cliente_id,
jsonb_agg(jsonb_build_object('id', id, 'fecha', fecha, 'total', total)) AS pedidos
FROM pedidos
GROUP BY cliente_id;
-- cliente_id | pedidos
-- 42 | [{"id":1,"fecha":"2026-04-01","total":100},{"id":2,...}]
Útil para APIs REST: devolver el cliente con sus pedidos anidados en una sola query.
jsonb_object_agg
Como jsonb_agg pero construye un objeto con clave/valor:
SELECT jsonb_object_agg(codigo, nombre) FROM productos;
-- {"ABC": "Laptop", "DEF": "Tablet", ...}
Modificar JSONB
jsonb_set para actualizar un campo
-- Actualizar datos->'usuario'->'rol' a 'admin'
UPDATE eventos
SET datos = jsonb_set(datos, '{usuario,rol}', '"admin"', true)
WHERE id = 1;
El cuarto parámetro (true) crea el path si no existe.
|| para merge (concatenar)
-- Anadir campos sin sobrescribir
UPDATE eventos
SET datos = datos || '{"verificado": true, "revisado_por": "admin"}'::jsonb
WHERE id = 1;
Si las claves existen, el de la derecha las sobrescribe.
- para eliminar claves
-- Eliminar una clave
UPDATE eventos SET datos = datos - 'campo_temporal';
-- Eliminar varias
UPDATE eventos SET datos = datos - ARRAY['campo1', 'campo2'];
-- Eliminar un path anidado
UPDATE eventos SET datos = datos #- '{usuario,telefono}';
SQL/JSON path (PostgreSQL 12+)
Estándar SQL:2016 para consultas JSON complejas:
-- Encontrar eventos cuyo items contenga uno con cant > 1
SELECT * FROM eventos
WHERE jsonb_path_exists(datos, '$.items[*] ? (@.cant > 1)');
-- Extraer todos los SKUs
SELECT jsonb_path_query_array(datos, '$.items[*].sku') AS skus
FROM eventos WHERE datos->>'tipo' = 'compra';
-- ["ABC"]
Sintaxis compacta y potente, similar a XPath pero para JSON.
Caso real: configuración flexible por cliente
CREATE TABLE configuraciones (
cliente_id INT PRIMARY KEY,
config JSONB NOT NULL DEFAULT '{}'
);
-- Cada cliente tiene su config:
INSERT INTO configuraciones VALUES
(1, '{"tema": "oscuro", "idioma": "es", "notificaciones": {"email": true, "sms": false}}'),
(2, '{"tema": "claro", "idioma": "en", "features": ["beta"]}');
-- Clientes con notificaciones por email activadas
SELECT cliente_id FROM configuraciones
WHERE config @> '{"notificaciones": {"email": true}}';
-- Actualizar solo un campo
UPDATE configuraciones
SET config = jsonb_set(config, '{tema}', '"claro"')
WHERE cliente_id = 1;
-- Merge con valores por defecto
SELECT cliente_id,
'{"tema":"claro","idioma":"es","notificaciones":{"email":true,"sms":false}}'::jsonb || config AS config_efectiva
FROM configuraciones;
Rendimiento: cuándo usar JSONB vs columnas
Usa columnas relacionales cuando:
- El esquema es estable y todos los registros tienen los mismos campos.
- Las consultas filtran/ordenan frecuentemente por esos campos.
- Necesitas foreign keys y constraints sobre esos campos.
Usa JSONB cuando:
- El esquema varía por registro (configuraciones, metadatos).
- Los campos raramente se filtran (guardas y lees, pero no buscas por ellos).
- Integras con APIs que ya devuelven JSON.
- Los datos tienen profundidad arbitraria (arrays anidados).
Un anti-patrón común: usar JSONB para TODO en lugar de columnas reales. Pierdes los beneficios del modelo relacional. La regla: estructura lo estable en columnas, semiestructura en JSONB.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, SQL es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de SQL
Explora más contenido relacionado con SQL y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Diferenciar JSON vs JSONB y elegir el correcto para cada caso. Consultar campos anidados con los operadores ->, ->> y #>. Usar @> y ? para filtrar por contenido dentro del documento. Indexar JSONB con GIN y jsonb_path_ops para acelerar busquedas. Construir documentos con jsonb_build_object, jsonb_build_array y jsonb_agg en agregaciones. Aplicar jsonb_path_query (SQL/JSON path) para extracciones avanzadas.
Cursos que incluyen esta lección
Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje