Todo el curso ha dado por supuesto que sabes qué columnas necesitas. Y casi siempre es cierto: un pedido tiene cliente, fecha, estado y portes, y eso no cambia. Pero hay datos que no tienen forma fija. Un aceite de oliva tiene acidez, variedad de aceituna y método de extracción; una crema tiene ingredientes, tipo de piel y volumen; un cepillo de bambú no tiene ninguna de esas cosas y sí tiene tipo de cerda. Añadir una columna por atributo daría una tabla de ochenta columnas casi todas nulas, y montar una tabla de pares clave-valor —el clásico EAV— convierte cualquier consulta en un rompecabezas de autouniones.
Para eso PostgreSQL tiene JSONB: una columna que guarda un documento con estructura propia, indexable y consultable con SQL. Aquí se cierra la promesa de 08-03 (índices GIN sobre JSON) y se cubre el caso de la respuesta de una API, las preferencias de usuario o el registro de eventos. Verás cómo construir documentos, cómo leerlos, cómo modificarlos, cómo volver a convertirlos en filas para seguir usando todo el SQL del curso, y —lo más importante de la lección— qué no debe ir nunca dentro de un JSON.
Contenido
- Cuándo un esquema fijo no basta
JSONfrente aJSONB- Construir documentos
- La columna
productos.atributosde TiendaVerde - Acceder:
->,->>,#>,#>> - Buscar: contención, existencia y JSONPath
- Modificar documentos
- Expandir a filas: el puente de vuelta al SQL
- Indexación con GIN
- La discusión de diseño: qué NO va en JSON
- Errores Comunes y Consejos
- Ejercicios
- Conclusión del módulo
- Cuándo un esquema fijo no basta
Cuatro situaciones en las que el modelo relacional puro incomoda:
| Situación | Por qué duele en columnas | Ejemplo en TiendaVerde |
|---|---|---|
| Atributos que varían por tipo de elemento | Una columna por atributo, casi todas nulas | Acidez del aceite, ingredientes de la crema, certificaciones ecológicas |
| Respuestas de una API externa | Su forma la decide otro, y cambia sin avisar | Lo que devuelve la pasarela de pago |
| Preferencias y eventos | Cada usuario y cada tipo de evento llevan datos distintos | Idioma, avisos; clics, errores, trazas |
Y las tres salidas clásicas, con su precio:
| Enfoque | Ventaja | Coste |
|---|---|---|
| Una columna por atributo | Tipado, restricciones, índices baratos | Tabla dispersa; cada atributo nuevo es un ALTER TABLE (05-06) |
Tabla EAV (atributo_id, valor) |
Flexible, sin migraciones | Todo es texto; una consulta con tres atributos son tres autouniones |
Columna JSONB |
Flexible y consultable, con índices propios | Sin tipado ni integridad referencial; fácil de usar mal |
JSON frente a JSONB
JSON frente a JSONBPostgreSQL tiene dos tipos, y elegir mal se paga.
JSON |
JSONB |
|
|---|---|---|
| Cómo se guarda | Texto literal, tal cual lo escribiste | Binario descompuesto (árbol de claves y valores) |
| Al escribir | Solo valida la sintaxis: muy rápido | Analiza y normaliza: algo más lento |
| Al leer una clave | Reanaliza el texto entero cada vez | Acceso directo: mucho más rápido |
| Espacios, formato y orden de claves | Se conservan | Se pierden: se reordena internamente |
| Claves duplicadas | Se conservan todas | Se queda con la última |
Operadores @>, ?, @@ / índice GIN |
No / no | Sí / sí |
La regla práctica: usa JSONB salvo que necesites conservar el texto exacto. Y ese "salvo" es muy estrecho: básicamente, guardar la respuesta literal de un servicio externo porque hay que verificar una firma digital o reproducirla byte a byte. Para todo lo demás, JSONB. La diferencia se ve al instante:
SELECT '{"b": 1, "a": 2, "a": 3}'::json AS como_json,
'{"b": 1, "a": 2, "a": 3}'::jsonb AS como_jsonb;| como_json | como_jsonb |
|---|---|
| {"b": 1, "a": 2, "a": 3} | {"a": 3, "b": 1} |
El json guarda el disparate tal cual —clave a repetida incluida—; el jsonb normaliza, ordena y se queda con el último valor de a.
- Construir documentos
| Función | Qué hace | Ejemplo |
|---|---|---|
| Literal | Texto con ::jsonb |
'{"origen": "España"}'::jsonb |
to_jsonb(x) |
Convierte cualquier valor o fila a JSON | to_jsonb(p.*) → el producto entero como objeto |
jsonb_build_object(k, v, ...) |
Objeto con claves y valores alternos | jsonb_build_object('id', p.id, 'precio', p.precio) |
jsonb_build_array(a, b, ...) |
Array a partir de valores sueltos | jsonb_build_array('bio', 'vegano') |
jsonb_agg(expr) |
Agregado: junta muchas filas en un array | Todas las líneas de un pedido |
jsonb_object_agg(k, v) |
Agregado: convierte filas en pares clave-valor | {"Alimentación": 256.27, ...} |
El caso estrella —devolver un pedido entero con sus líneas anidadas en una sola fila, que es lo que una API necesita— combina los tres últimos:
SELECT jsonb_build_object(
'pedido_id', pe.id, 'fecha', pe.fecha_pedido, 'portes', pe.gastos_envio,
'cliente', jsonb_build_object('id', c.id, 'pais', c.pais,
'nombre', c.nombre || ' ' || c.apellidos),
'lineas', (SELECT jsonb_agg(jsonb_build_object(
'producto', p.nombre, 'cantidad', lp.cantidad,
'importe', ROUND(lp.cantidad * lp.precio_unitario * (1 - lp.descuento), 2))
ORDER BY lp.id)
FROM lineas_pedido AS lp
JOIN productos AS p ON p.id = lp.producto_id
WHERE lp.pedido_id = pe.id)
) AS pedido
FROM pedidos AS pe JOIN clientes AS c ON c.id = pe.cliente_id
WHERE pe.id = 1;{"fecha": "2025-03-04", "portes": 4.95, "pedido_id": 1,
"cliente": {"id": 1, "pais": "España", "nombre": "Lucía Martínez Soler"},
"lineas": [{"importe": 23.90, "cantidad": 2, "producto": "Aceite de oliva virgen extra 500 ml"},
{"importe": 11.70, "cantidad": 3, "producto": "Arroz integral ecológico 1 kg"},
{"importe": 6.50, "cantidad": 2, "producto": "Infusión de manzanilla ecológica 20 uds"}]}Una fila, una columna, el pedido 1 completo con sus 42,10 € en tres líneas. Fíjate en dos cosas: jsonb_agg admite su propio ORDER BY dentro de los paréntesis, y las claves salen desordenadas respecto a como las escribiste, porque es jsonb. Esto ahorra a la aplicación el trabajo de reensamblar filas planas en un objeto anidado, y es la razón por la que muchas APIs modernas devuelven directamente lo que produce la base de datos — el enlace con 11-05.
Y para un resumen compacto, jsonb_object_agg:
-- Ojo: un agregado no puede anidarse dentro de otro, así que primero se agrupa (10-02)
WITH por_categoria AS (
SELECT cat.nombre, ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS total
FROM lineas_pedido AS lp
JOIN productos AS p ON p.id = lp.producto_id
JOIN categorias AS cat ON cat.id = p.categoria_id
GROUP BY cat.nombre)
SELECT jsonb_object_agg(nombre, total) AS facturacion FROM por_categoria;{"Bebidas": 195.28, "Alimentación": 256.27, "Hogar sostenible": 88.58,
"Higiene personal": 31.50, "Cosmética natural": 156.32}Las cinco categorías con ventas y sus cifras canónicas, en un solo valor.
- La columna
productos.atributos de TiendaVerde
productos.atributos de TiendaVerdeA partir de aquí trabajamos con una columna nueva. No es canónica: es el ejemplo de esta lección y ningún otro módulo la usa.
-- ⚠️ NO CANÓNICA: columna de ejemplo de 10-06. Recarga el script de 01-06 al terminar.
ALTER TABLE productos ADD COLUMN atributos JSONB;
UPDATE productos SET atributos = '{"origen":"España","acidez":0.3,"variedad":"picual",
"extraccion":"en frío","certificaciones":["ecologico-ue","sin-gluten"]}'::jsonb WHERE id = 1;
UPDATE productos SET atributos = '{"origen":"España","tipo_grano":"integral","certificaciones":["ecologico-ue"]}' WHERE id = 2;
UPDATE productos SET atributos = '{"origen":"Francia","volumen_ml":50,"tipo_piel":"seca",
"ingredientes":["aloe vera","aceite de jojoba"],"certificaciones":["cosmos-organic","vegano"]}'::jsonb
WHERE id = 6;
UPDATE productos SET atributos = '{"origen":"Portugal","volumen_ml":200,"tipo_piel":"normal",
"ingredientes":["almendra dulce","vitamina E"],"certificaciones":["vegano"]}'::jsonb WHERE id = 8;
UPDATE productos SET atributos = '{"origen":"Portugal","grado":"ceremonial","gramos":30,"certificaciones":["ecologico-ue","vegano"]}' WHERE id = 15;
UPDATE productos SET atributos = '{"origen":"Alemania","material":"bambú","cerdas":"nailon suave","certificaciones":["vegano"]}' WHERE id = 18;Seis productos con atributos; los otros catorce tienen atributos a NULL, que es lo normal en este tipo de columna y algo que las consultas tendrán que tener en cuenta.
- Acceder:
->, ->>, #>, #>>
->, ->>, #>, #>>Cuatro operadores, y la diferencia entre ellos es la fuente de errores número uno con JSON en PostgreSQL:
| Operador | Argumento | Devuelve | Ejemplo sobre el producto 1 |
|---|---|---|---|
-> |
Clave (texto) o índice (entero) | jsonb |
atributos -> 'origen' → "España" (con comillas) |
->> |
Clave o índice | text |
atributos ->> 'origen' → España |
#> |
Ruta: array de texto | jsonb |
atributos #> '{certificaciones,0}' → "ecologico-ue" |
#>> |
Ruta | text |
atributos #>> '{certificaciones,0}' → ecologico-ue |
La regla mnemotécnica: la flecha doble >> saca el valor "en crudo", como texto. La simple sigue devolviendo JSON, y eso permite encadenar: atributos -> 'certificaciones' ->> 0 baja al array con -> y saca el primer elemento como texto con ->>.
SELECT id, nombre,
atributos -> 'origen' AS origen_jsonb,
atributos ->> 'origen' AS origen_texto,
(atributos ->> 'volumen_ml')::int AS volumen_ml,
atributos -> 'certificaciones' AS certificaciones,
atributos #>> '{certificaciones,0}' AS primera_cert
FROM productos
WHERE atributos IS NOT NULL
ORDER BY id;| id | nombre | origen_jsonb | origen_texto | volumen_ml | certificaciones | primera_cert |
|---|---|---|---|---|---|---|
| 1 | Aceite de oliva virgen extra 500 ml | "España" | España | (null) | ["ecologico-ue", "sin-gluten"] | ecologico-ue |
| 2 | Arroz integral ecológico 1 kg | "España" | España | (null) | ["ecologico-ue"] | ecologico-ue |
| 6 | Crema facial de aloe vera 50 ml | "Francia" | Francia | 50 | ["cosmos-organic", "vegano"] | cosmos-organic |
| 8 | Aceite corporal de almendras 200 ml | "Portugal" | Portugal | 200 | ["vegano"] | vegano |
| 15 | Té verde matcha ceremonial 30 g | "Portugal" | Portugal | (null) | ["ecologico-ue", "vegano"] | ecologico-ue |
| 18 | Cepillo de dientes de bambú | "Alemania" | Alemania | (null) | ["vegano"] | vegano |
Tres cosas que hay que ver en esa tabla. La primera: "España" con comillas no es España; comparar atributos -> 'origen' = 'España' falla porque a la izquierda hay un jsonb y a la derecha un texto — hay que usar ->>, o comparar contra '"España"'::jsonb. La segunda: todo lo que sale de ->> es texto y hay que convertirlo (::int, ::numeric) para comparar como número; si no, '200' < '50' es cierto porque compara alfabéticamente. La tercera: una clave que no existe devuelve NULL, no un error — cómodo, y peligroso, porque una clave mal escrita no se queja.
- Buscar: contención, existencia y JSONPath
Los operadores anteriores extraen; estos filtran, y son los que aprovechan el índice GIN.
| Operador | Lee "…" | Ejemplo |
|---|---|---|
@> |
contiene | atributos @> '{"origen":"España"}' |
<@ |
está contenido en | '{"origen":"España"}' <@ atributos |
? |
existe la clave (o el elemento, en un array) | atributos ? 'acidez' |
?| / ?& |
existe alguna / todas estas claves | atributos ?& ARRAY['origen','certificaciones'] |
SELECT id, nombre, atributos ->> 'origen' AS origen
FROM productos
WHERE atributos @> '{"certificaciones": ["vegano"]}'
ORDER BY id;| id | nombre | origen |
|---|---|---|
| 6 | Crema facial de aloe vera 50 ml | Francia |
| 8 | Aceite corporal de almendras 200 ml | Portugal |
| 15 | Té verde matcha ceremonial 30 g | Portugal |
| 18 | Cepillo de dientes de bambú | Alemania |
Cuatro productos veganos. Fíjate en la potencia de @>: busca dentro de un array sin desmontarlo y sin saber en qué posición está el elemento. Y SELECT id FROM productos WHERE atributos ? 'volumen_ml' devuelve 2 filas —los productos 6 y 8, los únicos con esa clave—, que es cómo se pregunta "qué productos tienen definido este atributo".
JSONPath
Para condiciones que @> no puede expresar —comparaciones numéricas, filtros dentro de arrays, expresiones— PostgreSQL 12 añadió JSONPath, un lenguaje de rutas al estilo XPath:
| Operador / función | Qué hace |
|---|---|
@? |
¿La ruta encuentra algo? Devuelve booleano |
@@ |
¿La expresión JSONPath es cierta? |
jsonb_path_query(doc, ruta) |
Devuelve todos los valores que casan, como filas |
jsonb_path_query_first / _array |
El primero / todos en un array |
SELECT id, nombre FROM productos WHERE atributos @? '$.volumen_ml ? (@ > 100)'; -- 1
SELECT DISTINCT jsonb_path_query(atributos, '$.certificaciones[*]') #>> '{}' AS certificacion
FROM productos WHERE atributos IS NOT NULL ORDER BY 1; -- 2La primera devuelve una fila, el producto 8 (aceite corporal, 200 ml): el 6 tiene 50 y el resto no tiene la clave. La segunda devuelve cinco: cosmos-organic, ecologico-ue, sin-gluten, vegano y —al añadir cualquier producto nuevo— lo que sea que traiga. Lee $.volumen_ml ? (@ > 100) así: $ es la raíz del documento, .volumen_ml baja a esa clave, ? (...) es un filtro y @ es el valor actual. Con [*] se recorren todos los elementos de un array.
- Modificar documentos
| Operación | Cómo | Ejemplo |
|---|---|---|
| Fusionar | || |
atributos || '{"stock_min": 10}'::jsonb — añade o pisa las claves que coincidan |
| Borrar clave | - con texto |
atributos - 'acidez' |
| Borrar varias / por ruta | - con array / #- |
atributos - ARRAY['acidez','variedad'], atributos #- '{certificaciones,1}' |
| Fijar un valor | jsonb_set(doc, ruta, valor [, crear]) |
Cambia si existe; con el 4.º argumento a true (por omisión) lo crea |
| Insertar en un array | jsonb_insert(doc, ruta, valor [, después]) |
Añade sin pisar; falla si la ruta ya existe |
UPDATE productos
SET atributos = jsonb_set(atributos, '{acidez}', '0.25'::jsonb)
|| '{"revisado": true}'::jsonb
WHERE id = 1;
SELECT atributos ->> 'acidez' AS acidez, atributos ->> 'revisado' AS revisado
FROM productos WHERE id = 1;| acidez | revisado |
|---|---|
| 0.25 | true |
Y aquí el detalle de rendimiento que hay que interiorizar: un UPDATE sobre una columna JSONB reescribe el documento entero. No existe "actualizar una clave"; PostgreSQL crea una versión nueva de la fila completa con el documento completo (es MVCC, 09-02). Cambiar un booleano en un documento de 200 KB escribe 200 KB. La consecuencia práctica: JSONB es para escribir poco y leer mucho. Si un campo se actualiza constantemente, ese campo quiere ser una columna.
Una nota fina: jsonb_set con un NULL de SQL en cualquier argumento devuelve NULL, no el documento sin tocar — y como es un UPDATE, borra el documento entero sin avisar. Usa COALESCE o jsonb_set(COALESCE(atributos, '{}'::jsonb), ...) cuando la columna pueda ser nula.
- Expandir a filas: el puente de vuelta al SQL
Este apartado es el que conecta JSON con todo lo demás del curso: convertir un documento en filas para poder agrupar, unir, ordenar y aplicarle funciones de ventana.
| Función | Convierte | En |
|---|---|---|
jsonb_array_elements(doc) |
Un array JSON | Una fila por elemento (jsonb) |
jsonb_array_elements_text(doc) |
Un array JSON | Una fila por elemento (text) |
jsonb_each(doc) / jsonb_object_keys(doc) |
Un objeto | Una fila por par (key, value) / por clave |
jsonb_to_record / jsonb_to_recordset |
Objeto / array de objetos | Filas con columnas tipadas |
SELECT cert.valor AS certificacion, COUNT(*) AS productos
FROM productos AS p
CROSS JOIN LATERAL jsonb_array_elements_text(p.atributos -> 'certificaciones') AS cert(valor)
WHERE p.atributos ? 'certificaciones'
GROUP BY cert.valor
ORDER BY productos DESC, certificacion;| certificacion | productos |
|---|---|
| vegano | 4 |
| ecologico-ue | 3 |
| cosmos-organic | 1 |
| sin-gluten | 1 |
Y aquí ya no hay nada de JSON: es un GROUP BY del módulo 4 sobre filas normales, con el LATERAL de 07-04 haciendo de puente. Ese es exactamente el punto: una vez expandido, un documento es una tabla más, y todo lo que has aprendido en once lecciones vuelve a aplicarse. jsonb_to_recordset va un paso más allá y produce columnas tipadas directamente:
SELECT * FROM jsonb_to_recordset('[{"producto_id":1,"cantidad":2},{"producto_id":15,"cantidad":1}]'::jsonb)
AS t(producto_id INTEGER, cantidad INTEGER);| producto_id | cantidad |
|---|---|
| 1 | 2 |
| 15 | 1 |
Es la forma canónica de recibir una lista de líneas de pedido desde una API y convertirla en filas insertables con un solo INSERT ... SELECT — mucho más limpio que los dos arrays paralelos de sp_confirmar_pedido en 10-04.
- Indexación con GIN
Aquí se cierra la promesa de 08-03. Sin índice, cada consulta con @> recorre la tabla entera y analiza cada documento. Con un índice GIN, no.
CREATE INDEX ix_productos_atributos ON productos USING GIN (atributos); -- jsonb_ops
CREATE INDEX ix_productos_atributos_pth ON productos USING GIN (atributos jsonb_path_ops); -- jsonb_path_opsjsonb_ops (por omisión) |
jsonb_path_ops |
|
|---|---|---|
| Qué indexa | Cada clave y cada valor por separado | Un hash de la ruta completa hasta el valor |
| Operadores que soporta | @>, <@, ?, ?|, ?&, @?, @@ |
Solo @>, @? y @@ |
Tamaño / velocidad con @> |
Mayor / buena | Bastante menor / mejor |
El criterio: si solo consultas por contención (@>), jsonb_path_ops, que es más pequeño y más rápido. Si necesitas preguntar por existencia de claves (?), no queda más remedio que jsonb_ops. Y recuerda de 08-03 que un GIN se construye y se mantiene despacio: es el índice de "escribe poco, lee mucho", que casualmente es también el perfil de una columna JSONB.
Y la tercera vía, que muchas veces es la mejor: un B-tree sobre una expresión cuando la consulta va siempre por la misma clave.
CREATE INDEX ix_productos_origen ON productos ((atributos ->> 'origen'));
SELECT id, nombre FROM productos WHERE atributos ->> 'origen' = 'Portugal';Devuelve los productos 8 y 15. Un B-tree sobre (atributos ->> 'origen') es minúsculo comparado con un GIN, soporta rangos y ordenación, y es lo correcto cuando una clave concreta se consulta mucho. Los paréntesis dobles son obligatorios: es un índice de expresión, de los de 08-02 — y funciona porque ->> es IMMUTABLE, la condición que 10-04 explicó.
- La discusión de diseño: qué NO va en JSON
Lo más importante de la lección. JSONB es tan cómodo que invita a meterlo todo dentro, y esa es una decisión que se paga durante años.
Lo que NO debe ir en un JSON:
- Lo que se filtra o se une constantemente.
categoria_iddentro de un JSON convierte unJOINde índice en una consulta que hay que reescribir con->>y castear en cada uso. - Lo que tiene integridad referencial. Una clave foránea no puede apuntar dentro de un documento. Si guardas
{"proveedor_id": 7}y borran el proveedor 7, nadie te avisa: acabas de perder la garantía que 01-05 te daba gratis. - Lo que en realidad es una tabla. Un array de mil pedidos dentro del documento de un cliente es una tabla
pedidosdisfrazada, sin índices propios, sin poder consultarse por sí sola y reescrita entera con cada compra. - Lo que tiene reglas. No hay
NOT NULL, niUNIQUE, niCHECKdentro de un JSON: unprecioen JSON puede llegar a ser"caro"y nada lo impedirá. Y lo que se actualiza constantemente, porque cadaUPDATEreescribe el documento entero (apartado 7).
El antipatrón: usar la base de datos relacional como almacén de documentos por pereza de modelar. Se reconoce por una tabla con
idy una columnadatos JSONBque lo contiene todo. Funciona el primer mes, y a partir del sexto cada consulta es un castillo de->>y::numeric, nada tiene índice, nada tiene integridad y nadie sabe qué claves existen. Si sabes qué campos hay, son columnas. Si de verdad necesitas un almacén de documentos, existen bases de datos que hacen eso mucho mejor.
Y la tabla que resuelve la duda concreta:
| Pregunta | Si la respuesta es sí → |
|---|---|
| ¿Lo tienen todas las filas y sabes qué es? | Columna |
| ¿Se filtra, se ordena o se une con frecuencia? | Columna |
¿Necesita NOT NULL, UNIQUE, CHECK o una FK? ¿Se actualiza a menudo por separado? |
Columna |
| ¿Es una lista de entidades con vida propia? ¿Hay que consultarlo o agregarlo por sí solo? | Tabla relacionada |
| ¿Varía por tipo de fila y solo se lee junto al resto? ¿Viene de fuera con forma que no controlas? | JSON |
| ¿Es opcional, disperso y de baja frecuencia de uso? | JSON |
En TiendaVerde, aplicado: precio, stock y categoria_id son columnas, sin discusión; las líneas de pedido son una tabla, no un array dentro de pedidos; y la acidez del aceite o el tipo de piel de una crema son JSON, porque cada categoría tiene los suyos y solo se muestran en la ficha del producto.
Nota de dialecto: MySQL 8 tiene un tipo
JSONbinario con->y->>(con la misma semántica de PostgreSQL),JSON_EXTRACT,JSON_TABLEpara expandir a filas, y no tiene índices sobre JSON: se indexan columnas generadas. SQLite trae la extensión JSON1 compilada por omisión:json_extract(),json_each(), el operador->>desde la 3.38, y todo guardado como texto. SQL Server almacena JSON enNVARCHARy lo consulta conJSON_VALUE,JSON_QUERYyOPENJSONpara expandir a filas, con índices sobre columnas calculadas. Oracle tiene un tipoJSONnativo desde 21c y soporta JSONPath ampliamente. El estándar SQL:2016 define JSONPath, y por esojsonb_path_queryse parece tanto entre motores; lo demás es dialecto puro.
Errores Comunes y Consejos
- Confundir
->con->>. El primero devuelvejsonb("España", con comillas) y el segundotext(España).atributos -> 'origen' = 'España'no casa nunca. - Comparar números sin convertir.
atributos ->> 'volumen_ml' > '100'compara texto:'50'es mayor que'100'. Castea siempre:(atributos ->> 'volumen_ml')::int. - Escribir mal una clave. No da error: devuelve
NULLy la fila desaparece en silencio. Comprueba con?qué claves existen de verdad. Y usarJSONen lugar deJSONB: Sin operadores de contención, sin índices GIN y reanalizando el texto en cada acceso.JSONBsalvo que necesites el texto literal exacto. - Olvidar que un
UPDATEreescribe el documento entero. En documentos grandes y actualizaciones frecuentes, eso es bloat y trabajo deVACUUM(09-02). jsonb_setsobre una columnaNULL. DevuelveNULLy borra el documento.COALESCE(atributos, '{}'::jsonb).- Indexar con GIN por defecto y no medir. Si solo usas
@>,jsonb_path_opsocupa bastante menos; y si siempre consultas la misma clave, un B-tree sobre la expresión gana a los dos. - Meter en JSON algo que tiene clave foránea. No hay integridad referencial dentro de un documento, y no la habrá nunca. Consejo: documenta las claves esperadas. Un JSON sin esquema documentado es un campo de texto libre. Si el conjunto de claves es cerrado, valídalo con un
CHECK (atributos ?& ARRAY['origen'])o con la extensión de validación de esquemas. - Consejo: empieza por columnas y mueve a JSON solo lo que sobre. Al revés no funciona: sacar de un JSON tres años de datos a columnas tipadas es una migración cara.
- Consejo:
jsonb_pretty(atributos)para leer un documento enpsql. Y\xpara el modo expandido.
Ejercicios
Ejercicio 1
Con la columna atributos cargada como en el apartado 4: (1) Lista los productos de origen español con su acidez, si la tienen. (2) Cuenta cuántos productos hay por origen, ordenados de más a menos. (3) Encuentra todos los que tienen aloe vera entre sus ingredientes, usando @>. (4) Añade la clave "revisado": false a todos los productos que tengan atributos, sin pisar nada de lo que ya hay.
Ejercicio 2
Marketing quiere el catálogo en JSON para la web: un array con un objeto por categoría, y dentro de cada uno, el nombre de la categoría y el array de sus productos activos con id, nombre y precio. Escríbelo con jsonb_agg y jsonb_build_object, y di cuántos elementos tiene el array exterior.
Ejercicio 3
Decide, con la tabla del apartado 10, dónde va cada dato y justifica en una frase:
- La valoración media de un producto, que se muestra en el listado y permite ordenar.
- Las dimensiones del embalaje (alto, ancho, fondo), que solo conocen algunos productos y solo se usan al calcular portes.
- El historial de cambios de precio de un producto.
- La respuesta completa de la pasarela de pago al cobrar un pedido.
- El proveedor de un producto.
Soluciones
Solución 1
-- 1
SELECT id, nombre, (atributos ->> 'acidez')::numeric AS acidez
FROM productos WHERE atributos @> '{"origen":"España"}' ORDER BY id;
-- 2
SELECT atributos ->> 'origen' AS origen, COUNT(*) AS productos
FROM productos WHERE atributos ? 'origen' GROUP BY 1 ORDER BY productos DESC, origen;
-- 3
SELECT id, nombre FROM productos WHERE atributos @> '{"ingredientes":["aloe vera"]}';
-- 4
UPDATE productos SET atributos = '{"revisado": false}'::jsonb || atributos
WHERE atributos IS NOT NULL;1 devuelve dos filas: el aceite (id 1) con acidez 0.25 —la que dejó el jsonb_set del apartado 7— y el arroz (id 2) con NULL, porque no tiene esa clave. 2 devuelve España 2, Portugal 2, Alemania 1 y Francia 1: cuatro orígenes para los seis productos con atributos. 3 devuelve una fila, la crema facial (id 6), y funciona porque @> busca dentro del array sin importar la posición. 4 afecta a 6 filas, y el orden del || es la clave: '{"revisado": false}' || atributos hace que gane atributos si la clave ya existiera, mientras que atributos || '{"revisado": false}' la pisaría. Es la diferencia entre "añade si falta" y "fuerza el valor".
Solución 2
SELECT jsonb_agg(jsonb_build_object(
'categoria', cat.nombre,
'productos', (SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', p.id, 'nombre', p.nombre, 'precio', p.precio)
ORDER BY p.id), '[]'::jsonb)
FROM productos AS p
WHERE p.categoria_id = cat.id AND p.activo)
) ORDER BY cat.id) AS catalogo
FROM categorias AS cat;El array exterior tiene 6 elementos, uno por categoría, incluida Complementos — que aparece con "productos": [] porque su único producto, las Cápsulas de espirulina, está descatalogado (activo = FALSE). Ese COALESCE(..., '[]'::jsonb) no es un adorno: sin él, jsonb_agg sobre cero filas devuelve NULL y la clave saldría como null en lugar de como array vacío, lo que rompería cualquier código que recorra la lista. Es exactamente el problema de 04-04 con SUM sobre el conjunto vacío, ahora en JSON.
Solución 3
| # | Dato | Dónde | Por qué |
|---|---|---|---|
| 1 | Valoración media | Columna (o vista/materializada) | Se ordena y filtra por ella en el listado: dentro de un JSON no tendría índice útil |
| 2 | Dimensiones del embalaje | JSON | Opcionales, dispersas y solo se leen junto al resto del producto al calcular portes |
| 3 | Historial de precios | Tabla relacionada | Es una lista de entidades con vida propia, que hay que consultar y agregar por sí sola: es la auditoria_precios de 10-05 |
| 4 | Respuesta de la pasarela | JSON | Viene de fuera con una forma que no controlas y que puede cambiar sin avisar. Aquí incluso cabe JSON en vez de JSONB, si hay que verificar una firma sobre el texto exacto |
| 5 | Proveedor | Columna con FK | Tiene integridad referencial: dentro de un documento, ON DELETE RESTRICT no existe |
Las cinco respuestas salen de aplicar tres preguntas: ¿se filtra u ordena por ello? (columna), ¿tiene vida propia? (tabla), ¿es opcional, variable y de solo lectura conjunta? (JSON).
Conclusión del módulo
Cierras el módulo con la última pieza de la caja de herramientas:
JSONBfrente aJSON: binario descompuesto frente a texto literal.JSONBnormaliza claves, elimina duplicados, accede rápido y es el único indexable y con operadores de contención. La regla:JSONBsalvo que necesites el texto exacto.- Construir:
to_jsonb,jsonb_build_object,jsonb_build_array, y sobre todo los agregadosjsonb_aggyjsonb_object_agg, que devuelven el pedido 1 completo con sus tres líneas y sus 42,10 € en una sola fila — el caso estrella para una API (11-05). - Acceder:
->y#>devuelvenjsonb;->>y#>>devuelventext. Esa diferencia es la fuente de errores número uno, junto con comparar números sin convertir y con las claves inexistentes que devuelvenNULLen silencio. - Buscar:
@>(contención, que entra dentro de los arrays),?,?|y?&(existencia de claves), y JSONPath con@?,@@yjsonb_path_querypara lo que la contención no puede expresar, como$.volumen_ml ? (@ > 100). - Modificar:
||fusiona,-borra,jsonb_setfija yjsonb_insertañade — recordando que unUPDATEreescribe el documento entero, y que por esoJSONBes para escribir poco y leer mucho. - Expandir a filas con
jsonb_array_elements,jsonb_eachyjsonb_to_recordsetes el puente de vuelta: una vez expandido, un documento es una tabla más y vuelve a servir todo el SQL del curso — como el recuento de certificaciones, conveganoen 4 productos yecologico-ueen 3. - Indexar, cerrando 08-03: GIN con
jsonb_opssoporta todos los operadores y ocupa más;jsonb_path_opssolo@>,@?y@@, pero es más pequeño y más rápido; y un B-tree sobre(atributos ->> 'clave')gana a los dos cuando siempre consultas la misma clave. - Y la discusión de diseño: fuera del JSON todo lo que se filtre o se una, todo lo que tenga integridad referencial, todo lo que sea en realidad una tabla, todo lo que necesite reglas y todo lo que se actualice constantemente. El antipatrón —usar una base relacional como almacén de documentos por pereza de modelar— se paga con intereses. Si sabes qué campos hay, son columnas.
Y con esto se cierra el módulo 10. En seis lecciones has montado la caja de herramientas completa: vistas que dan nombre a una consulta y encapsulan una métrica, con las materializadas para los informes caros; CTE que convierten tres niveles de subconsultas en pasos legibles, y WITH RECURSIVE para recorrer el organigrama entero y la cadena de referidos de tres saltos; funciones de ventana que agregan sin colapsar y resuelven rankings, acumulados, medias móviles y top N por grupo; procedimientos donde vive por fin la confirmación de pedido con su atomicidad garantizada; triggers que aplican solos las reglas que un CHECK no alcanza; y JSON para lo que no cabe en un esquema fijo. Ya no estás aprendiendo SQL: lo estás usando.
Lo que falta no son más funciones, sino contexto. Un sistema real no es una consulta bien escrita: es un conjunto de decisiones sobre cómo se organiza el trabajo, quién puede ver qué, qué se mide y desde dónde se llama. En el módulo 11, Práctica: casos de uso reales, verás SQL en su entorno: los casos de uso que aparecen una y otra vez en cualquier proyecto; las mejores prácticas de escritura, nomenclatura y mantenimiento que separan una base con la que se puede trabajar de una que da miedo tocar; la seguridad —la inyección SQL y cómo se evita de verdad, los permisos y los roles que este módulo ha ido remitiendo lección tras lección—; el SQL para análisis de datos, donde las funciones de ventana de 10-03 se convierten en informes completos; y el SQL en el desarrollo web, con los ORM, el pool de conexiones y el problema N+1. La caja de herramientas ya está llena; queda aprender el oficio.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
