# Migrer vers JSON v2 en Go sans changer d'un octet ce que voient tes clients

> Depuis Go 1.27, le vieux encoding/json tourne sur le moteur de la v2 : tu as déjà migré, en partie. Passer à l'API v2 change en revanche des comportements par défaut, et les plus dangereux ne lèvent aucune erreur.


*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](/blog/go-1-27-methodes-generiques/) 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
{.subtitle}

### 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 future[^1].

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 :

```text
# GOEXPERIMENT=nojsonv2 (ancienne implémentation)
json: cannot unmarshal string into Go struct field Account.tags of type int
json: cannot unmarshal string into Go struct field Address.address.zip of type int

# Go 1.27.1 par défaut (v1 adossé à v2)
json: cannot unmarshal string into Account.tags.1 of type int
json: cannot unmarshal string into Go struct field Account.address.zip of type int
```

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 comportement[^2]. 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 valeur[^3].

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 `embed`[^1]. 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 :

```go
asString := jsonv2.MarshalFunc(func(d time.Duration) ([]byte, error) {
	return jsonv2.Marshal(d.String())
})
out, err := jsonv2.Marshal(job, jsonv2.WithMarshalers(asString))
// {"timeout":"1.5s"}
```

### 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 une[^2]. `JoinOptions` les combine, et la dernière valeur d'une propriété l'emporte.

```go
// apiOptions keeps the v1 wire format, but turns on two strict v2 checks.
var apiOptions = jsonv2.JoinOptions(
	jsonv1.DefaultOptionsV1(),
	jsontext.AllowDuplicateNames(false),
	jsontext.AllowInvalidUTF8(false),
)
```

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](/blog/table-driven-tests-go/), 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.

---

[^1]: Les [notes de version de Go 1.27](https://go.dev/doc/go1.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 tag `format`, l'option de tag `unknown`, l'option `DiscardUnknownMembers`, ainsi que le renommage de `inline` en `embed`. Le [billet de sortie](https://go.dev/blog/go1.27) date la version du 19 août 2026.

[^2]: La [documentation de `encoding/json`, section « Migrating to v2 »](https://pkg.go.dev/encoding/json#hdr-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 ».

[^3]: Le billet [« A new experimental Go API for JSON »](https://go.dev/blog/jsonv2-exp) 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](https://bishopfox.com/blog/json-interoperability-vulnerabilities) (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.

[^4]: La [documentation de `json.Marshal`](https://pkg.go.dev/encoding/json#Marshal) le 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'option [`jsontext.EscapeForHTML`](https://pkg.go.dev/encoding/json/jsontext#EscapeForHTML) rétablit cet échappement « so that the output is safe to embed within HTML ».



