JSON en SQL: generar documentos y consultar columnas JSON

Las bases de datos relacionales también hablan JSON. Con JSON en SQL puedes devolver un resultado ya convertido en documento para una API y guardar datos con forma libre en una columna. Las tres bases de este ejemplo lo hacen, cada una con sus funciones.

Todas las consultas usan la base de ejemplo «tienda» del curso (clientes, productos, pedidos y sus líneas). Las que funcionan en SQLite tienen un enlace para abrirlas en el editor de SQL, que la trae cargada.

Generar un documento a partir de varias tablas

El pedido 1 con su cliente y sus líneas, en un solo documento. json_object arma un objeto y json_group_array junta las líneas en un array, una por fila del grupo:

-- Un pedido con sus líneas, como un solo documento JSON
SELECT json_object(
         'pedido', pe.id,
         'fecha', pe.fecha,
         'cliente', c.nombre,
         'lineas', json_group_array(json_object('producto', p.nombre, 'cantidad', d.cantidad,
                                                'precio', d.precio_unitario))
       ) AS documento
FROM pedidos pe
JOIN clientes c  ON c.id = pe.cliente_id
JOIN detalle d   ON d.pedido_id = pe.id
JOIN productos p ON p.id = d.producto_id
WHERE pe.id = 1
GROUP BY pe.id, pe.fecha, c.nombre;

Ábrelo en el editor y cámbialo →

resultado · SQLitedocumento
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
{"pedido":1,"fecha":"2026-01-10","cliente":"Ana Torres","lineas":[{"producto":"Portátil Aero 14","cantidad":1,"precio":1299.99},{"producto":"Ratón inalámbrico","cantidad":1,"precio":24.99}]}

PostgreSQL usa json_build_object y json_agg; MySQL, JSON_OBJECT y JSON_ARRAYAGG. El resultado es el mismo documento, pero MySQL reordena las claves (las guarda ordenadas por longitud y nombre):

resultado · PostgreSQL                                                                                                         documento                                                                                                          
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 {"pedido" : 1, "fecha" : "2026-01-10", "cliente" : "Ana Torres", "lineas" : [{"producto" : "Portátil Aero 14", "cantidad" : 1, "precio" : 1299.99}, {"producto" : "Ratón inalámbrico", "cantidad" : 1, "precio" : 24.99}]}
resultado · MySQL+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| documento                                                                                                                                                                                                           |
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| {"fecha": "2026-01-10", "lineas": [{"precio": 1299.99, "cantidad": 1, "producto": "Portátil Aero 14"}, {"precio": 24.99, "cantidad": 1, "producto": "Ratón inalámbrico"}], "pedido": 1, "cliente": "Ana Torres"}    |
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

Una columna JSON

Las preferencias de cada cliente cambian de uno a otro, así que se guardan como documento. En SQLite la columna es TEXT; en PostgreSQL, JSONB, y en MySQL, JSON:

-- Preferencias de cada cliente guardadas como un documento JSON
CREATE TABLE preferencias (
  cliente_id INTEGER PRIMARY KEY REFERENCES clientes(id),
  datos      TEXT NOT NULL
);
INSERT INTO preferencias (cliente_id, datos) VALUES
  (1, '{"idioma": "es", "boletin": true,  "intereses": ["portátiles", "monitores"]}'),
  (2, '{"idioma": "es", "boletin": false, "intereses": ["redes"]}'),
  (3, '{"idioma": "en", "boletin": true,  "intereses": []}'),
  (4, '{"idioma": "es", "boletin": true,  "intereses": ["redes", "almacenamiento"], "tema": "oscuro"}');

El operador ->> saca un campo como valor de SQL (texto o número). En SQLite y MySQL la ruta empieza por $; en PostgreSQL basta el nombre:

-- Leer campos del documento: ->> devuelve el valor como texto o número de SQL
SELECT cliente_id,
       datos ->> '$.idioma'  AS idioma,
       datos ->> '$.boletin' AS boletin,
       datos ->> '$.tema'    AS tema,
       json_array_length(datos, '$.intereses') AS n_intereses
FROM preferencias
ORDER BY cliente_id;

SELECT c.nombre
FROM preferencias pr JOIN clientes c ON c.id = pr.cliente_id
WHERE pr.datos ->> '$.boletin' = 1
ORDER BY c.nombre;

Ábrelo en el editor y cámbialo →

