Cómo consultar un archivo JSON enorme sin escribir un script
No necesitas un parser de usar y tirar para sacar un valor de una exportación JSON gigante — demasiado grande para abrirla en un editor. Obtener datos de un JSON es un problema de consulta, y jq, JSONPath y DuckDB lo resuelven, hasta en archivos demasiado grandes para la memoria.
Tienes un archivo JSON y una pregunta sobre él. ¿Qué cuentas están inactivas? ¿Cuál es el ID del registro que falló? ¿Cuántos eventos hay de cada tipo? El archivo pesa 80 MB — demasiado para desplazarse por él, demasiado para revisarlo a ojo — y tu instinto es abrir un editor, escribir data = json.load(open("data.json")) y ponerte a iterar. Para una pregunta que harás una sola vez, es mucho montaje para llegar a un número. Y si el archivo es lo bastante grande, json.load se quedará ahí devorando memoria y quizá se caiga antes de responder.
Este es el cambio de enfoque que te salva la tarde: sacar un valor de un JSON es una consulta, no un programa. El JSON tiene lenguajes de consulta, igual que una base de datos, y todos se ejecutan desde una sola línea de comandos. Este artículo cubre tres que, entre ellos, resuelven la mayor parte de lo que aparece — jq, JSONPath y DuckDB — cuándo recurrir a cada uno y qué hacer cuando el archivo de verdad no cabe en memoria.
El archivo que usaremos
Llamémoslo events.json: un objeto en la raíz, con un array events dentro. La muestra impresa tiene tres eventos; imagina que el array contiene doscientos mil.
{
"generatedAt": "2026-07-17T09:00:00Z",
"events": [
{ "id": "e_1001", "type": "login", "userId": 42, "ok": true, "ms": 128 },
{ "id": "e_1002", "type": "upload", "userId": 42, "ok": false, "ms": 940 },
{ "id": "e_1003", "type": "login", "userId": 7, "ok": true, "ms": 96 }
]
}
Las preguntas que responderemos contra él son las que aparecen de verdad: leer un valor cerca de la raíz, sacar un campo de cada registro, quedarse solo con los registros que cumplen una condición, y contar o promediar sobre todo el array.
jq: la respuesta por defecto
jq es un pequeño programa de línea de comandos que parsea el JSON y ejecuta un filtro sobre él — un único binario que consigues con brew install jq, apt install jq o desde jqlang.org. Un filtro es una expresión que describe la forma de la respuesta, y el más simple es una ruta. Leer un único valor de nivel superior se parece a la ruta que escribirías en código:
jq '.generatedAt' events.json
# "2026-07-17T09:00:00Z"
Las comillas son jq diciéndote que el resultado es una cadena JSON. Cuando quieres el texto pelado — para pasárselo a otro comando — añade -r para salida cruda:
jq -r '.generatedAt' events.json
# 2026-07-17T09:00:00Z
.events[] recorre el array y va enviando cada elemento a lo que venga después de la tubería. Así que «el tipo de cada evento» es una ruta con un recorrido en medio:
jq -r '.events[].type' events.json
# login
# upload
# login
El filtrado es select, que solo deja pasar un registro cuando su condición se cumple. Los IDs de los eventos que fallaron:
jq -r '.events[] | select(.ok == false) | .id' events.json
# e_1002
Léelo de izquierda a derecha: toma cada evento, quédate con los que tienen ok en false, y emite su id. Contar es envolver el resultado en [ ] para recogerlo de vuelta en un array y tomar su length:
jq '[.events[] | select(.type == "login")] | length' events.json
# 2
Cuatro piezas pequeñas — una ruta, [] para recorrer, select para filtrar y length para contar — ya responden la mayoría de las preguntas que provoca un archivo grande. Cuando solo quieres un recuento rápido de una categoría, jq más dos herramientas Unix clásicas le gana a todo lo anterior:
jq -r '.events[].type' events.json | sort | uniq -c | sort -rn
# 2 login
# 1 upload
Leer un archivo más grande que la memoria
Detrás de todo lo anterior se esconde un límite. Por defecto, jq — igual que json.load — lee el documento entero y construye todo el árbol en memoria antes de evaluar nada. Para 80 MB no pasa nada. Para uno más grande que tu RAM, sí, y ningún filtro ingenioso lo cambia: el parser tiene que sostener el valor antes de que el filtro pueda ejecutarse.
Dos cosas te sacan de ahí. La primera es la forma del archivo. Si la exportación es NDJSON — un objeto JSON por línea, sin array que lo envuelva — entonces el uso de memoria depende del tamaño de un solo registro, no del archivo entero, así que permanece aproximadamente estable por muchos registros que se acumulen, porque las herramientas leen cada línea, la procesan y la descartan:
# events.ndjson: un objeto por línea
jq -r 'select(.ok == false) | .id' events.ndjson
Su huella la marca el registro individual más grande, no el tamaño del archivo, así que permanece aproximadamente estable a medida que el archivo crece. Si controlas cómo se exportan los datos, emitir NDJSON en vez de un único array gigante es uno de los cambios más útiles que puedes hacer; a partir de ahí, procesarlo de forma incremental es sencillo.
Si estás atrapado con un array enorme que no creaste tú, tienes que leer del disco en lugar de construir todo el árbol. El propio modo --stream de jq hace justo eso: recorre el archivo emitiendo eventos [ruta, valor], y el modismo fromstream(1 | truncate_stream(...)) reensambla el array de nivel superior un elemento a la vez, de modo que la memoria se mantiene acotada:
# big.json es un único array de nivel superior gigante — procesa sus elementos en flujo, sin cargarlo entero
jq -rn --stream 'fromstream(1 | truncate_stream(inputs)) | select(.ok == false) | .id' big.json
Conviene nombrar dos límites antes de recurrir a ello: esta forma solo funciona cuando el nivel superior es un array, y está pensada para sacar valores, no para combinarlos. Es de bajo nivel y fácil de escribir mal, así que en cuanto el trabajo pasa de extraer un campo — sobre todo si hay agregación real — DuckDB (más abajo) o un parser en streaming como ijson de Python es más fácil de mantener. Recurre a cualquiera de ellas solo cuando el archivo de verdad no quepa; para lo que sí cabe, jq a secas es más simple.
JSONPath: una ruta que puedes pegar en cualquier parte
jq tiene su propia sintaxis, que vale la pena aprender, pero solo existe donde jq está instalado. JSONPath es una idea más pequeña — una expresión de ruta, nada más — implementada en muchos lenguajes y en un montón de herramientas, editores y clientes de API. Si alguna vez escribiste $.store.book[0].title, eso es JSONPath. Encaja limpiamente con las mismas preguntas:
| Pregunta | jq | JSONPath |
|---|---|---|
| Un valor de nivel superior | .generatedAt | $.generatedAt |
| Un campo de cada registro | .events[].type | $.events[*].type |
| Registros que cumplen una condición | .events[] | select(.ok == false) | $.events[?(@.ok == false)] |
| Ese campo, de las coincidencias | .events[] | select(.ok==false) | .id | $.events[?(@.ok == false)].id |
El compromiso es real y conviene decirlo claro. JSONPath selecciona — apunta a valores y te los devuelve. No transforma, no reestructura, no agrega. No hay un JSONPath estándar para «promedio de ms agrupado por tipo», porque agrupar y promediar no son selección. Así que la regla es: recurre a JSONPath cuando quieras el valor en una ubicación, sobre todo cuando la expresión tiene que viajar a código o a una configuración donde una dependencia de jq no es bienvenida; y cambia a jq en cuanto la respuesta exija construir algo nuevo a partir de lo que seleccionaste.
Una advertencia sobre portabilidad: JSONPath consiguió un estándar formal del IETF en 2024, RFC 9535, pero muchas librerías son anteriores o añaden sus propias extensiones — y la sintaxis de filtro (?(@.ok == false)) es justo donde divergen: espacios, comillas, soporte de funciones. La forma mostrada aquí es la común en librerías como jsonpath-plus (que es lo que ejecuta el evaluador enlazado más abajo); revisa la documentación de tu implementación antes de dar por hecho que una expresión funciona igual en otra parte.
Agrupar, promediar y unir: SQL sobre JSON
En cuanto la pregunta se convierte en «cuántos de cada», «cuál es el promedio» o «cuál de estos aparece en aquel otro archivo», dejaste atrás la selección y estás haciendo analítica. jq puede hacerlo — agrupar y promediar ms por tipo es una sola expresión:
jq '.events
| group_by(.type)
| map({ type: .[0].type, count: length, avgMs: (map(.ms) | add / length) })' events.json
Funciona, y para algo puntual está bien. Pero en cuanto la agregación se complica — varias claves de agrupación, una unión con otro archivo, un orden por el agregado — SQL es el lenguaje diseñado de verdad para el trabajo, y DuckDB lo ejecuta sobre JSON directamente desde la línea de comandos (otro binario único: brew install duckdb, o desde duckdb.org). Apúntalo al archivo y despliega el array dentro del SQL:
duckdb -c "
SELECT e.type, count(*) AS n, round(avg(e.ms)) AS avg_ms
FROM (SELECT unnest(events) AS e FROM read_json_auto('events.json'))
GROUP BY e.type
ORDER BY n DESC"
# ┌────────┬───┬────────┐
# │ type │ n │ avg_ms │
# ├────────┼───┼────────┤
# │ login │ 2 │ 112.0 │
# │ upload │ 1 │ 940.0 │
# └────────┴───┴────────┘
read_json_auto muestrea el archivo e infiere las columnas y sus tipos por ti, así que no hay nada que declarar de antemano; unnest despliega la lista events en una fila por evento, y e.type entra en cada struct. Fíjate en que no hay un paso previo jq '.events' > tmp.json. Eso importa: pre-extraer con jq cargaría el archivo entero en memoria y arruinaría el propósito en un archivo demasiado grande para ella, así que DuckDB lee el original directo del disco. (Si el archivo ya cabe en memoria, un paso previo de jq está bien — solo que no es la jugada para el caso enorme.) DuckDB es mucho más tolerante con el tamaño que una tubería que carga todo en jq, aunque un único objeto de nivel superior monolítico todavía hay que parsearlo; la forma enorme más amable es NDJSON o un array de nivel superior que pueda escanear fila por fila. Más allá de eso, cualquier cosa que escribirías contra una tabla de base de datos — WHERE, GROUP BY, JOIN de dos archivos JSON, ORDER BY por una columna calculada — funciona aquí, sobre el archivo JSON directamente, sin un esquema que declarar.
La trampa: buscar un valor con grep
Hay un atajo tentador que falla en silencio: grep '"userId"' events.json. Parece que funciona en la muestra pequeña y te traiciona en la de verdad. grep compara líneas de texto, y el JSON no está organizado por líneas — un valor puede estar en una línea distinta de su clave, un objeto puede abarcar veinte líneas o ninguna, y un archivo minificado es una sola línea, así que grep devuelve todo o nada. Tampoco distingue una clave de un valor de cadena que casualmente contiene los mismos caracteres. En cuanto la estructura importa — y «dame el valor en esta clave» es puramente estructura — necesitas algo que lea el JSON como estructura y no como texto, que es exactamente lo que hacen jq, JSONPath y DuckDB.
Iterar sobre una expresión que se resiste
Los filtros de arriba son cortos porque las preguntas eran limpias. Las reales se ponen espinosas — un select con tres condiciones, una ruta anidada de la que no estás seguro, un corchete que no dejas de colocar mal — y editar una expresión larga reejecutándola contra un archivo de 80 MB cada vez que la cambias es lento y a ciegas. Este es el único sitio donde una herramienta de navegador se gana su lugar. Pega una porción representativa de los datos en el Evaluador de JSONPath y jq y ambos motores se ejecutan mientras escribes, con un recuento de coincidencias en vivo que te dice al instante si tu filtro capturó tres registros o treinta mil — la forma más rápida de llegar a una expresión correcta. Mantiene la entrada en la memoria del navegador, así que es para una muestra representativa y no para el gigabyte entero; una vez que la expresión está bien, la ejecutas a ella sobre el archivo completo con jq en disco.
Cuando no se parsea
Todo esto asume que el archivo es JSON válido. Las exportaciones a menudo no lo son del todo — una descarga truncada, una coma final, o el desajuste entre NDJSON y array, donde una herramienta emite un objeto por línea y se lo entregas a algo que espera un único array (o al revés). El parser falla antes de que ninguna consulta pueda ejecutarse, así que revisa la sintaxis primero: jq . file.json lee el documento entero y, si no puede, informa de la línea y columna del primer error, que suele ser todo lo que necesitas para detectar el carácter descolocado o la forma equivocada. Arréglalo y luego consulta.
Cuándo sí deberías escribir el script
Las consultas ganan para preguntas de solo lectura. Un script se gana su lugar cuando el trabajo deja de ser una pregunta y se vuelve un proceso: unir varios archivos con una lógica que un solo JOIN no expresa, buscar cada registro contra una API externa, transformar y volver a escribir el resultado, o cualquier cosa que ejecutarás de forma periódica y tendrás que mantener. La línea es más o menos esta: si borrarías el código en cuanto imprima la respuesta, debió ser una consulta; si lo vas a ejecutar otra vez la semana que viene, escribe el script.
Qué método, cuándo
| Quieres | Recurre a |
|---|---|
| Cualquier pregunta de solo lectura — filtrar, sacar un campo, reestructurar | jq |
| Una ruta portable a uno o varios valores | JSONPath ($.a.b[*].c) |
| Agrupar, promediar, unir y contar a escala | DuckDB (SQL sobre JSON) |
| Un histograma rápido por categoría | jq -r '…' | sort | uniq -c | sort -rn |
| Iterar sobre una expresión difícil | Evaluador de JSONPath y jq en el navegador |
| Un archivo más grande que la RAM | NDJSON + jq; DuckDB para analítica, jq --stream para extracción puntual |
| No se parsea | jq . para encontrar la línea y la columna |
El reflejo de escribir un script viene de tratar el JSON como un problema de programación. La mayoría de las veces es un problema de consulta — qué registros coinciden, cuál es el valor en esta clave, cuántos de cada uno — y tratarlo así convierte un rodeo de diez minutos en una línea que borrarás en cuanto responda.