Migrer vers JSON v2 en Go sans changer d'un octet ce que voient tes clients
Ou : Comment changer le moteur en roulant, sans que les passagers remarquent qu’on a changé de voiture
Le 19 août 2026, Go 1.27 a fait sortir encoding/json/v2 du statut expérimental. La phrase la plus importante des notes de version n’est pourtant pas celle-là. C’est celle qui dit que le vieux encoding/json est désormais « backed by the v2 implementation »1. Autrement dit, si tu as compilé un service avec Go 1.27, ton code JSON tourne déjà sur le nouveau moteur, sans que tu aies changé une ligne. Le comportement est censé être identique. Il l’est presque, et ce « presque » mérite qu’on l’examine avant de faire le second pas : passer à l’API v2 elle-même, dont les comportements par défaut sont volontairement différents.
L’article sur les méthodes génériques de Go 1.27 annonçait celui-ci. Voici donc, tests à l’appui, ce qui change pour une API qui parle JSON à des clients que tu ne contrôles pas.
encoding/json/v2 en Go 1.27 : ce qui change entre v1 et v2, et comment migrer
Tu as déjà migré, presque sans risque
Les notes de version promettent que les comportements de sérialisation et de désérialisation sont préservés, « but the exact text of error messages may differ ». Une porte de sortie existe, GOEXPERIMENT=nojsonv2 à la compilation, qui restaure l’ancienne implémentation et dont la disparition est annoncée pour une version future1.
J’ai voulu savoir ce que « may differ » voulait dire en pratique. Même programme, mêmes neuf entrées invalides, compilé deux fois avec Go 1.27.1. Sept messages sur neuf sont identiques. Deux changent :
| |
Les nouveaux messages sont meilleurs : l’index de l’élément fautif apparaît, et le chemin part enfin du bon type. Mais un test qui compare err.Error() à une chaîne casse, et une API qui renvoie ce texte à ses clients vient de modifier son contrat. Le champ Field de *json.UnmarshalTypeError bouge aussi, de tags à tags.1. Seuls Value, Type et Offset sont restés stables dans mes essais. Si tes tests vérifient une erreur, qu’ils le fassent avec errors.As sur ces champs-là, pas sur le texte.
Les différences qui crient, et celles qui se taisent
La documentation de encoding/json dresse la liste complète des différences entre v1 et v2, seize au total, chacune avec l’option qui rétablit l’ancien comportement2. J’ai écrit un test pour chacune de celles qui touchent une API, toujours sous Go 1.27.1.
| Cas | v1 | v2 par défaut | Option pour retrouver v1 |
|---|---|---|---|
{"NAME":"Alice"} vers un champ json:"name" | "Alice" | "", sans erreur | MatchCaseInsensitiveNames(true) |
Nom en double, "name" deux fois | la dernière valeur gagne | erreur duplicate object member name "name" | jsontext.AllowDuplicateNames(true) |
| UTF-8 invalide dans une chaîne | remplacé par U+FFFD | erreur invalid UTF-8 | jsontext.AllowInvalidUTF8(true) |
Slice et map à nil | null | [] et {} | FormatNilSliceAsNull, FormatNilMapAsNull |
omitempty sur un entier à 0 | champ omis | "quantity":0 | OmitEmptyWithLegacySemantics(true) |
time.Duration | nombre de nanosecondes | erreur, pas de représentation par défaut | FormatDurationAsNano(true) |
<b>Tom & Jerry</b> | \u003cb\u003e... | <b>Tom & Jerry</b> | jsontext.EscapeForHTML(true) |
| Ordre des clés d’une map | une seule sortie sur 100 appels | 10 sorties différentes sur 100 | Deterministic(true) |
Les trois lignes qui produisent une erreur ne m’inquiètent pas : tu les verras au premier test, et l’équipe de Go les justifie par la sécurité. Un nom en double donne un objet « without a universally agreed upon meaning », exploitable quand deux analyseurs ne choisissent pas la même valeur3.
Les cinq autres se taisent, et ce sont elles qui cassent une API en production. Un client mobile qui envoie "Email" au lieu de "email" voyait son champ accepté ; avec la v2, il disparaît sans un mot. Une application qui teste items === null reçoit soudain []. Un champ à zéro, omis depuis toujours, apparaît. Une empreinte calculée sur le JSON d’une map, pour un cache ou un ETag, change d’un appel à l’autre. Aucun journal ne t’en préviendra : ce sont des données valides, simplement différentes.
La durée qui n’a plus de format
time.Duration mérite une ligne de plus. La v1 la sérialisait en nanosecondes, 1500000000 pour une seconde et demie, ce que personne ne lit correctement sans calculatrice. La v2 refuse de choisir à ta place : json: cannot marshal from Go time.Duration within "/timeout": no default representation. Pendant la phase expérimentale, une option de tag format permettait de choisir. Elle a été retirée avant Go 1.27, avec l’option unknown, et l’option inline est devenue embed1. Les tutoriels écrits pendant l’expérience sont donc à relire avec méfiance.
La solution sans tag passe par une fonction de sérialisation déclarée pour le type :
| |
Migrer par étapes, une option à la fois
La documentation propose une méthode que je trouve exemplaire : appeler la v2 avec l’ensemble des options de la v1, vérifier que rien ne change, puis retirer les options une par une2. JoinOptions les combine, et la dernière valeur d’une propriété l’emporte.
| |
Avec ces options, la sortie de mes cas de test est identique octet pour octet à celle de la v1, la casse est toujours ignorée à la lecture, et les noms en double sont refusés. C’est le premier palier que je recommande pour une API publique : durcir l’entrée, ne rien changer à la sortie. Le test qui le garantit compare les deux versions sur une liste de valeurs, en table, comme tout test de non-régression, et il échouera le jour où une option retirée change un octet de ce que voient tes clients.
Le palier suivant touche la sortie, et il se négocie avec les clients plutôt qu’avec le compilateur. FormatNilSliceAsNull et OmitEmptyWithLegacySemantics peuvent sauter avec une nouvelle version de l’API, pendant que l’ancienne garde les options de la v1 jusqu’à sa fin de vie. EscapeForHTML demande un examen à part. La documentation de la v1 expliquait l’échappement de <, > et & par une seule raison : que le JSON reste « safe to embed inside HTML <script> tags »4. Si une de tes pages insère ce JSON tel quel dans une balise <script>, c’était sa seule protection, et la v2 la retire sans prévenir. Garde l’option, ou corrige la page avant.
DevOps Dave : J’ai remplacé encoding/json par encoding/json/v2 dans tout le dépôt. Tout compile, les tests passent.
Security Sarah : Tes tests envoient des noms de champs en majuscules ?
DevOps Dave : Pourquoi ils feraient ça ?
Security Sarah : Parce que l’application iOS le fait depuis 2023, et que la v1 te le pardonnait.
Les tests passaient parce qu’ils ont été écrits avec le même JSON impeccable que le code. Les clients, eux, n’ont jamais lu ta documentation.
Tout le code de cet article a été testé sous Go 1.27.1 : huit différences vérifiées, la composition d’options, et la comparaison des messages d’erreur avec et sans GOEXPERIMENT=nojsonv2, go vet et le détecteur de courses compris. Go a changé le moteur de encoding/json sous ton capot, et il a tenu sa promesse à deux messages d’erreur près. Le second changement, celui des valeurs par défaut, c’est toi qui le conduis. Autant savoir où sont les virages.
Les notes de version de Go 1.27 : « The encoding/json package is now backed by the v2 implementation. Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ. » Elles précisent que l’option
GOEXPERIMENT=nojsonv2« is expected to be removed in a future release », et listent les retraits survenus pendant l’expérience : l’option de tagformat, l’option de tagunknown, l’optionDiscardUnknownMembers, ainsi que le renommage deinlineenembed. Le billet de sortie date la version du 19 août 2026. ↩︎ ↩︎ ↩︎La documentation de
encoding/json, section « Migrating to v2 », énumère les différences et leurs options, puis décrit la migration progressive :jsonv2.Marshal(v, jsonv1.DefaultOptionsV1())est « semantically equivalent to jsonv1.Marshal ». Elle conclut que toute nouvelle utilisation devrait passer par la v2, « but the v1 package will forever remain supported ». ↩︎ ↩︎Le billet « A new experimental Go API for JSON » du 9 septembre 2025, signé par Joe Tsai, Daniel Martí et quatre autres auteurs, justifie chaque changement par défaut. Sur les doublons : « This could be exploited by attackers in security applications. » Sur la casse, il qualifie l’ancien comportement de « surprising default, a potential security vulnerability, and a performance limitation ». L’attaque est décrite en détail par Jake Miller dans une étude de Bishop Fox sur l’interopérabilité de JSON (février 2021) : deux services qui ne retiennent pas la même valeur en double peuvent valider une donnée et en traiter une autre. ↩︎
La documentation de
json.Marshalle dit en toutes lettres : « So that the JSON will be safe to embed inside HTML<script>tags, the string is encoded using HTMLEscape ». Côté v2, l’optionjsontext.EscapeForHTMLrétablit cet échappement « so that the output is safe to embed within HTML ». ↩︎