Aller au contenu

jq : interroger du JSON

200 Compagnon ⏱ 1 h 40 jqjsonbashlinuxscalewayubuntudebian

À la fin, vous saurez

  • Expliquer pourquoi grep, sed ou cut ne lisent pas du JSON de façon fiable, en citant des différences d'écriture qui ne changent pas les données
  • Prévoir la sortie d'un filtre jq simple (chemins, itération, composition, select, valeur par défaut) en raisonnant en flux de valeurs
  • Extraire d'un document ou d'un flux JSON Lines des valeurs prêtes pour le shell, avec -r, @tsv, @csv, @sh ou --raw-output0, sans perdre l'alignement des champs
  • Passer des valeurs du shell à jq par --arg, --argjson ou --args, et démontrer l'injection que l'on évite ainsi
  • Utiliser les codes de sortie de jq (-e, erreurs d'analyse, erreurs d'exécution) dans un script Bash sous set -euo pipefail
  • Diagnostiquer les erreurs courantes de jq (Cannot index, Cannot iterate over null, parse error) et traiter un fichier JSON Lines qui contient des lignes cassées

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) jq 1.7.1 (Ubuntu 24.04 et Debian 13 ; amont : 1.8.2) python 3.12 (Ubuntu 24.04), 3.13 (Debian 13) , vérifié le 9 octobre 2026

Pourquoi

Parmi les scripts que Camille a laissés sur sig-outils, il y en a un qui aurait dû sonner mercredi après-midi. Il tourne toutes les cinq minutes et compte les erreurs 503 dans le journal JSON de l'API :

n=$(grep -c '"statut":503' /srv/donnees/journaux/sig-app-2.pn-signalements.internal/api.jsonl)

Il n'a rien dit, pour une raison qui n'a rien à voir avec grep (le seuil était mal réglé), mais en le relisant pour l'enquête, l'équipe découvre qu'il ne tient qu'à un fil. Sur le bac à sable de la leçon 1, la version naïve, grep -c 503, trouve 111 lignes ; il n'y a que 101 réponses 503. Les dix autres sont des requêtes réussies dont l'horodatage se termine par .503Z ou dont le chemin est /signalements/13503. La version de Camille, avec "statut":503, donne le bon chiffre, mais seulement parce que l'application écrit son JSON sans aucune espace.

Or l'application est écrite en Python, et un développeur qui « nettoie » l'appel à json.dumps en retirant ses paramètres retombe sur les réglages par défaut du module, que décrit sa documentation : une espace après chaque : et chaque ,, et tout caractère non ASCII remplacé par une séquence \uXXXX. La donnée est la même ; le texte ne l'est plus :

$ python3 -c 'import json; print(json.dumps({"commune": "Bourg-Témoin", "statut": 503}))' | tee defaut.json
{"commune": "Bourg-T\u00e9moin", "statut": 503}
$ grep -c '"statut":503' defaut.json
0
$ grep -c 'Témoin' defaut.json
0

Le compteur d'erreurs tombe à zéro, définitivement, et la recherche par commune ne trouve plus rien. Aucun message d'erreur : grep a fait exactement ce qu'on lui demandait.

Le second script hérité dresse l'inventaire des instances à partir de scw instance server list -o json, avec grep '"address"'. Il affiche bien deux adresses IP publiques, mais rien ne dit à quelle instance chacune appartient, ni lesquelles n'en ont pas. L'information était dans la structure du document, et grep, qui ne voit que des lignes, l'a jetée.

JSON est un format structuré : un arbre de valeurs, dans lequel les espaces ne comptent pas, l'ordre des clés d'un objet n'a pas de sens, et un même caractère peut s'écrire de plusieurs façons. Les outils des leçons précédentes travaillent sur des lignes et des champs ; ils lisent le JSON comme on lirait un plan d'architecte en comptant les traits. Il faut un outil qui analyse le document, l'interroge comme un arbre, puis rend un résultat que le shell sait manipuler. C'est le rôle de jq. Cette leçon apprend à le lire et à l'interroger ; la leçon 8 apprendra à transformer et à produire du JSON.

Les concepts

JSON en quelques règles

La RFC 8259, qui fait référence aujourd'hui, définit le JSON (JavaScript Object Notation) comme un format texte d'échange de données. Une valeur JSON est de l'un de six types :

TypeExempleRemarque
objet{"nom": "sig-app-1", "zone": "fr-par-1"}paires nom-valeur ; les noms sont des chaînes
tableau["app=signalements", "env=prod"]suite ordonnée de valeurs, de types quelconques
chaîne"Bourg-Témoin"entre guillemets doubles, avec des échappements \", \\, \n, \uXXXX...
nombre503, -1.5, 2.5e3pas de distinction entre entier et décimal
booléentrue, false
nulnull

Quatre règles de la RFC expliquent pourquoi les outils de lignes échouent :

  • Les blancs sont insignifiants autour des éléments de structure. {"statut":503}, {"statut": 503} et le même objet réparti sur trois lignes indentées sont un seul et même document.
  • L'ordre des membres d'un objet n'a pas de sens. La RFC note que les bibliothèques diffèrent sur le fait de rendre cet ordre visible ou non ; un programme ne doit pas en dépendre.
  • Les noms d'un objet devraient être uniques (SHOULD, pas MUST) ; quand ils ne le sont pas, dit la RFC, « le comportement du logiciel qui reçoit un tel objet est imprévisible », et beaucoup d'implémentations ne retiennent que la dernière paire.
  • Une chaîne peut s'écrire de plusieurs façons. é peut figurer tel quel en UTF-8 ou sous la forme é. Un échange entre systèmes ouverts doit être encodé en UTF-8 (section 8.1).

Pour les nombres, la RFC laisse les implémentations libres de leur précision, mais conseille de s'en tenir à ce que représente un nombre à virgule flottante en double précision (IEEE 754 binary64) : un entier n'y est exact que jusqu'à 2^53, soit 9 007 199 254 740 992. On y reviendra dans la partie Sous le capot.

Un document, ou un document par ligne

Une réponse d'API ou la sortie de scw ... -o json est un document, souvent un objet ou un tableau, éventuellement indenté sur des centaines de lignes. Un journal ne peut pas fonctionner ainsi : on ne réécrit pas un tableau géant à chaque requête. D'où un format très répandu, JSON Lines (aussi appelé NDJSON, Newline Delimited JSON) : un document JSON complet par ligne, sans retour à la ligne à l'intérieur, chaque ligne terminée par \n, le tout en UTF-8. C'est le format de api.jsonl, le journal JSON de l'API de Signalements : on peut y ajouter une ligne à la fois, le couper avec head ou tail, le compter avec wc -l, et chaque ligne reste un document valide.

La RFC 7464 normalise une variante plus robuste, les JSON text sequences : chaque document est précédé du caractère de contrôle RS (code 0x1E) et suivi d'un saut de ligne, ce qui permet de se resynchroniser après un document tronqué. jq la lit et l'écrit avec l'option --seq, mais elle reste rare dans les journaux.

jq ne fait pas de différence entre les deux cas : il lit son entrée comme une suite de documents séparés par des blancs, quel que soit leur nombre et leur découpage en lignes. Un fichier qui contient un tableau unique est une suite d'un document ; api.jsonl est une suite de 2 193 documents ; trois objets collés sur une ligne en sont trois aussi.

Le modèle de jq : un filtre, zéro, une ou plusieurs sorties

Un programme jq est un filtre. Le manuel le dit dès sa première page : un filtre prend une entrée et produit une sortie, ou plus exactement un flux de sorties, qui peut en compter zéro, une ou plusieurs. jq applique le filtre à chaque document d'entrée, l'un après l'autre, et écrit chaque sortie produite.

Ce modèle est la clé de tout le reste, et la source de presque toutes les surprises :

  • .nom produit une sortie par entrée : la valeur du champ, ou null s'il n'existe pas.
  • .[] produit autant de sorties que d'éléments : zéro pour un tableau vide.
  • select(condition) produit son entrée telle quelle si la condition est vraie, et aucune sortie sinon. C'est ainsi que l'on filtre : une valeur qui ne passe pas le test disparaît simplement du flux.

Quand on écrit .[] | select(.state == "running") | .name, il n'y a pas de boucle, pas de variable, pas de liste intermédiaire. Il y a trois filtres en série : le premier émet chaque instance, le deuxième laisse passer celles qui sont en marche, le troisième remplace chacune par son nom.

Naviguer dans un document

