JSON y JSONB en Vivo
PostgreSQL de verdad, compilado a WebAssembly, corriendo en tu navegador. Esta vez vas a guardar documentos en una columna jsonb, leerlos, filtrarlos, modificarlos, desarmarlos en filas e indexarlos, y a ver con tus propios ojos los errores y trampas reales que la guía solo te contó.
Bienvenido
Este taller es la práctica de laboratorio de JSON y JSONB en PostgreSQL. No vuelve a explicar la teoría desde cero —asume que ya viste la guía— y en cambio te da un PostgreSQL real donde vas a escribir las consultas, provocar a propósito algunos errores y corregirlos.
Si todavía no viste la teoría, repasa primero JSON y JSONB en PostgreSQL antes de continuar. Este taller usa una única tabla, productos, con una columna atributos de tipo jsonb: el catálogo de una tienda de tecnología.
Esta página corre su propia base de datos, independiente de cualquier otro taller que hayas abierto antes. Existe solo mientras esta pestaña siga abierta: si recargas la página, la cierras, o navegas a otra guía, se pierde todo y hay que empezar de nuevo desde "Prepara tu base de datos". Si te equivocas y quieres reiniciar sin recargar, usa el botón "Reiniciar base de datos" de abajo.
La sección 4 modifica los documentos, y las secciones 5, 6 y el reto asumen esos cambios. Haz los ejercicios en orden. Si algo no te cuadra, el botón de reinicio te devuelve al principio.
✍️ Lo escribes tú
Cada bloque parte vacío. El enunciado te dice qué tienes que lograr — el código SQL lo escribes de cero, y copiar y pegar está deshabilitado a propósito.
⛔ Algunos errores son a propósito
Varios ejercicios te piden hacer algo que debería fallar (o devolver 0 filas sin avisar). Ver la trampa en vivo es la mejor forma de no caer en ella después.
🔎 Compara con lo esperado
Debajo de cada ejercicio hay un recuadro con lo que deberías ver. Si tu resultado es distinto, vuelve a leer el enunciado antes de seguir.
Zona de pruebas libres
Este bloque queda disponible durante toda la guía. Úsalo en cualquier momento para probar una idea propia o revisar el estado de la tabla con SELECT * FROM productos; — sin desordenar los bloques guiados.
Prepara tu base de datos
Escribe el CREATE TABLE y el INSERT de productos. Las columnas son id (clave primaria autoincremental), nombre (texto obligatorio), precio (NUMERIC(10,2) obligatorio) y atributos (JSONB obligatorio, con valor por defecto '{}'). Agrégale una restricción llamada atributos_es_objeto que use jsonb_typeof para exigir que el valor sea un objeto.
| nombre | precio | atributos |
|---|---|---|
| Notebook Aurora 14 | 5200.00 | {"marca":"Lenovo","ram_gb":16,"almacenamiento":{"tipo":"SSD","gb":512},"puertos":["USB-C","HDMI","USB-A"],"garantia_meses":12} |
| Auriculares Vento | 350.00 | {"marca":"Sony","bluetooth":true,"autonomia_h":30,"colores":["negro","azul"]} |
| Mouse Pixel | 120.00 | {"marca":"Logitech","inalambrico":true,"colores":["negro"]} |
| Monitor Lumen 27 | 2100.00 | {"marca":"LG","pulgadas":27,"puertos":["HDMI","DisplayPort"],"garantia_meses":24} |
| Cable HDMI 2 m | 45.00 | {} |
El SELECT final debería mostrarte 5 filas. Fíjate en que el orden de las claves dentro de cada documento no es el que escribiste: jsonb las reordena. Si algo no coincide (por ejemplo, un error de comillas en el JSON), usa "🔄 Reiniciar base de datos" y vuelve a intentar — el resto del taller asume exactamente estos datos.
1. json, jsonb y los tipos de valor
Antes de consultar nada, comprueba con tus propios ojos qué hace jsonb con lo que le entregas, y qué rechaza.
Escribe un SELECT con dos columnas, como_json y como_jsonb, que conviertan el mismo literal {"a":1, "b":2, "a":3} (con los espacios y la clave a repetida) al tipo json y al tipo jsonb con ::.
El json conservó el texto tal cual: espacios, orden y las dos a. El jsonb quedó como {"a": 3, "b": 2}: una sola a (gana la última) y sin los espacios.
Intenta insertar en productos una fila de nombre Cosa rara, precio 10 y atributos [1,2] (un arreglo, no un objeto). Debería fallar.
Deberías ver new row for relation "productos" violates check constraint "atributos_es_objeto". Es el CHECK que escribiste en "Prepara tu base de datos": solo deja pasar objetos.
Con jsonb_typeof sobre literales ::jsonb, consulta el tipo de estos seis valores en un solo SELECT: 16, "16", true, null, {"gb":512} y ["HDMI"].
Deberías ver, en orden: number, string, boolean, null, object y array. Fíjate en los dos primeros: 16 y "16" parecen lo mismo, pero para jsonb son tipos distintos. Eso vuelve en la sección 3.
2. Leer: ->, ->> y #>>
Sacar un valor del documento es fácil. Lo difícil es acordarse de qué tipo devuelve cada operador.
Lista nombre, la marca con -> (columna con_flecha) y la marca con ->> (columna con_doble_flecha) de todos los productos, ordenados por id.
La columna con_flecha conserva las comillas ("Lenovo") porque sigue siendo JSON; con_doble_flecha es texto SQL limpio (Lenovo). El Cable HDMI no tiene marca: devuelve NULL en ambas, sin ningún error.
Solo para el Notebook Aurora 14, obtén: el tipo de almacenamiento (encadenando -> y ->>), los gb de almacenamiento (con #>> y una ruta), el primer_puerto (posición 0 del arreglo puertos) y la cantidad de puertos como cant_puertos (con jsonb_array_length).
Deberías ver SSD, 512, USB-C y 3. El arreglo empieza en la posición 0, no en 1.
Quieres los productos con al menos 16 GB de RAM. Escribe SELECT nombre FROM productos WHERE comparando directamente atributos->>'ram_gb' >= 16. Debería fallar.
Deberías ver operator does not exist: text >= integer. ->> siempre devuelve text, y PostgreSQL no compara texto con un número.
Repite la misma consulta, pero convirtiendo el valor a número con ::int antes de comparar (con paréntesis alrededor de la expresión ->>).
Devuelve solo el Notebook Aurora 14 (el único producto con ram_gb; los demás dan NULL y no pasan el filtro).
Para ver el daño real de comparar como texto, ejecuta SELECT v, v >= '16' AS como_texto, v::int >= 16 AS como_numero FROM (VALUES ('8'), ('16'), ('32')) t(v);
En como_texto el 8 sale true: como texto, '8' viene después de '1'. En como_numero sale false, que es lo correcto. Una consulta que "funciona" puede estar mal.
3. Filtrar: @>, ? y compañía
Los operadores propios de jsonb: describes cómo debería verse el documento en vez de recorrerlo.
Escribe tres SELECT nombre FROM productos separados por ;, cada uno con @>: (a) marca Lenovo; (b) almacenamiento con tipo SSD (documento anidado); (c) puertos que incluya HDMI, ordenado por id.
Las dos primeras devuelven solo el Notebook Aurora 14. La tercera devuelve dos filas: el Notebook Aurora 14 y el Monitor Lumen 27. Para buscar un elemento dentro de un arreglo no lo recorres: describes el fragmento esperado.
Otra vez tres consultas con ;, todas ordenadas por id: (a) productos con la clave garantia_meses; (b) productos con al menos una de las claves bluetooth o inalambrico (?| con ARRAY[...]); (c) productos con las dos claves marca y garantia_meses (?&).
(a) Notebook y Monitor. (b) Auriculares y Mouse. (c) Notebook y Monitor.
Busca con @> los productos cuyo ram_gb sea el texto "16" (entre comillas dobles, dentro del literal). Después, en el mismo editor, repite la búsqueda con el número 16, sin comillas.
La primera consulta no devuelve filas y tampoco lanza ningún error: es el peligro de esta trampa. La segunda devuelve el Notebook. Para jsonb, "16" y 16 son valores distintos.
Comprueba con un solo SELECT sobre el literal '{"a":{"b":1}}'::jsonb si contiene la clave b (columna en_primer_nivel) y la clave a (columna en_raiz), usando ? en ambas.
b está un nivel más abajo, así que ? no la ve (false); a sí está en el primer nivel (true).
4. Modificar sin reescribir todo
Un jsonb no se edita "en el lugar": cada función devuelve un documento nuevo y tú lo guardas con UPDATE ... SET atributos = .... Estos ejercicios cambian los datos: las secciones siguientes y el reto parten del resultado.
Con dos UPDATE sobre el Notebook Aurora 14 usando jsonb_set: sube ram_gb a 32 y después cambia el valor anidado almacenamiento.gb a 1024 (ruta de dos niveles). Cierra con un SELECT que muestre ram (con ->>) y gb (con #>>).
El SELECT final debe mostrar ram = 32 y gb = 1024. Las dos sentencias de UPDATE afectaron una fila cada una.
Sobre el mismo Notebook, en este orden: (1) con || agrega {"color":"gris","garantia_meses":18}; (2) con - quita la clave color; (3) con #- quita la clave tipo de dentro de almacenamiento. Cierra con SELECT atributos FROM productos WHERE nombre = 'Notebook Aurora 14';
Debe quedar {"marca": "Lenovo", "ram_gb": 32, "puertos": ["USB-C", "HDMI", "USB-A"], "almacenamiento": {"gb": 1024}, "garantia_meses": 18}: la garantía pasó de 12 a 18 (a igual clave gana el documento de la derecha de ||), color ya no está y almacenamiento perdió su tipo.
El Mouse Pixel tiene colores = ["negro"]. Agrégale "rojo": concatena con || el arreglo actual (atributos->'colores') y guarda el resultado con jsonb_set. Después consulta atributos->'colores'.
Debe mostrar ["negro", "rojo"].
Intenta con jsonb_set poner 6 en la ruta {wifi,version} del Mouse (la clave wifi no existe). Después consulta, con ?, si el Mouse tiene ahora la clave wifi (columna tiene_wifi) junto con su atributos.
El UPDATE dice que afectó una fila, pero tiene_wifi es false y el documento quedó igual. Como wifi no existe, no hay dónde poner version, y PostgreSQL no avisa.
Intenta con jsonb_set poner un NULL de SQL (la palabra NULL sin comillas) en la clave marca del Mouse. Debería fallar.
Deberías ver null value in column "atributos" of relation "productos" violates not-null constraint. Con un NULL de SQL, jsonb_set devuelve NULL para todo el documento, y solo el NOT NULL de la columna lo salvó. Si quisieras guardar un nulo de JSON, se escribe 'null'::jsonb.
Ahora pon el texto Logitech en marca, pero escribiendo el tercer argumento como 'Logitech', sin comillas dobles internas. Debería fallar.
Deberías ver invalid input syntax for type json. El valor debe ser jsonb válido, y un texto necesita sus comillas dobles dentro de las simples: '"Logitech"'.
5. De JSON a filas y de filas a JSON
Cruza el puente en ambos sentidos: desarma documentos para contar y agrupar con el GROUP BY de siempre, y arma documentos listos para una API.
Con jsonb_each_text(atributos) AS e(k, v) en el FROM (junto a productos), muestra cada clave del documento de los Auriculares Vento como una fila: columnas clave y valor, ordenadas por clave.
autonomia_h (30), bluetooth (true), colores (["negro", "azul"]) y marca (Sony).
¿Qué puertos aparecen más en el catálogo? Expande atributos->'puertos' con jsonb_array_elements_text (alias p), agrupa por puerto y cuenta: columnas puerto y productos, ordenadas por cantidad descendente y luego por puerto.
HDMI con 2; luego DisplayPort, USB-A y USB-C con 1 cada uno. Los productos sin puertos simplemente no aportan filas.
Sin pistas esta vez: haz la misma consulta para los colores. Columnas color y productos, ordenadas por cantidad descendente y luego por color.
negro con 2 (Auriculares y Mouse), azul con 1 y rojo con 1 (el que agregaste en 4.3). Si rojo no aparece, repasa el ejercicio 4.3.
Tres consultas separadas por ;: (a) con jsonb_build_object, un objeto con id, nombre y precio del producto de id = 3; (b) con jsonb_agg(nombre ORDER BY id), un arreglo con los nombres de los productos que tienen la clave garantia_meses; (c) con to_jsonb, el producto de id = 2 convertido en objeto (pista: pásale una subconsulta alias p que seleccione id, nombre, precio).
(a) {"id": 3, "nombre": "Mouse Pixel", "precio": 120.00}; (b) ["Notebook Aurora 14", "Monitor Lumen 27"]; (c) {"id": 2, "nombre": "Auriculares Vento", "precio": 350.00}. Es el tipo de respuesta que una API le devuelve directo a la aplicación.
6. Índices GIN: que no recorra todo el catálogo
Comprueba con EXPLAIN, sobre Postgres real, qué consultas aprovechan cada índice. La tabla tiene solo unas filas, así que el planificador preferiría recorrerla entera: para ver el índice hay que pedirle que no lo haga.
En tres sentencias separadas por ;: (1) crea el índice idx_productos_atributos sobre productos USING GIN (atributos); (2) SET enable_seqscan = off; (solo para esta demostración); (3) EXPLAIN de la búsqueda atributos @> '{"marca":"Lenovo"}' que devuelve el nombre.
En el plan debe aparecer Bitmap Index Scan on idx_productos_atributos. Ignora los números de cost: lo importante es qué nodos aparecen.
Pide el EXPLAIN de la misma búsqueda, pero escrita como atributos->>'marca' = 'Lenovo'.
Ahora el plan muestra Seq Scan on productos con un Filter, aunque el GIN exista. (Verás también Disabled: true, porque apagaste los recorridos secuenciales en 6.1 y aun así no había otra opción.) El GIN solo sirve a los operadores propios de jsonb.
Crea idx_productos_marca sobre la expresión (atributos->>'marca') (paréntesis dobles), repite el EXPLAIN de 6.2 y cierra con RESET enable_seqscan; para devolver el planificador a la normalidad.
Debe aparecer Index Scan using idx_productos_marca on productos. Cada tipo de consulta pide su índice: GIN para @> y ?; índice de expresión para igualdad u orden sobre una clave concreta con ->>.
Reto final: modelo híbrido de punta a punta
Reúne todo: insertar un documento nuevo, completar con || los que no tienen un dato, y entregar un reporte armado en JSON. Parte del estado en que dejaste la base al terminar las secciones anteriores.
- 1. Inserta un producto nuevo:
Teclado Rex, precio480.00, con atributosmarca=Redragon,inalambrico=falseycolores=["negro","rojo"](sin garantía). - 2. Con un único
UPDATEy el operador||, agrega"garantia_meses":12solo a los productos que todavía no tienen esa clave (pista:WHERE NOT atributos ? '...'). Debe afectar 4 filas. - 3. Entrega un reporte con una fila por marca: columna
marca(con->>) y columnaproductos, un arreglo JSON con los nombres de esa marca ordenados alfabéticamente (jsonb_agg). Excluye los productos sin marca y ordena el reporte por marca. - 4. Confirma con un
count(*)que los 6 productos tienen ahora la clavegarantia_meses.
| marca | productos |
|---|---|
| LG | ["Monitor Lumen 27"] |
| Lenovo | ["Notebook Aurora 14"] |
| Logitech | ["Mouse Pixel"] |
| Redragon | ["Teclado Rex"] |
| Sony | ["Auriculares Vento"] |
El Cable HDMI no tiene marca y el Teclado no tenía garantía, y la tabla los aceptó a los dos sin tocar su estructura: esa es la flexibilidad del documento. Pero nombre y precio siguen siendo columnas con tipo, NOT NULL y CHECK: lo que se une, ordena y valida va en columnas; lo que varía, en el documento.
Repaso
Preguntas cortas sobre lo que acabas de ejecutar en vivo. Tu progreso se guarda automáticamente en este navegador.
En el ejercicio 1.2, el INSERT de [1,2] en atributos fue rechazado. ¿Qué lo rechazó?
En el ejercicio 3.3, la búsqueda con @> '{"ram_gb":"16"}' devolvió 0 filas porque todavía no se había creado el índice GIN.
En el ejercicio 4.5, jsonb_set(atributos, '{marca}', NULL) falló con un error de NOT NULL. ¿Qué había hecho jsonb_set con ese NULL de SQL?
En el ejercicio 6.2, el EXPLAIN de atributos->>'marca' = 'Lenovo' mostró Seq Scan aunque el índice GIN ya existía. ¿Por qué?
En el ejercicio 4.4, jsonb_set(atributos, '{wifi,version}', '6') no cambió nada y tampoco dio error. ¿Por qué?
Clasifica cada expresión que usaste en este taller según lo que hace con el documento.
jsonb_agg(nombre ORDER BY id)
atributos #- '{almacenamiento,tipo}'
jsonb_each_text(atributos)
jsonb_array_elements_text(atributos->'colores')
Sobre los índices del ejercicio 6: elige el índice que acelera cada consulta.
1. WHERE atributos @> '{"marca":"Lenovo"}' se acelera con
.
2. WHERE atributos->>'marca' = 'Lenovo' se acelera con
.
Une cada herramienta con lo que hizo en este taller.
Cheat Sheet del taller
| Forma | Para qué sirve |
|---|---|
atributos->'clave' / atributos->>'clave' | Valor como jsonb / valor como texto SQL. |
atributos#>>'{a,b}' | Texto siguiendo una ruta de varios niveles. |
(atributos->>'ram_gb')::int >= 16 | Comparar como número: hay que convertir. |
atributos @> '{"marca":"LG"}' | ¿El documento contiene este fragmento? Distingue 16 de "16". |
atributos ? 'clave', ?|, ?& | Existe una clave del primer nivel / alguna / todas. |
jsonb_set(doc, '{ruta}', '"texto"') | Cambia una clave; no crea rutas intermedias; el valor debe ser jsonb. |
doc || '{...}', doc - 'clave', doc #- '{ruta}' | Agregar o pisar, quitar del primer nivel, quitar de una ruta. |
jsonb_each_text, jsonb_array_elements_text | Desarmar un documento o un arreglo en filas. |
jsonb_build_object, jsonb_agg, to_jsonb | Armar JSON desde columnas o filas. |
CREATE INDEX ... USING GIN (atributos) | Acelera @>, ?, ?| y ?&. |
CREATE INDEX ... ((atributos->>'clave')) | Acelera igualdad u orden sobre una clave concreta. |
¿Sabías que...?
🔤 Desde PostgreSQL 14 hay subíndices
Además de -> y jsonb_set, se puede escribir atributos['marca'] para leer y UPDATE productos SET atributos['marca'] = '"Dell"' para modificar. Hace lo mismo, con una sintaxis más cercana a la de un lenguaje de programación.
🐢 Un Seq Scan no siempre es un problema
Con unas pocas filas, recorrer toda la tabla es lo más barato, y por eso en la sección 6 tuviste que apagar enable_seqscan para ver trabajar al índice. El planificador elige por costo, no por moda.
🗂️ Tu tabla es tuya sola
Cada estudiante corre su propia base en su propia pestaña. La tabla productos con sus documentos vive solo en la memoria de este navegador y desaparece al cerrar o recargar la página.