resultado · SQLitecliente_id | idioma | boletin | tema   | n_intereses
-----------+--------+---------+--------+------------
         1 | es     |       1 | NULL   |           2
         2 | es     |       0 | NULL   |           1
         3 | en     |       1 | NULL   |           0
         4 | es     |       1 | oscuro |           2

nombre
--------------
Ana Torres
Camila Rojas
Diego Martínez

SQLite devuelve los booleanos de JSON como 1 y 0. PostgreSQL y MySQL los devuelven como el texto true y false, así que el filtro cambia en cada motor:

-- PostgreSQL
WHERE (pr.datos ->> 'boletin')::boolean

-- MySQL
WHERE pr.datos ->> '$.boletin' = 'true'

PostgreSQL tiene además el operador de contención @>: «¿este documento incluye este fragmento?». Con un índice GIN, es la forma rápida de buscar en JSONB:

-- Contención: ¿qué documentos incluyen este fragmento?
SELECT cliente_id FROM preferencias WHERE datos @> '{"intereses": ["redes"]}' ORDER BY cliente_id;
resultado · PostgreSQL cliente_id 
------------
          2
          4

De un array a filas

Para contar cuántos clientes tienen cada interés, el array tiene que convertirse en filas. En SQLite, con json_each:

-- Un interés por fila: json_each convierte el array en una tabla
SELECT j.value AS interes, COUNT(*) AS clientes
FROM preferencias pr, json_each(pr.datos, '$.intereses') AS j
GROUP BY j.value
ORDER BY clientes DESC, interes;

Ábrelo en el editor y cámbialo →

resultado · SQLiteinteres        | clientes
---------------+---------
redes          |        2
almacenamiento |        1
monitores      |        1
portátiles     |        1

En PostgreSQL, jsonb_array_elements_text; en MySQL, JSON_TABLE, más largo pero que permite sacar varias columnas a la vez. Los dos dan la misma tabla:

SELECT i AS interes, COUNT(*) AS clientes
FROM preferencias pr, jsonb_array_elements_text(pr.datos -> 'intereses') AS i
GROUP BY i
ORDER BY clientes DESC, interes;
SELECT j.interes, COUNT(*) AS clientes
FROM preferencias pr,
     JSON_TABLE(pr.datos, '$.intereses[*]' COLUMNS (interes VARCHAR(40) PATH '$')) AS j
GROUP BY j.interes
ORDER BY clientes DESC, j.interes;

Modificar un documento y validar

json_set cambia o añade campos sin reescribir el documento. Y como en SQLite la columna es texto, acepta un JSON mal escrito; json_valid lo detecta:

-- Cambiar un campo sin reescribir el documento a mano
UPDATE preferencias SET datos = json_set(datos, '$.boletin', json('false'), '$.tema', 'claro')
WHERE cliente_id = 1;

SELECT datos FROM preferencias WHERE cliente_id = 1;

-- Un documento mal escrito: SQLite lo guarda igual, porque la columna es TEXT
INSERT INTO preferencias (cliente_id, datos) VALUES (5, '{"idioma": "es", }');
SELECT cliente_id, json_valid(datos) AS valido FROM preferencias WHERE cliente_id IN (4, 5);

Ábrelo en el editor y cámbialo →

resultado · SQLitedatos
-------------------------------------------------------------------------------------
{"idioma":"es","boletin":false,"intereses":["portátiles","monitores"],"tema":"claro"}

cliente_id | valido
-----------+-------
         4 |      1
         5 |      0

PostgreSQL y MySQL validan al guardar, porque sus columnas son de tipo JSON:

resultado · PostgreSQLERROR:  invalid input syntax for type json
LINE 1: ... INTO preferencias (cliente_id, datos) VALUES (5, '{"idioma"...
                                                             ^
DETAIL:  Expected string, but found "}".
CONTEXT:  JSON data, line 1: {"idioma": "es", }
resultado · MySQLERROR 3140 (22032) at line 1: Invalid JSON text: "Missing a name for object member." at position 17 in value for column 'preferencias.datos'.
En SQLite, añade la validación tú: datos TEXT NOT NULL CHECK (json_valid(datos)). Y no metas en JSON lo que consultas a menudo o usas para unir tablas: eso va mejor en columnas normales, con su tipo y su índice.

Todas las consultas de esta página se ejecutaron antes de publicarla sobre la base de ejemplo «tienda» del curso, en contenedores limpios: SQLite 3.46.1 (el de Python 3.13), PostgreSQL 17 y MySQL 8.4. Los resultados y los mensajes de error están copiados de esa ejecución. Documentación oficial: funciones JSON de SQLite.