FiltreProduitSur une entrée qui ne convient pas
.l'entrée, inchangée
.nom, .a.b.cla valeur du champ (en profondeur)null si le champ manque ; erreur si l'entrée n'est ni un objet ni null
."x-y", .["clé avec espace"]un champ dont le nom n'est pas un identifiantidem
.[2], .[-1]un élément de tableau, en partant de 0, ou de la finnull hors limites
.[1:3], .[-2:]une tranche de tableau ou de chaîne
.[]chaque élément d'un tableau, chaque valeur d'un objeterreur sur null, un nombre, une chaîne
.nom?, .[]?la même chose, mais sans erreur : aucune sortie au lieu d'une erreur

Deux points méritent d'être retenus. D'abord, un champ absent n'est pas une erreur : .inexistant donne null, en silence. C'est commode, et c'est un piège pour un script qui compte sur jq pour détecter une absence. Ensuite, .x-y ne lit pas le champ x-y : jq le comprend comme .x - y, une soustraction, et se plaint que la fonction y n'existe pas. Les noms qui contiennent autre chose que des lettres, des chiffres et _ s'écrivent entre guillemets : ."x-y".

Composer : la barre, la virgule, les parenthèses, les crochets

  • a | b envoie chaque sortie de a en entrée de b. C'est un tube, au sens de la leçon 6 de Premiers pas, mais entre valeurs JSON et à l'intérieur d'un seul processus.
  • a, b produit les sorties de a, puis celles de b, sur la même entrée. .name, .zone donne deux valeurs par instance.
  • Les parenthèses groupent, comme en arithmétique : .[] | (.name, .zone).
  • Les crochets [ ... ] collectent toutes les sorties d'un filtre dans un tableau. [.[] | .name] donne un seul tableau de noms au lieu de quatre sorties. C'est la façon de compter : [.[] | select(...)] | length. La leçon 8 détaillera la construction de tableaux et d'objets ; pour interroger, ces crochets suffisent.

map(f) est un raccourci pour [.[] | f] : appliquer f à chaque élément et garder un tableau.

Tester et choisir

Les comparaisons ==, !=, <, <=, >, >= fonctionnent sur toutes les valeurs. Entre deux types différents, jq applique un ordre fixe, documenté dans le manuel à propos de sort : null, puis false, true, les nombres, les chaînes, les tableaux, les objets. Conséquences utiles et dangereuses :

  • null < 0 est vrai, donc .requete.statut >= 500 est faux pour un objet qui n'a pas de champ requete : il ne passe pas le filtre, sans erreur.
  • "10" < "9" est vrai : deux chaînes se comparent caractère par caractère. Une valeur numérique stockée en chaîne se compare mal.
  • Deux chaînes de date au format ISO 8601, de même longueur et du même fuseau (2026-10-07T14:02:06.000Z), se comparent dans l'ordre chronologique, justement parce que la comparaison est lexicographique. C'est ce qui permet de filtrer une fenêtre horaire sans convertir les dates.

La vérité suit une règle simple et différente de celle de Bash ou de C : seuls false et null sont faux. 0, "", [] et {} sont vrais. if .x then ... else ... end et select(.x) suivent cette règle.

Les opérateurs and, or et la fonction not (qui s'écrit après un tube : has("requete") | not) combinent des conditions. has("cle") teste la présence d'une clé, ce qui distingue une clé absente d'une clé présente qui vaut null. length donne la taille d'un tableau, d'un objet ou d'une chaîne (en points de code), la valeur absolue d'un nombre, et 0 pour null. type donne le nom du type. keys donne les noms d'un objet triés, keys_unsorted dans l'ordre du document.

L'opérateur a // b (alternative) produit les sorties de a qui ne sont ni false ni null, et, s'il n'y en a aucune, celles de b. C'est la valeur par défaut de jq : .public_ips[0].address // "aucune". Attention : false // "x" donne "x", donc // ne convient pas pour donner un défaut à un champ booléen.

Les chaînes

Dans une chaîne, \( expression ) insère le résultat d'une expression : "\(.name) est en \(.zone)". C'est l'interpolation. Les fonctions de chaînes d'usage courant sont length, split(s) et join(s), startswith(s) et endswith(s), ltrimstr(s) et rtrimstr(s) (retirer un préfixe ou un suffixe s'il est présent), ascii_downcase et ascii_upcase, tostring et tonumber, et les tranches .[0:16]. Les fonctions fondées sur des expressions régulières (test, capture, sub) utilisent la bibliothèque Oniguruma, dont la syntaxe est proche de celle de PCRE ; la leçon 8 y revient.

Sortir du JSON pour le shell

Par défaut, jq écrit du JSON : une chaîne garde ses guillemets et ses échappements, un objet est indenté sur plusieurs lignes. Pour le shell, on choisit la forme de sortie :

Option ou formatEffet
-r (--raw-output)une chaîne est écrite brute, sans guillemets ni échappements, suivie d'un saut de ligne
-j (--join-output)comme -r, sans saut de ligne après chaque sortie
--raw-output0comme -r, avec un octet nul après chaque sortie (nouveauté de jq 1.7)
-c (--compact-output)un document JSON par ligne, sans indentation : la forme JSON Lines
-S (--sort-keys)les clés des objets triées
@tsvun tableau devient une ligne de valeurs séparées par des tabulations, avec \t, \n, \r et \\ échappés
@csvun tableau devient une ligne CSV, chaînes entre guillemets doubles, guillemets doublés
@shune chaîne, ou un tableau de chaînes, protégé pour le shell entre guillemets simples
@uriencodage pour URL (%2C pour la virgule)
@base64, @base64dencodage et décodage Base64
@jsonla valeur sérialisée en JSON, sous forme de chaîne

Les formats s'appliquent à l'entrée du filtre ([.a, .b] | @tsv) ou, placés devant une chaîne, à chaque interpolation qu'elle contient (@sh "rm -- \(.fichier)"). Ils ne servent qu'avec -r : sans lui, la ligne produite serait réécrite comme une chaîne JSON, avec ses guillemets.

Faire entrer des valeurs du shell

Un filtre jq est du code. Comme pour awk à la leçon 5, une valeur du shell ne doit jamais être collée dans son texte : on la passe comme une variable du langage.

OptionVariable crééeType
--arg nom valeur$nomtoujours une chaîne
--argjson nom 'json'$nomla valeur JSON analysée : nombre, objet, etc.
--args a b c (en fin de ligne)$ARGS.positionaltableau de chaînes
--jsonargs 1 '"x"' null$ARGS.positionaltableau de valeurs JSON
(toutes les précédentes)$ARGS.namedobjet de toutes les variables nommées

Le $ dans le filtre est celui de jq, pas celui du shell : d'où les guillemets simples autour du programme, qui empêchent Bash de le remplacer.

Les codes de sortie

Le code de sortie de jq dit ce qui s'est passé. D'après le manuel et les constantes de src/main.c dans le code source de la version 1.7.1 :

CodeSignification
0le programme s'est exécuté (même s'il n'a rien produit, même si la dernière sortie est null)
1avec -e seulement : la dernière sortie est false ou null
2problème d'utilisation ou erreur système : option inconnue, fichier illisible
3erreur de compilation du filtre : faute de syntaxe
4avec -e seulement : aucune sortie produite
5erreur pendant l'exécution, y compris une entrée JSON invalide (parse error)

L'option -e (--exit-status) est donc ce qui permet d'utiliser jq comme une condition dans un if : sans elle, un champ absent passe inaperçu.

En pratique

Les sorties ont été produites avec le paquet jq 1.7.1 d'Ubuntu 24.04, Bash 5.2.21 et LC_ALL=C.UTF-8, dans le bac à sable de la leçon 1. Placez-vous à sa racine :

$ cd ~/essais-texte
$ jq --version
jq-1.7

Note

Le paquet jq d'Ubuntu 24.04 contient bien la version 1.7.1 (dpkg -l jq affiche 1.7.1-3ubuntu0.24.04.2), mais jq --version répond jq-1.7. La cause est un correctif du paquet Debian, patch-version-into-build.patch, qui écrit le numéro de version à la main (jq le calcule normalement à partir du dépôt Git, absent lors de la construction du paquet) et n'a pas été mis à jour pour 1.7.1. Ubuntu en hérite, et le paquet de Debian 13 (1.7.1-6+deb13u3) porte le même correctif. Ne vous fiez donc pas à cette ligne pour savoir quelle version, ni quels correctifs de sécurité, sont installés : interrogez le gestionnaire de paquets (dpkg -l jq).

Regarder avant de filtrer

Avant d'écrire un filtre, on regarde la forme du document. jq . (le filtre identité) analyse le document et le réécrit indenté, ce qui suffit souvent :

$ head -n 1 journaux/sig-app-2.pn-signalements.internal/api.jsonl | jq .
{
  "horodatage": "2026-10-05T00:08:00.195Z",
  "niveau": "info",
  "hote": "sig-app-2",
  "requete": {
    "id": "a23c57a9b98f",
    "methode": "POST",
    "chemin": "/signalements",
    "statut": 201,
    "duree_ms": 15
  },
  "client": {
    "ip": "192.0.2.23",
    "agent": "Mairie-Exempleville-Export/1.0"
  }
}

Le journal JSON apporte deux informations que le journal texte de l'API n'a pas : la durée de chaque requête et l'adresse réelle du client. Le journal texte ne montre que 172.16.8.20, l'adresse du répartiteur de charge ; l'application lit l'adresse d'origine dans l'en-tête que le répartiteur ajoute, et l'écrit dans client.ip.

Pour un document volumineux, on explore par étapes, comme on lirait la table des matières d'un livre :

$ jq length scw/serveurs.json
4
$ jq -c 'map(type)' scw/serveurs.json
["object","object","object","object"]
$ jq -c '.[0] | keys_unsorted' scw/serveurs.json
["id","name","commercial_type","state","zone","tags","image","public_ips","private_nics","creation_date"]
$ jq '.[0].tags' scw/serveurs.json
[
  "app=signalements",
  "env=prod",
  "role=api"
]

Un tableau de quatre objets ; chaque objet a dix clés. keys_unsorted les donne dans l'ordre du document, keys les aurait triées par ordre alphabétique.

Note

scw/serveurs.json est un échantillon abrégé de la sortie de scw instance server list zone=all -o json, réduit aux champs utiles pour la leçon. La vraie sortie contient plusieurs dizaines de champs par instance, et ses valeurs (identifiants, adresses) seront différentes chez vous. Les noms des champs repris ici (name, state, zone, commercial_type, tags, public_ips) sont ceux qu'utilisent les leçons du cours Le cloud : les fondamentaux.

L'inventaire des instances

Première demande de l'équipe : un tableau des instances, avec leur zone, leur état, leur adresse privée et leur adresse publique.

$ jq -r '.[] | [.name, .zone, .state, .private_nics[0].ip, (.public_ips[0].address // "aucune")] | @tsv' scw/serveurs.json
sig-app-1	fr-par-1	running	172.16.8.11	51.15.97.37
sig-app-2	fr-par-2	running	172.16.8.12	51.15.73.124
sig-outils	fr-par-1	running	172.16.8.30	aucune
sig-app-3	fr-par-1	stopped	172.16.8.13	aucune

Lisons le filtre de gauche à droite :

  • .[] émet chaque instance, une par une ;
  • [ ... ] construit, pour chacune, un tableau de cinq valeurs ;
  • .private_nics[0].ip descend dans le premier élément du tableau des interfaces privées ;
  • .public_ips[0].address vaut null quand le tableau est vide (.[0] hors limites donne null, et .address sur null aussi) ; // "aucune" le remplace. Les parenthèses sont nécessaires : sans elles, // porterait sur toute l'expression qui le précède dans le tableau ;
  • @tsv joint le tableau par des tabulations, et -r écrit la ligne brute.

Cette forme, un tableau par enregistrement, puis @tsv, garantit l'alignement : chaque instance donne exactement une ligne de cinq champs, même quand une valeur manque. Comparez avec ce qui semble plus simple :

$ jq -r '.[] | .name, .public_ips[].address' scw/serveurs.json
sig-app-1
51.15.97.37
sig-app-2
51.15.73.124
sig-outils
sig-app-3

.public_ips[] sur un tableau vide ne produit rien : sig-outils et sig-app-3 n'ont pas de ligne d'adresse, et un script qui lirait les lignes deux par deux associerait sig-outils à sig-app-3. C'est exactement le défaut du grep '"address"' de Camille, reproduit avec jq. Le flux de sorties de jq ne garde pas la structure ; c'est à vous de la garder, avec un tableau par enregistrement.

Et si une valeur manque dans le tableau, @tsv écrit un champ vide plutôt que de décaler les autres, comme le montre cat -A, qui affiche les tabulations sous la forme ^I :

$ jq -r '.[] | [.name, .public_ips[0].address] | @tsv' scw/serveurs.json | cat -A
sig-app-1^I51.15.97.37$
sig-app-2^I51.15.73.124$
sig-outils^I$
sig-app-3^I$

Les instances qui ne sont pas en marche, puis le nombre de celles qui le sont :

$ jq -r '.[] | select(.state != "running") | .name' scw/serveurs.json
sig-app-3
$ jq '[.[] | select(.state == "running")] | length' scw/serveurs.json
3

sig-app-3 est arrêtée depuis son retrait (le cours Bash en parlait dans hotes.txt). Une instance arrêtée ne coûte plus de calcul, mais son volume et ses éventuelles adresses restent facturés : elle mérite une ligne dans le rapport.

Les étiquettes : index, contains et le piège des sous-chaînes

Les serveurs de l'API portent l'étiquette role=api. Pour les sélectionner :

$ jq -r '.[] | select(.tags | index("role=api")) | .name' scw/serveurs.json
sig-app-1
sig-app-2
sig-app-3

Appliqué à un tableau, index(x) donne la position de la première occurrence de x, ou null si elle est absente. La position peut valoir 0 : en Bash ou en C, ce serait faux, mais en jq seuls null et false sont faux, donc select laisse passer l'instance. On le vérifie :

$ jq -n '["role=api"] | index("role=api")'
0

La tentation est d'écrire contains, qui a l'air plus lisible. Mais sur des chaînes, contains teste une sous-chaîne, et sur un tableau, il teste si chaque élément demandé est contenu dans un élément du tableau, toujours au sens des sous-chaînes :

$ jq -r '.[] | select(.tags | contains(["role"])) | .name' scw/serveurs.json
sig-app-1
sig-app-2
sig-app-3

Le filtre demandait l'étiquette role, qui n'existe pas ; il a trouvé role=api. Une étiquette role=api-interne serait de même prise pour role=api. Pour une égalité exacte, comparez les éléments eux-mêmes : index(...) comme ci-dessus, ou any(.tags[]; . == "role=api"), que la leçon 8 présentera avec les autres fonctions de quantification.

Des chiffres fiables sur le journal JSON

Retour au compteur de Camille. Avec jq, la condition porte sur la valeur du champ, quelle que soit l'écriture du document :

$ J=journaux/sig-app-2.pn-signalements.internal/api.jsonl
$ jq -c 'select(.requete.statut == 503)' "$J" | wc -l
101
$ jq -c 'select(.statut == 503)' defaut.json
{"commune":"Bourg-Témoin","statut":503}
$ jq -r '.commune' defaut.json
Bourg-Témoin

Le fichier defaut.json, celui que grep ne trouvait plus, est lu sans difficulté : jq a analysé é et le restitue en UTF-8. jq lit chaque ligne de api.jsonl comme un document, applique le filtre, et -c réécrit chaque objet retenu sur une ligne. Le résultat reste du JSON Lines, que l'on peut compter, couper ou repasser à jq.

La répartition des codes de réponse, en combinant jq, qui extrait, et les outils de la leçon 1, qui agrègent :

$ jq '.requete.statut' "$J" | sort | uniq -c
   1752 200
    171 201
    101 304
     61 404
      6 500
    101 503
      1 null

Une ligne null. Le journal contient donc un document qui n'a pas de champ requete, et le filtre .requete.statut l'a traversé sans erreur. Cherchons-le :

$ jq -c 'select(has("requete") | not)' journaux/*/api.jsonl
{"horodatage":"2026-10-07T14:02:06.000Z","niveau":"warning","hote":"sig-app-2","message":"pool de connexions saturé","pool":{"taille":10,"en_attente":37}}

Un avertissement de l'application, de forme différente des lignes de requêtes, à 14:02:06 le jour de l'incident. Un journal structuré mélange souvent plusieurs sortes d'événements : un filtre robuste commence par sélectionner celles qui l'intéressent, select(has("requete")), plutôt que de supposer que toutes les lignes se ressemblent. Pour le reste de l'enquête, cet avertissement est déjà un indice précieux : dix connexions dans le pool, trente-sept requêtes en attente.

La fenêtre de l'incident

Les usagers parlent d'erreurs « vers 14 h ». Les horodatages sont des chaînes ISO 8601 en UTC, toutes de même longueur : on peut les comparer comme des chaînes, et extraire la minute par une tranche.

$ jq -r 'select(.requete.statut >= 500) | .horodatage[0:16]' "$J" | uniq -c
      1 2026-10-05T11:54
      1 2026-10-05T17:23
      1 2026-10-05T19:59
      1 2026-10-06T11:01
      5 2026-10-07T14:02
     10 2026-10-07T14:03
      3 2026-10-07T14:04
      3 2026-10-07T14:05
      6 2026-10-07T14:06
      7 2026-10-07T14:07
      6 2026-10-07T14:08
      6 2026-10-07T14:09
      5 2026-10-07T14:10
      9 2026-10-07T14:11
      4 2026-10-07T14:12
      7 2026-10-07T14:13
      5 2026-10-07T14:14
      6 2026-10-07T14:15
      1 2026-10-07T14:16
      8 2026-10-07T14:17
      7 2026-10-07T14:18
      3 2026-10-07T14:19
      1 2026-10-08T08:23
      1 2026-10-08T09:09
  • .requete.statut >= 500 élimine aussi l'avertissement, puisque null est inférieur à tout nombre.
  • .horodatage[0:16] prend les seize premiers caractères, jusqu'à la minute.
  • uniq -c suffit sans sort parce que le journal est déjà dans l'ordre chronologique ; la leçon 1 a montré pourquoi on trie d'habitude avant uniq.

L'incident apparaît nettement : de 14:02 à 14:19, des erreurs chaque minute, et quelques erreurs isolées les autres jours. Isolons la fenêtre par une comparaison de chaînes, et regardons ce qui s'y passe :

$ jq -r 'select(.horodatage >= "2026-10-07T14:02" and .horodatage < "2026-10-07T14:20") | .requete.statut' "$J" | sort | uniq -c
     30 200
    101 503
      1 null
$ jq -r 'select(.erreur) | .erreur.type' journaux/*/api.jsonl | sort | uniq -c
     13 KeyError
    101 OperationalError

Pendant les dix-huit minutes, plus des trois quarts des requêtes ont échoué. Toutes les 503 viennent de OperationalError, les erreurs de la base de données ; les KeyError, réparties sur la semaine, sont un autre problème, à signaler aux développeurs. select(.erreur) utilise la règle de vérité : un objet erreur est vrai, une clé absente donne null, qui est faux.

Le message complet, une seule fois :

$ jq -r 'select(.erreur.type == "OperationalError") | .erreur.message' "$J" | sort -u
connection to server at "sig-db" failed: FATAL: sorry, too many clients already

PostgreSQL a refusé de nouvelles connexions : son nombre maximal de clients était atteint. Qui étaient les clients concernés ? Si c'était une seule adresse, on penserait à un robot ou à une attaque :

$ jq -r 'select(.horodatage >= "2026-10-07T14:02" and .horodatage < "2026-10-07T14:20" and .requete.statut == 503) | .client.ip' "$J" | sort | uniq -c | sort -rn | head -n 5
      4 192.0.2.8
      3 198.51.100.27
      3 198.51.100.17
      3 192.0.2.15
      2 198.51.100.5
$ jq -r 'select(.requete.statut == 503) | .client.agent' "$J" | sort | uniq -c | sort -rn
     32 Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15
     27 Signalements-Android/3.2.1
     23 Mairie-Exempleville-Export/1.0
     19 Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0

Aucune adresse ne domine, tous les types de clients sont touchés, y compris l'outil d'export de la mairie : ce n'est pas un client abusif, c'est le service qui a cédé. Et les erreurs ne concernent que sig-app-2, ce que l'on vérifie sur les deux journaux à la fois :

$ jq -r 'select(.requete.statut == 503) | .hote' journaux/*/api.jsonl | sort | uniq -c
    101 sig-app-2

jq a lu les deux fichiers à la suite, comme un seul flux de documents. Le tableau de l'enquête se précise : le pool de connexions de sig-app-2 s'est saturé, la base a refusé de nouvelles connexions, l'API a renvoyé des 503 pendant dix-huit minutes. Le journal texte, interrogé avec grep, complète le récit : grep systemd sur le syslog.log de sig-app-2 montre l'arrêt de l'API à 14:20:00 et son redémarrage à 14:20:02, qui a mis fin à l'incident. Reste la cause de fond, un réglage de app.conf, taille_pool, que la leçon 4 corrige sur les deux serveurs.

Les durées, en colonnes pour awk

Pour le volet technique du rapport, l'équipe veut les durées des requêtes. jq les extrait en colonnes, que awk agrège ensuite :

$ jq -r 'select(has("requete")) | [.horodatage, .requete.statut, .requete.duree_ms] | @tsv' "$J" | head -n 3
2026-10-05T00:08:00.195Z	201	15
2026-10-05T00:15:38.353Z	200	62
2026-10-05T00:18:10.581Z	304	34
$ jq -r 'select(.requete.statut == 503) | .requete.duree_ms' "$J" | sort -n | sed -n '1p;$p'
30000
30019

Toutes les 503 ont duré trente secondes : l'application a attendu une connexion jusqu'à son délai, delai = 30 dans app.conf, avant d'abandonner. Chaque requête en échec a donc occupé un processus de travail pendant trente secondes, ce qui aggrave la saturation : un détail que la leçon 6 quantifie avec les percentiles de latence.

Ce partage des rôles est la bonne pratique : jq comprend le JSON et produit des lignes ; sort, uniq et awk savent compter, trier et calculer sur des lignes. La leçon 8 montrera comment faire ces agrégations dans jq lui-même, quand on veut rester en JSON.

Une réponse d'API paginée

L'API de Signalements renvoie ses listes par pages. Le fichier api/signalements-page1.json est la première page d'une réponse :

$ jq 'del(.resultats)' api/signalements-page1.json
{
  "total": 351,
  "page": 1,
  "par_page": 3,
  "suivant": "/signalements?page=2&par_page=3"
}

(del retire un champ de la sortie, sans toucher au fichier ; on l'utilise ici pour masquer le tableau des résultats, et la leçon 8 le détaille.) Les résultats eux-mêmes, en CSV pour un tableur, puis avec le nombre de pièces jointes de chaque signalement :

$ jq -r '.resultats[] | [.id, .type, .commune, .etat] | @csv' api/signalements-page1.json
12000,"signalisation","Exempleville","resolu"
12001,"depot-sauvage","Exempleville","en_cours"
12002,"graffiti","Exempleville","resolu"
$ jq -r '.resultats[] | "\(.id) \(.commune) (\(.pieces_jointes | length) pièce(s))"' api/signalements-page1.json
12000 Exempleville (0 pièce(s))
12001 Exempleville (1 pièce(s))
12002 Exempleville (1 pièce(s))
$ jq -r '.resultats[] | .pieces_jointes[]' api/signalements-page1.json
ab0ccd4aba940e3c.jpg
50208a51a430445c.jpg

@csv met les chaînes entre guillemets et laisse les nombres nus : c'est un CSV conforme à la RFC 4180, qu'un tableur lit correctement même si une commune contient une virgule, comme Saint-Exemple, centre. Avec @tsv, une virgule dans une valeur ne pose aucun problème, mais une tabulation y serait écrite \t.

Pour parcourir toutes les pages, un script suit le champ suivant jusqu'à ce qu'il disparaisse. Le principe, sans sortie ici puisqu'il interroge l'API réelle :

url=/signalements?par_page=100
while [[ -n $url ]]; do
    page=$(curl --fail --silent --show-error "https://api.signalements.example${url}")
    jq -r '.resultats[] | [.id, .commune] | @tsv' <<< "$page"
    url=$(jq -r '.suivant // empty' <<< "$page")
done

.suivant // empty produit le chemin de la page suivante, ou rien sur la dernière page (empty est le filtre qui ne produit aucune sortie). -r sur une sortie vide n'écrit rien, la variable devient vide, la boucle s'arrête. Avec .suivant seul, la dernière page aurait donné la chaîne null, et la boucle aurait demandé l'URL https://api.signalements.example/null.

Passer une valeur du shell, sans l'injecter

Le script d'inventaire prend un nom d'instance en argument. La première idée est de l'insérer dans le filtre par une variable du shell :

$ nom=sig-app-2
$ jq -r ".[] | select(.name == \"$nom\") | .zone" scw/serveurs.json
fr-par-2

Cela fonctionne, jusqu'à ce que la valeur contienne un guillemet. Elle peut alors modifier le programme :

$ nom='x" or true or "'
$ jq -r ".[] | select(.name == \"$nom\") | .name" scw/serveurs.json
sig-app-1
sig-app-2
sig-outils
sig-app-3

Le shell a construit le filtre select(.name == "x" or true or ""), vrai pour toutes les instances. Si ce nom venait d'un formulaire, d'un ticket ou d'une étiquette posée par quelqu'un d'autre, le script qui devait arrêter une instance les aurait toutes visées. C'est l'injection de code, sous la même forme qu'en SQL ou dans un eval de Bash. Un nom simplement maladroit, lui, casse le programme :

$ nom='sig-app-2"x'
$ jq -r ".[] | select(.name == \"$nom\") | .name" scw/serveurs.json
jq: error: syntax error, unexpected IDENT, expecting ';' or ')' (Unix shell quoting issues?) at <top-level>, line 1:
.[] | select(.name == "sig-app-2"x") | .name
jq: 1 compile error

La forme correcte passe la valeur comme une donnée :

$ nom='x" or true or "'
$ jq -r --arg nom "$nom" '.[] | select(.name == $nom) | .name' scw/serveurs.json
$ jq -r --arg nom sig-app-2 '.[] | select(.name == $nom) | .zone' scw/serveurs.json
fr-par-2

La valeur hostile n'est qu'une chaîne, qui ne correspond à aucun nom : aucune sortie. Le programme, entre guillemets simples, ne change jamais.

--arg produit toujours une chaîne. Pour un nombre ou une valeur JSON, on utilise --argjson, ou l'on convertit dans le filtre :

$ jq -n --arg n 5 '$n + 1'
jq: error (at <unknown>): string ("5") and number (1) cannot be added
$ jq -n --argjson n 5 '$n + 1'
6
$ jq -n --arg n 5 '($n | tonumber) + 1'
6

-n (--null-input) lance le filtre une fois, sur l'entrée null, sans rien lire : pratique pour essayer une expression. $ARGS rassemble tout ce qui a été passé :

$ jq -n -c --arg a 1 --argjson b '{"x":2}' '$ARGS'
{"positional":[],"named":{"a":"1","b":{"x":2}}}
$ jq -n -c '$ARGS' --args un 'deux mots'
{"positional":["un","deux mots"],"named":{}}

--args doit venir après le filtre et les fichiers : tout ce qui suit est pris comme argument positionnel. On l'utilise pour passer une liste, par exemple les noms reçus par un script ("$@"), sans boucle.

jq dans un script Bash

Trois façons de récupérer les résultats, selon leur forme.

Une valeur par ligne, dans un tableau Bash, avec mapfile et une substitution de processus (la leçon 5 du cours Bash explique pourquoi pas de tube) :

$ mapfile -t actifs < <(jq -r '.[] | select(.state == "running") | .name' scw/serveurs.json)
$ declare -p actifs
declare -a actifs=([0]="sig-app-1" [1]="sig-app-2" [2]="sig-outils")

Plusieurs champs par enregistrement, avec @tsv et read sur la tabulation :

$ while IFS=$'\t' read -r nom zone ip; do
>     printf '%s est en %s (%s)\n' "$nom" "$zone" "$ip"
> done < <(jq -r '.[] | [.name, .zone, .private_nics[0].ip] | @tsv' scw/serveurs.json)
sig-app-1 est en fr-par-1 (172.16.8.11)
sig-app-2 est en fr-par-2 (172.16.8.12)
sig-outils est en fr-par-1 (172.16.8.30)
sig-app-3 est en fr-par-1 (172.16.8.13)

La tabulation est un séparateur strict pour read quand IFS n'en contient pas d'autre ; mais la tabulation est aussi un blanc d'IFS, si bien que deux tabulations consécutives (un champ vide) sont fusionnées par read, et les champs suivants se décalent. Quand un champ peut être vide, faites-lui produire une valeur explicite (// "-"), ou lisez les enregistrements un par un avec --raw-output0.

Des valeurs quelconques, qui peuvent contenir des sauts de ligne (un message d'erreur, un nom de fichier) : --raw-output0 les sépare par l'octet nul, le seul séparateur sûr, comme find -print0 :

$ jq --raw-output0 '.[].name' scw/serveurs.json | od -c | head -n 3
0000000   s   i   g   -   a   p   p   -   1  \0   s   i   g   -   a   p
0000020   p   -   2  \0   s   i   g   -   o   u   t   i   l   s  \0   s
0000040   i   g   -   a   p   p   -   3  \0

On les lit avec while IFS= read -r -d '' valeur ou mapfile -t -d ''. Cette option est apparue avec jq 1.7 ; sur une machine plus ancienne, elle n'existe pas.

Une condition, avec -e. Le filtre doit produire une seule valeur, vraie ou fausse :

$ if jq -e --arg n sig-app-3 'any(.[]; .name == $n and .state == "running")' scw/serveurs.json > /dev/null; then
>     echo active
> else
>     echo "pas active (code $?)"
> fi
pas active (code 1)

Sans -e, jq aurait renvoyé 0 dans les deux cas, et le if aurait toujours conclu « active ». Sous set -e, un jq qui renvoie 1 en dehors d'une condition arrête le script : c'est souvent ce que l'on veut pour une donnée obligatoire, comme un identifiant d'instance que le script va utiliser ensuite.

Les autres codes de sortie se rencontrent vite :

$ jq '.name' scw/serveurs.json; echo "code $?"
jq: error (at scw/serveurs.json:103): Cannot index array with string "name"
code 5
$ jq '.[0].inexistant' scw/serveurs.json; echo "code $?"
null
code 0
$ jq -e '.[0].inexistant' scw/serveurs.json; echo "code $?"
null
code 1
$ jq -e '.[] | select(.name == "sig-app-9")' scw/serveurs.json; echo "code $?"
code 4
$ jq '.[] | .name' scw/serveurs.json nexistepas.json; echo "code $?"
jq: error: Could not open file nexistepas.json: No such file or directory
"sig-app-1"
"sig-app-2"
"sig-outils"
"sig-app-3"
code 2
$ jq '.[] | .name |' scw/serveurs.json; echo "code $?"
jq: error: syntax error, unexpected end of file (Unix shell quoting issues?) at <top-level>, line 1:
.[] | .name |
jq: 1 compile error
code 3
  • Cannot index array with string "name" : le document est un tableau, et l'on a demandé un champ comme s'il était un objet. Il manque .[]. Le numéro 103 est la ligne du fichier où jq se trouvait, ici la fin du document.
  • Un fichier absent n'arrête pas le traitement des autres, mais fixe le code à 2 : sous set -o pipefail, un tube qui contient ce jq échouera, même si sa sortie semble complète.
  • La mention (Unix shell quoting issues?) est l'indice que jq donne à chaque faute de syntaxe : la cause la plus fréquente est un filtre mal protégé par les guillemets du shell.

Quand une ligne est cassée

Un journal JSON Lines peut contenir une ligne invalide : une écriture interrompue par un arrêt brutal, une ligne coupée par une rotation, un programme qui a écrit un message en texte brut au milieu. jq s'arrête à la première erreur d'analyse :

$ printf '{"a":1}\n{"a":2\n{"a":3}\n' > casse.jsonl
$ jq -c .a casse.jsonl; echo "code $?"
jq: parse error: Expected separator between values at line 3, column 1
1
code 5

La deuxième ligne n'est pas fermée ; jq s'en aperçoit au début de la troisième (d'où « line 3 »), abandonne, et perd aussi la troisième ligne, pourtant valide. Le même problème touche la dernière ligne d'un journal en cours d'écriture, lu au mauvais moment :

$ head -c -40 "$J" > coupe.jsonl
$ jq -c 'select(.requete.statut == 503)' coupe.jsonl | wc -l
jq: parse error: Unfinished string at EOF at line 2193, column 249
101

Le résultat est complet ici, puisque seule la dernière ligne manque, mais jq a fini avec le code 5 : un script de supervision sous pipefail le prendra pour une panne.

La solution est de lire chaque ligne comme du texte, puis de l'analyser soi-même. -R (--raw-input) fait de chaque ligne une chaîne ; fromjson analyse une chaîne comme du JSON ; le ? transforme l'erreur en absence de sortie :

$ jq -c -R 'fromjson? | .a' casse.jsonl; echo "code $?"
1
3
code 0

Pour ne pas perdre l'information, try ... catch remplace l'erreur par une valeur de votre choix, et input_line_number donne le numéro de la ligne lue :

$ jq -c -R 'try (fromjson | .a) catch "ligne \(input_line_number) illisible"' casse.jsonl
1
"ligne 2 illisible"
3
$ jq -c -R 'try fromjson catch ("ligne \(input_line_number) : JSON invalide\n" | stderr | empty)' casse.jsonl > valides.jsonl
ligne 2 : JSON invalide

La seconde forme envoie le message sur la sortie d'erreur avec stderr (qui, depuis jq 1.7, écrit une chaîne telle quelle) et ne garde sur la sortie standard que les documents valides. Cette méthode ne convient qu'au JSON Lines : un document indenté sur plusieurs lignes ne s'analyse pas ligne par ligne.

Sous le capot

Analyser, évaluer, sérialiser

jq ne modifie jamais du texte. Pour chaque document, il fait trois choses distinctes :

  1. Analyser : le texte devient une valeur en mémoire (un arbre d'objets, de tableaux, de chaînes et de nombres). Les blancs, la forme des échappements, l'écriture des nombres disparaissent.
  2. Évaluer le filtre sur cette valeur, ce qui produit un flux de valeurs.
  3. Sérialiser chaque valeur produite en texte, selon les options de sortie.

D'où des effets qui surprennent quand on attend une édition de texte :

$ echo '{"t":"caf\u00e9","z":1,"a":2}' | jq -c .
{"t":"café","z":1,"a":2}
$ echo '{"z":1,"a":2}' | jq -S -c .
{"a":2,"z":1}
$ echo '{"a":1,"a":2}' | jq -c .
{"a":2}
$ python3 -c 'import json; print(json.dumps({"commune": "Bourg-Témoin"}))' | jq -a -c .
{"commune":"Bourg-T\u00e9moin"}
  • La séquence \u00e9 est devenue un é en UTF-8 : jq écrit les caractères non ASCII tels quels, sauf avec -a (--ascii-output), qui rétablit l'échappement, comme le montre la dernière commande.
  • L'ordre des clés est conservé (jq garde l'ordre d'insertion), sauf avec -S.
  • Une clé en double ne garde que la dernière valeur, comme le prévoyait la RFC pour « beaucoup d'implémentations ». Un document qui contient "role":"lecteur" puis "role":"admin" sera lu comme admin par jq, et peut-être comme lecteur par un autre programme. Ce désaccord entre analyseurs est une source connue de failles de sécurité.

Conséquence pratique : jq . fichier ne rend pas le fichier « tel quel, mais joli ». Il rend une représentation des données ; pour comparer deux documents JSON, comparez les sorties de jq -S . plutôt que les fichiers bruts.

Des générateurs et du retour arrière

Le wiki de jq décrit le langage comme un langage de générateurs : toute expression peut produire plusieurs valeurs, et une expression qui en combine d'autres essaie toutes les combinaisons, par retour arrière (backtracking), comme le ferait une boucle imbriquée.

$ jq -n -c '(1,2) + (10,20)'
11
12
21
22
$ jq -n -c '[(1,2) + (10,20)]'
[11,12,21,22]

L'addition a été évaluée pour chaque combinaison : pour la première valeur de droite (10), chaque valeur de gauche, puis pour la seconde (20). C'est le même mécanisme qui fait que .[] | select(...) filtre : select produit son entrée ou rien, et « rien » revient à abandonner cette branche pour passer à l'élément suivant. empty est le générateur vide ; .[]? sur une entrée qu'on ne peut pas parcourir se comporte comme empty :

$ jq -n -c '[empty]'
[]
$ jq -n -c '[.[]?]'
[]

Cette façon de penser explique les résultats « en trop » ou « en moins » : une virgule ou un .[] placé au mauvais endroit dans un filtre multiplie les sorties ; un champ manquant qui produit empty (par exemple un tableau vide parcouru) fait disparaître toute une branche, comme l'adresse publique de sig-outils plus haut.

Le programme compilé

jq compile le filtre en un code pour une petite machine virtuelle à pile, puis l'exécute sur chaque entrée. L'option --debug-dump-disasm montre ce code :

$ echo '{"a":1}' | jq --debug-dump-disasm '.a'
0000 TOP
0001 PUSHK_UNDER "a"
0003 INDEX
0004 RET

1

Pour .a, empiler la constante "a", indexer l'entrée, rendre le résultat. Un filtre plus riche donne des instructions FORK et BACKTRACK, qui implémentent les générateurs décrits ci-dessus. Vous n'aurez pas à lire ce code ; retenez que le filtre est compilé une fois, puis appliqué à chaque document, ce qui rend jq rapide sur un flux de petits documents.

Les nombres

jq représente les nombres en double précision. Depuis la version 1.7, d'après son manuel, il conserve le littéral d'origine d'un nombre tant qu'aucun calcul ne le touche, ce qui évite de corrompre les grands identifiants en simple passage :

$ echo '{"id":9007199254740993}' | jq -c '.id, .id + 0'
9007199254740993
9007199254740992
$ echo '{"id":12345678901234567890}' | jq -c '.id + 0'
12345678901234567000

Recopié tel quel, 9007199254740993 (2^53 + 1) est intact ; dès qu'on lui ajoute 0, il devient le double le plus proche, 2^53. La comparaison, en revanche, se fait sur les valeurs converties : deux identifiants proches de 2^64 peuvent être déclarés égaux. Avec jq 1.6 et les versions plus anciennes, encore présentes sur de vieilles distributions, même la simple recopie arrondissait. La règle de prudence vaut pour tous les outils : un identifiant n'est pas un nombre. S'il dépasse 2^53, faites-le transporter en chaîne par le producteur.

Un document entier en mémoire

Pour évaluer un filtre, jq construit tout le document en mémoire. Sur un flux JSON Lines, ce n'est qu'une ligne à la fois ; sur un document unique de plusieurs gigaoctets, c'est tout le document. L'option -s (--slurp) lit tous les documents de l'entrée dans un seul tableau, ce qui revient au même. Sur un journal dix fois plus gros que celui du bac à sable (preparer-donnees --volume 10, 21 921 lignes, 6,2 Mo), mesuré avec /usr/bin/time :

CommandeMémoire maximale
jq -c 'select(.requete.statut == 503)' (un document à la fois)3,5 Mo
jq -s 'map(select(.requete.statut == 503)) | length' (tout le fichier)50 Mo

Le rapport est d'environ quinze : la représentation en mémoire d'une valeur JSON occupe plusieurs fois la taille de son texte. Sur ce petit journal, le temps reste négligeable (moins d'un dixième de seconde dans les deux cas) ; sur un journal de plusieurs gigaoctets, -s épuisera la mémoire. La leçon 8 montrera inputs, qui permet d'agréger tout un flux sans le charger, et --stream, qui découpe un document géant en petits morceaux.

Enfin, l'analyseur de jq 1.7.1 limite la profondeur d'imbrication à 256 niveaux (constante MAX_PARSING_DEPTH de src/jv_parse.c) :

$ python3 -c 'print("[" * 257 + "]" * 257)' | jq length
jq: parse error: Exceeds depth limit for parsing at line 1, column 257

C'est une protection contre les documents conçus pour épuiser la pile ; d'après son journal des modifications, jq 1.8 a relevé cette limite à 10 000.

Pièges courants

Découper du JSON avec grep, sed ou cut. Blancs, ordre des clés, échappements \uXXXX et retours à la ligne changent le texte sans changer les données. Analysez avec jq, puis traitez des lignes.

Oublier -r. nom=$(jq '.name' ...) met "sig-app-1", guillemets compris, dans la variable ; ssh "$nom" cherchera un hôte au nom bizarre. -r pour toute valeur destinée au shell.

Croire qu'un champ absent provoque une erreur. .inexistant vaut null, code 0. Une faute de frappe dans un nom de champ donne des null en silence. -e, has("cle"), ou un contrôle // error("champ manquant").

Cannot iterate over null. .liste[] sur un champ absent. Vérifiez le nom du champ ; si son absence est normale, .liste[]? ou (.liste // [])[].

Cannot index array with string. Le document est un tableau : .[] | .nom ou .[0].nom, pas .nom.

Perdre l'alignement des champs. .a, .b[] ne produit rien pour un b vide, et les lignes se décalent. Un tableau par enregistrement, puis @tsv ou @csv.

contains pour une égalité. contains cherche des sous-chaînes : ["role=api"] | contains(["role"]) est vrai. index(x), ou une comparaison == sur chaque élément.

Comparer une chaîne et un nombre. select(.statut == "503") ne trouve rien si statut est un nombre, et "10" < "9" est vrai. Regardez le type avec type, convertissez avec tonumber ou tostring.

Les guillemets du shell. Un filtre entre guillemets doubles laisse Bash remplacer $nom et interpréter ! en mode interactif. Guillemets simples autour du filtre, --arg pour les valeurs.

.x-y au lieu de ."x-y". Le tiret est une soustraction : y/0 is not defined.

jq ... fichier > fichier. La redirection vide le fichier avant que jq ne le lise : le résultat est un fichier vide. Écrivez dans un fichier temporaire, vérifiez, puis renommez ; la leçon 8 détaille la procédure.

--arg pour un nombre. $n + 1 échoue avec string ("5") and number (1) cannot be added. --argjson, ou ($n | tonumber).

// sur un booléen. .actif // true vaut true même quand actif vaut false. Testez la présence avec has("actif"), ou écrivez if .actif == null then true else .actif end.

Une ligne cassée dans un JSON Lines. jq s'arrête à la première, perd la suite et sort en code 5. -R 'fromjson?', ou try ... catch pour garder la trace.

Sécurité

  • Ne jamais coller une valeur dans le filtre. C'est la démonstration de la partie pratique : une valeur contenant " or true or " change le sens du programme. --arg, --argjson, --args, toujours, y compris pour des valeurs « sûres » aujourd'hui : un nom d'instance, une étiquette, une adresse viennent tôt ou tard de quelqu'un d'autre.

  • Produire du shell avec @sh, et seulement avec lui. S'il faut vraiment générer une commande à partir de données JSON, @sh protège chaque valeur entre guillemets simples, y compris les apostrophes des noms de communes :

    $ jq -n -r '"Val-d'"'"'Essai" | @sh'
    'Val-d'\''Essai'
    $ jq -n -r '["a b", "c'"'"'d"] | @sh'
    'a b' 'c'\''d'
    

    Mais préférez toujours lire les valeurs dans des variables ou un tableau Bash (mapfile, read) et les passer en arguments entre guillemets, sans jamais passer par eval.

  • Les séquences d'échappement du terminal. Une chaîne JSON peut contenir n'importe quel caractère de contrôle, écrit \u001b. Sans -r, jq l'affiche échappée ; avec -r, il écrit le vrai caractère, qui sera interprété par le terminal :

    $ printf '"\\u001b[31mALERTE\\u001b[0m"\n' > esc.json
    $ jq . esc.json
    "\u001b[31mALERTE\u001b[0m"
    $ jq -r . esc.json | od -c | head -n 1
    0000000 033   [   3   1   m   A   L   E   R   T   E 033   [   0   m  \n
    

    Un champ agent ou un chemin de requête choisi par un client peut ainsi changer les couleurs de votre terminal, effacer des lignes de l'écran ou, dans un journal recopié, faire croire à des événements qui n'ont pas eu lieu. Pour regarder des données venues de l'extérieur, gardez la sortie JSON (sans -r) ou passez par cat -v, comme le recommandait la leçon 1.

  • Les documents hostiles. Un document très profond, très gros ou très long à analyser peut servir à épuiser la mémoire ou le processeur d'un script qui traite des données reçues. Bornez la taille avant d'analyser (curl --max-filesize, head -c), évitez -s sur des entrées non maîtrisées, et appliquez un délai (timeout, vu à la leçon 11 du cours Bash).

  • Les doublons de clés. jq garde la dernière valeur ; un autre analyseur, la première. Quand une décision de sécurité dépend d'un champ (un rôle, une autorisation), le document doit être validé par le même analyseur que celui qui l'applique, et un document qui contient des doublons devrait être refusé.

  • Les secrets à l'écran et dans les journaux. jq . reponse.json affiche tout, y compris une secret_key renvoyée par scw iam api-key create ou un jeton dans une réponse d'API. Dans une CI, cette sortie part dans les journaux du pipeline. Extrayez seulement les champs nécessaires (jq -r .access_key), comme le fait la leçon 7 du cours sur le cloud.

  • Les données personnelles. client.ip et client.agent sont des données personnelles au sens du RGPD. Une extraction pour une enquête reste sur sig-outils et ne part pas en pièce jointe d'un ticket ; si elle doit sortir, on l'anonymise d'abord, comme le fait la leçon 3 pour le journal texte.

En production

  • Toutes les CLI parlent JSON. scw ... -o json, kubectl get ... -o json, aws ... --output json, terraform output -json, gh api : leur sortie lisible change d'une version à l'autre, leur sortie JSON est un contrat. Un script d'exploitation demande toujours du JSON et le lit avec jq, comme le font les leçons Régions et zones et Instances du cours sur le cloud.
  • Journaliser en JSON Lines. Le journal JSON de l'API a permis, en quelques commandes, ce que le journal texte ne permettait pas : la durée de chaque requête, l'adresse réelle du client, le type d'erreur. Un objet par ligne, des noms de champs stables, un horodatage ISO 8601 en UTC, un champ qui distingue les sortes d'événements : ce sont les conditions pour que jq, et plus tard un outil de centralisation des journaux, puissent l'exploiter.
  • Fixer la version de jq. Ubuntu 24.04 et Debian 13 fournissent 1.7.1 ; la version amont est 1.8.2. D'après son journal des modifications, jq 1.8 change des comportements sur lesquels un filtre peut reposer : ltrimstr et rtrimstr échouent sur une entrée qui n'est pas une chaîne (en 1.7.1, 42 | ltrimstr("x") rend 42), limit avec un nombre négatif devient une erreur, indices et index comptent en points de code et non plus en octets, tonumber refuse les espaces autour du nombre, --indent 0 n'implique plus -c. Il ajoute aussi des fonctions, comme trim, ltrim et rtrim. Un filtre testé sur un poste à jour peut donc se comporter autrement dans une image de CI ou sur un serveur : installez la version de la distribution partout, ou épinglez la même version dans vos images.
  • Garder les filtres longs dans des fichiers. Au-delà d'une ligne, un filtre se range dans un fichier lib/nom.jq, se charge avec jq -f, se relit en revue de code et se teste sur des exemples d'entrée versionnés. La leçon 8 en fait une règle pour rapport-hebdo.
  • jq extrait, les autres agrègent. Pour des comptages simples, jq -r ... | sort | uniq -c est lisible et rapide. Pour des agrégations multiples, calculez en jq (leçon 8) ou passez à awk (leçon 6) ; pour une logique de plusieurs dizaines de lignes, Python et son module json sont plus lisibles qu'un filtre jq.
  • La performance n'est presque jamais le problème. Sur le journal de 6,2 Mo, jq -c 'select(...)' met moins d'un dixième de seconde, à peu près comme un script Python équivalent ; grep -c est dix fois plus rapide, mais faux dès que le format bouge. Le coût de jq est dans la mémoire des gros documents uniques, pas dans le nombre de lignes.
  • Les lignes incomplètes. Un script qui lit un journal pendant qu'il est écrit, ou juste après une rotation, rencontrera une dernière ligne tronquée. Prévoyez -R 'fromjson?' dans les outils de supervision, sinon chaque lecture au mauvais moment déclenche une fausse alerte.

Exercices

1. Prévoir des sorties (niveau 100). Sur l'entrée {"a":{"b":[1,2]},"c":null,"d":"10"}, prévoyez ce que produit chaque filtre (une valeur, plusieurs, aucune, une erreur), puis vérifiez avec echo '...' | jq -c 'filtre' : (a) .a.b[] ; (b) .a.b[-1] ; (c) .c.x ; (d) .e ; (e) .d | length ; (f) .c | length ; (g) .d + 1 ; (h) .c[] ; (i) .c[]? ; (j) .c // "rien" ; (k) .a.b[] | select(. > 1) ; (l) .a.b, .d.

Solution

(a) Deux sorties, 1 puis 2. (b) 2 : un indice négatif part de la fin. (c) null : .x sur null donne null, sans erreur. (d) null : clé absente. (e) 2 : la longueur de la chaîne "10". (f) 0 : la longueur de null vaut 0. (g) Erreur, string ("10") and number (1) cannot be added : "10" est une chaîne. (h) Erreur, Cannot iterate over null (null). (i) Aucune sortie. (j) "rien". (k) 2 : 1 ne passe pas select. (l) Deux sorties, [1,2] puis "10".

Les cas (c), (d), (f) et (i) sont ceux qui trompent un script : aucune erreur, alors que la donnée n'existe pas.

2. Un inventaire par zone (niveau 100). À partir de scw/serveurs.json, affichez le nom et l'adresse privée des instances de la zone fr-par-1, séparés par une tabulation. Puis affichez le nombre d'instances en marche, toutes zones confondues.

Solution
$ jq -r '.[] | select(.zone == "fr-par-1") | [.name, .private_nics[0].ip] | @tsv' scw/serveurs.json
sig-app-1	172.16.8.11
sig-outils	172.16.8.30
sig-app-3	172.16.8.13
$ jq '[.[] | select(.state == "running")] | length' scw/serveurs.json
3

Sur la vraie CLI, le filtre par zone se fait de préférence côté API (scw instance server list zone=fr-par-1 -o json) : moins de données transférées, et la même logique dans jq quand on veut toutes les zones (zone=all).

3. Un script qui répond par son code de sortie (niveau 200). Écrivez etat-instance NOM [FICHIER_JSON] qui lit la sortie JSON de scw instance server list (dans un fichier, ou sur l'entrée standard), affiche NOM : état, et se termine avec le code 0 si l'instance est en marche, 1 si elle existe mais n'est pas en marche, 2 si elle est inconnue ou si le JSON est illisible. Le nom doit être passé sans risque d'injection, et le script doit fonctionner sous set -euo pipefail.

Solution
#!/usr/bin/env bash
# etat-instance : indique si une instance est en marche, d'après la sortie JSON de scw.
# Usage : etat-instance NOM [FICHIER_JSON]   (codes : 0 en marche, 1 arrêtée, 2 inconnue ou erreur)
set -euo pipefail

(( $# >= 1 && $# <= 2 )) || { printf 'Usage : %s NOM [FICHIER_JSON]\n' "${0##*/}" >&2; exit 2; }
nom=$1
fichier=${2:-/dev/stdin}

if ! etat=$(jq -er --arg nom "$nom" \
        '[.[] | select(.name == $nom) | .state][0]' "$fichier" 2>/dev/null); then
    printf 'etat-instance : instance inconnue ou JSON illisible : %s\n' "$nom" >&2
    exit 2
fi
printf '%s : %s\n' "$nom" "$etat"
[[ $etat == running ]]
$ for n in sig-app-1 sig-app-3 sig-app-9 'x" or true or "'; do ./etat-instance "$n" scw/serveurs.json; echo "code $?"; done
sig-app-1 : running
code 0
sig-app-3 : stopped
code 1
etat-instance : instance inconnue ou JSON illisible : sig-app-9
code 2
etat-instance : instance inconnue ou JSON illisible : x" or true or "
code 2
$ echo '[' | ./etat-instance sig-app-1; echo "code $?"
etat-instance : instance inconnue ou JSON illisible : sig-app-1
code 2
  • [ ... ][0] garde le premier résultat si deux instances portaient le même nom, et donne null si aucune ne correspond ; -e transforme ce null en code 1, et une erreur d'analyse donne le code 5 : dans les deux cas, la branche d'erreur du if.
  • L'affectation etat=$(...) dans le if évite que set -e n'arrête le script avant le message.
  • La dernière commande, [[ ... ]], fixe le code de sortie du script : 0 ou 1.
  • 2>/dev/null masque le message de jq au profit du nôtre. Dans un vrai outil, gardez-le dans un journal de débogage : « JSON illisible » et « instance inconnue » méritent d'être distingués quand on cherche une panne.

4. Comparer les deux serveurs pendant l'incident (niveau 200). Pour chaque hôte, comptez les réponses 5xx entre 14:00 et 14:30 le 7 octobre, et donnez la durée maximale de ces requêtes, en lisant les deux journaux JSON en une seule commande jq. Expliquez pourquoi sig-app-1 n'apparaît pas, puis retrouvez l'avertissement qui précède l'incident et ce qu'il indique.

Solution
$ jq -r 'select(.requete.statut >= 500 and .horodatage >= "2026-10-07T14:00" and .horodatage < "2026-10-07T14:30") | [.hote, .requete.duree_ms] | @tsv' journaux/*/api.jsonl \
>   | sort -k1,1 -k2,2n | awk '{ n[$1]++; max[$1] = $2 } END { for (h in n) print h, n[h], max[h] }'
sig-app-2 101 30019

sig-app-1 n'a eu aucune erreur 5xx dans la fenêtre : il n'a pas de ligne, puisque select a éliminé toutes les siennes. Pour un rapport, il faudrait l'afficher avec un zéro, ce que l'on fera en partant de la liste des hôtes (leçons 6 et 8). Le tri numérique sur la deuxième colonne fait que la dernière valeur vue par awk pour chaque hôte est la plus grande.

$ jq -c 'select(.niveau == "warning")' journaux/*/api.jsonl
{"horodatage":"2026-10-07T14:02:06.000Z","niveau":"warning","hote":"sig-app-2","message":"pool de connexions saturé","pool":{"taille":10,"en_attente":37}}

Quatre secondes avant la première 503, le pool de connexions de sig-app-2 était plein (dix connexions), avec trente-sept requêtes en attente. Les erreurs des requêtes disent la suite : en voulant ouvrir de nouvelles connexions, l'application s'est heurtée à la limite de la base elle-même (too many clients already), et chaque requête a attendu trente secondes avant d'échouer, ce qui a encore allongé la file. Deux réglages sont en cause, la limite de connexions de sig-db et la taille du pool : ce sont les deux actions décidées le jeudi, dont la seconde, taille_pool passée de 10 à 20 sur les deux serveurs, est l'objet de la leçon 4.

5. Un journal avec des lignes cassées (niveau 200). Un fichier JSON Lines contient des lignes invalides à des endroits inconnus. Écrivez une commande qui écrit les documents valides, compacts, dans valides.jsonl, signale chaque ligne invalide avec son numéro sur la sortie d'erreur, et se termine avec le code 0. Expliquez pourquoi jq -c . fichier > valides.jsonl ne convient pas, et pourquoi votre solution ne convient pas à un fichier JSON indenté.

Solution
$ jq -c -R 'try fromjson catch ("ligne \(input_line_number) : JSON invalide\n" | stderr | empty)' casse.jsonl > valides.jsonl
ligne 2 : JSON invalide
$ cat valides.jsonl
{"a":1}
{"a":3}

jq -c . s'arrête à la première erreur d'analyse, avec le code 5, et perd tous les documents suivants, valides ou non. Ici, il aurait perdu {"a":3}. En lisant chaque ligne comme une chaîne (-R), puis en l'analysant avec fromjson dans un try, une erreur ne touche qu'une ligne.

Un fichier indenté répartit un document sur plusieurs lignes : aucune ligne prise seule n'est un document complet, et toutes seraient déclarées invalides. Cette technique ne vaut que pour le JSON Lines, où la ligne est l'unité de document.

Récapitulatif

  • JSON est un arbre : blancs, ordre des clés et échappements changent le texte sans changer les données. On l'analyse avec jq, jamais avec grep, sed ou cut.
  • jq lit une suite de documents : un document unique ou un JSON Lines (un document par ligne) se traitent de la même façon.
  • Un filtre transforme une entrée en zéro, une ou plusieurs sorties. | enchaîne, , juxtapose, [ ... ] collecte, select garde ou élimine, empty ne produit rien.
  • Chemins : .a.b, ."x-y", .[n], .[a:b], .[] ; ? supprime les erreurs. Un champ absent vaut null, sans erreur.
  • Seuls null et false sont faux ; 0, "" et [] sont vrais. // donne une valeur par défaut, mais pas pour un booléen.
  • Types ordonnés null < false < true < nombres < chaînes < tableaux < objets ; des dates ISO 8601 de même format se comparent comme des chaînes.
  • Pour le shell : -r, un tableau par enregistrement puis @tsv ou @csv, --raw-output0 pour des valeurs quelconques, @sh en dernier recours.
  • Les valeurs du shell entrent par --arg (chaîne), --argjson (JSON), --args ; jamais collées dans le filtre.
  • Codes de sortie : 0 même pour null, 1 et 4 avec -e, 2 fichier ou usage, 3 syntaxe du filtre, 5 erreur d'exécution ou JSON invalide.
  • Une ligne cassée arrête jq : -R 'fromjson?' ou try ... catch pour le JSON Lines.
  • Sous le capot : analyse, évaluation par générateurs et retour arrière, sérialisation ; nombres en double précision, littéraux conservés en 1.7 tant qu'on ne calcule pas ; tout le document en mémoire, profondeur limitée à 256 en 1.7.1.

Pour aller plus loin

  • Le manuel de jq 1.7, à lire en entier une fois : il est court, et chaque fonction y a des exemples exécutables. Pour la version amont, le journal des modifications liste précisément les changements de comportement de la série 1.8.
  • La page jq Language Description du wiki de jq, qui explique les générateurs, le retour arrière et les chemins mieux que le manuel.
  • La RFC 8259, courte et lisible, en particulier ses sections sur les objets (noms en double), les nombres (interopérabilité) et l'encodage ; la RFC 6901 (JSON Pointer), qui désigne un endroit d'un document par une chaîne comme /resultats/0/id, utile dans les messages d'erreur et dans les API.
  • Les spécifications JSON Lines et NDJSON, et la RFC 7464 pour la variante à séparateur RS.
  • La leçon 7 du cours Bash, qui montrait où s'arrêtent les tableaux de Bash, à relire maintenant que jq prend le relais.
  • La leçon suivante, jq : transformer, produire du JSON et choisir son outil, construit des objets, agrège avec group_by et reduce, traite des flux sans -s, produit du JSON sûr pour un webhook, et assemble rapport-hebdo.
+20 XP Carte du ciel →Mon cosmonaute →

Sources