YAML
Syntaxe, ancres, pièges classiques.
Vérifié en septembre 2026 · outils aux versions citées dans le cours · environ 18 min
YAML est le format dans lequel s'écrivent les pipelines GitHub Actions, Azure Pipelines et GitLab CI, les fichiers Compose et les manifestes Kubernetes. Il décrit un arbre de données, comme JSON, mais sans guillemets ni accolades obligatoires : l'indentation porte la structure, et le parseur devine seul le type de chaque valeur. Cette économie de signes est ce qui le rend lisible, et aussi ce qui le rend traître : un espace de trop change l'arbre, et une valeur écrite comme du texte peut arriver sous la forme d'un booléen ou d'un nombre. Ce que le parseur devine dépend de la version de YAML qu'il applique, et chaque outil a la sienne. Les résultats donnés dans ce cours ont été obtenus avec YamlDotNet 18.1, sous .NET 10 ; quand un résultat dépend de la version de YAML, il est donné d'après la spécification, ou d'après la documentation du parseur nommé.
Trois structures et une indentation
Un document YAML ne contient que trois sortes de nœuds. Une correspondance (mapping) associe des clés à des valeurs, comme un objet JSON ou un Dictionary. Une séquence est une liste ordonnée. Un scalaire est une valeur simple : chaîne, nombre, booléen ou null. Tout fichier de configuration est un emboîtement de ces trois-là, et ce qui les distingue à la lecture tient en deux signes : : (deux-points suivi d'un espace) sépare une clé de sa valeur, - ouvre un élément de séquence.
# Une correspondance a la racine : des paires cle: valeur, une par ligne
nom: boutique-api
version: 1.4.0 # un numero a trois composantes : une chaine, pas un nombre
publie: true
reessais: 3
delai: 2.5
proprietaire: ~ # null, comme une valeur laissee vide
# Une sequence : un tiret suivi d'un espace par element
environnements:
- recette
- production
# L'indentation seule dit qui contient quoi
build:
configuration: Release
cibles:
- linux-x64
- win-x64Lu selon le schéma core de YAML 1.2, le document donne exactement ce que l'on aurait écrit en JSON :
{
"nom": "boutique-api",
"version": "1.4.0",
"publie": true,
"reessais": 3,
"delai": 2.5,
"proprietaire": null,
"environnements": ["recette", "production"],
"build": {
"configuration": "Release",
"cibles": ["linux-x64", "win-x64"]
}
}L'indentation n'a pas de largeur imposée : deux espaces, quatre, peu importe, pourvu que les frères soient alignés sur la même colonne. Un tiret peut même s'aligner sur la clé qui le contient, comme le font les exemples de la documentation d'Azure Pipelines. Ce qui compte, c'est ce que chaque tiret ouvre : un nouvel élément. D'où l'erreur la plus fréquente dans une liste d'étapes, qui produit un fichier valide et une structure fausse.
# Un tiret par ligne : quatre elements, dont aucun n'a a la fois un nom et une commande
steps:
- name: Restaurer
- run: dotnet restore
- name: Tester
- run: dotnet test --no-restore
# steps = [{name: Restaurer}, {run: dotnet restore},
# {name: Tester}, {run: dotnet test --no-restore}]Chaque étape est une correspondance ; ses clés vont ensemble, sous un seul tiret, alignées sur la première.
# Un tiret par element ; les cles suivantes s'alignent sur la premiere
steps:
- name: Restaurer
run: dotnet restore
- name: Tester
run: dotnet test --no-restore
# steps = [{name: Restaurer, run: dotnet restore},
# {name: Tester, run: dotnet test --no-restore}]Bloc et flux
YAML offre deux écritures pour les mêmes structures. Le style bloc, celui du reste de ce cours, se lit par l'indentation. Le style flux reprend les crochets et les accolades de JSON et tient souvent sur une ligne ; l'indentation n'y compte plus. Depuis la version 1.2, la spécification fait de YAML un sur-ensemble strict de JSON : un fichier JSON est un document YAML valide, et les deux styles se mélangent librement.
# Style bloc : la structure tient a l'indentation
bloc:
os:
- ubuntu-latest
- windows-latest
dotnet:
version: "10.0"
configuration: Release
# Style flux : crochets pour une sequence, accolades pour une correspondance
flux: {os: [ubuntu-latest, windows-latest], dotnet: {version: "10.0", configuration: Release}}
# Du JSON est du YAML 1.2 valide
json: {"os": ["ubuntu-latest", "windows-latest"], "dotnet": {"version": "10.0", "configuration": "Release"}}
# bloc, flux et json portent exactement la meme valeur.
# Dans un flux, la virgule separe les elements
sans_guillemets: [v1.0, api,front] # trois elements : v1.0, api, front
avec_guillemets: [v1.0, "api,front"] # deux elements : v1.0, api,frontLe flux convient aux listes courtes — les systèmes d'une matrice, les branches d'un déclencheur — et le bloc à tout le reste, parce qu'une ligne par valeur donne des différences lisibles en revue de code. À l'intérieur d'un flux, la virgule, les crochets et les accolades deviennent des séparateurs : une valeur qui en contient doit être mise entre guillemets.
Les chaînes et leurs guillemets
Une chaîne s'écrit de trois façons. Nue, elle ne connaît aucun échappement et s'arrête au premier signe qui a un sens pour YAML. Entre apostrophes, elle ne connaît toujours aucun échappement, sauf '' pour écrire une apostrophe. Entre guillemets doubles, l'antislash introduit des séquences comme en C# : \n, \t, \u00e9. C'est cette dernière forme qui piège les chemins Windows : un \t ou un \n y devient, sans la moindre erreur, une tabulation ou un saut de ligne, et C:\build cache un retour arrière. La lecture n'échoue que sur un antislash suivi d'un caractère qui n'ouvre aucun échappement, comme \o, ou sur un \U, un \u ou un \x privé de ses chiffres hexadécimaux, comme dans C:\Users.
# Entre guillemets doubles, l'antislash ouvre une sequence d'echappement :
# \t est une tabulation, \n un saut de ligne.
sortie: "C:\temp\nouveau"
# sortie = "C:<tabulation>emp<saut de ligne>ouveau", sans la moindre erreur.
# "C:\Users\moi", lui, est refuse : \U attend huit chiffres hexadecimaux.Pour un chemin, une expression régulière ou tout texte chargé d'antislashs, les apostrophes sont la forme sûre : ce qui est écrit est ce qui est lu.
nu: C:\temp\nouveau # scalaire nu : aucun echappement
simples: 'C:\temp\nouveau' # apostrophes : aucun echappement non plus
apostrophe: 'l''artefact' # la seule regle des apostrophes : '' pour une apostrophe
doubles: "C:\\temp\\nouveau" # guillemets doubles : l'antislash se double
unicode: "\u00e9t\u00e9" # "ete" accentue ; \n, \t et \" existent aussi
# nu, simples et doubles valent tous trois C:\temp\nouveauChaînes multilignes
Un script de plusieurs lignes s'écrit en bloc, introduit par un indicateur après la clé. | (littéral) garde les sauts de ligne tels quels. > (replié) remplace chaque saut de ligne par un espace, sauf là où une ligne vide sépare deux paragraphes. Le contenu commence à la ligne suivante et s'arrête à la première ligne moins indentée.
Reste le sort des sauts de ligne finaux, que règle un second indicateur, le chomping. Sans indicateur, le bloc garde un seul saut de ligne final et jette les lignes vides qui suivent. - les retire tous, + les garde tous. La différence compte dès qu'une valeur est comparée ou concaténée : un jeton lu avec | se termine par un \n qui ne fait pas partie du jeton.
litteral: |
dotnet restore
dotnet test --no-restore
replie: >
Une phrase coupee
sur deux lignes.
Un paragraphe, apres une ligne vide.
sans_fin: |-
aucun saut de ligne final
tout_garder: |+
tous les sauts de ligne finaux
suivante: okYamlDotNet lit ces cinq clés ainsi :
{
"litteral": "dotnet restore\ndotnet test --no-restore\n",
"replie": "Une phrase coupee sur deux lignes.\nUn paragraphe, apres une ligne vide.\n",
"sans_fin": "aucun saut de ligne final",
"tout_garder": "tous les sauts de ligne finaux\n\n",
"suivante": "ok"
} L'erreur classique confond les deux indicateurs dans un pipeline. Avec >, deux commandes écrites l'une sous l'autre deviennent une seule ligne : le shell lance dotnet build en lui passant la seconde commande comme arguments.
# GitHub Actions : > replie les deux lignes en une seule commande
steps:
- name: Construire et tester
run: >
dotnet build -c Release
dotnet test -c Release --no-build
# run = "dotnet build -c Release dotnet test -c Release --no-build\n"| pour un script, >- pour une commande unique qu'on veut couper sur plusieurs lignes sans l'antislash de continuation du shell.
steps:
# | garde chaque ligne : deux commandes
- name: Construire et tester
run: |
dotnet build -c Release
dotnet test -c Release --no-build
# > convient a une seule commande trop longue pour une ligne
- name: Publier
run: >-
dotnet publish src/Boutique.Api/Boutique.Api.csproj
-c Release -o publish --no-build
# run (1) = "dotnet build -c Release\ndotnet test -c Release --no-build\n"
# run (2) = "dotnet publish src/Boutique.Api/Boutique.Api.csproj -c Release -o publish --no-build"Ancres, alias et clé de fusion
Une ancre, &nom, étiquette un nœud ; un alias, *nom, le répète ailleurs dans le même document. L'ancre doit précéder l'alias, et elle ne traverse pas les fichiers : GitLab précise qu'une ancre n'est pas utilisable d'un fichier inclus par include à un autre. La clé de fusion << va plus loin : elle recopie les paires d'une correspondance dans celle où elle apparaît, ce qui permet d'écrire un job à partir d'un modèle.
# .gitlab-ci.yml : une cle qui commence par un point ne cree pas de job
.dotnet: &dotnet
image: mcr.microsoft.com/dotnet/sdk:10.0
tags: &runners [docker, linux]
variables:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
CONFIGURATION: Release
tester:
<<: *dotnet # recopie les cles de .dotnet dans ce job
script:
- dotnet test -c $CONFIGURATION
nettoyer:
image: alpine:3.24
tags: *runners # un alias seul vaut le noeud entier, ici la sequence
script:
- rm -rf publish Les deux jobs obtenus, tels que les rend YamlDotNet quand son parseur est enveloppé dans un MergingParser :
{
"tester": {
"image": "mcr.microsoft.com/dotnet/sdk:10.0",
"tags": ["docker", "linux"],
"variables": { "DOTNET_CLI_TELEMETRY_OPTOUT": "1", "CONFIGURATION": "Release" },
"script": ["dotnet test -c $CONFIGURATION"]
},
"nettoyer": {
"image": "alpine:3.24",
"tags": ["docker", "linux"],
"script": ["rm -rf publish"]
}
}<< ne fait pas partie de YAML 1.2. C'est un type proposé pour YAML 1.1, resté à l'état de brouillon de 2005, et la liste des changements de YAML 1.2 le dit retiré ; chaque outil décide donc de le prendre ou non. GitLab CI le documente. YamlDotNet ne l'applique qu'à travers un MergingParser et lit sinon une clé ordinaire nommée << ; la bibliothèque npm yaml, d'après sa documentation, ne l'active par défaut qu'en mode 1.1. GitHub Actions accepte les ancres et les alias depuis septembre 2025, mais pas la clé de fusion : un mainteneur du runner l'a écartée parce qu'elle fait partie de YAML 1.1 et non de YAML 1.2. Azure Pipelines n'accepte pas même les ancres : sa référence du schéma YAML les range, avec les clés complexes et les ensembles, parmi les fonctions non prises en charge ; la réutilisation y passe par les templates.
La règle de fusion est simple et elle surprend : une clé déjà présente dans la correspondance l'emporte sur la clé fusionnée, et l'emporte tout entière. La fusion ne descend pas dans les valeurs. Redéfinir variables pour changer une seule variable efface donc toutes les autres. Les résultats en commentaire, dans ce contre-exemple et dans sa correction, sont ceux de YamlDotNet avec MergingParser.
.dotnet: &dotnet
image: mcr.microsoft.com/dotnet/sdk:10.0
variables:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
CONFIGURATION: Release
publier-debug:
<<: *dotnet
variables: # une cle deja presente gagne : elle remplace le bloc entier
CONFIGURATION: Debug
# publier-debug.variables = {CONFIGURATION: Debug}
# DOTNET_CLI_TELEMETRY_OPTOUT a disparu : la fusion ne descend pas dans les valeurs Il faut fusionner à chaque niveau où l'on veut garder quelque chose, avec une ancre sur le bloc imbriqué. Dans GitLab, extends évite cet échafaudage : sa documentation le décrit comme une fusion profonde des correspondances, qui remplace en revanche les tableaux sans les concaténer ; le cours !reference compose un tableau.
.dotnet: &dotnet
image: mcr.microsoft.com/dotnet/sdk:10.0
variables: &variables-dotnet
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
CONFIGURATION: Release
publier-debug:
<<: *dotnet
variables:
<<: *variables-dotnet # on fusionne aussi au niveau des variables
CONFIGURATION: Debug
# publier-debug.variables = {DOTNET_CLI_TELEMETRY_OPTOUT: "1", CONFIGURATION: Debug}Le type d'une valeur dépend du parseur
Un scalaire nu n'a pas de type écrit : le parseur le déduit en confrontant le texte à une liste de formes, le schéma. YAML 1.1 en avait une très généreuse. YAML 1.2, publié en 2009, a surtout visé la compatibilité avec JSON, et la liste de ses changements est explicite : seuls true et false (avec True et TRUE) restent des booléens, yes, on et leurs contraires deviennent des chaînes, l'octal exige le préfixe 0o, et la base 60 disparaît, comme le type date. Les deux premières colonnes du tableau suivent la spécification ; la dernière est ce que YamlDotNet produit vers une propriété typée.
| Écrit | YAML 1.1 | YAML 1.2 (core) | YamlDotNet, propriété typée |
|---|---|---|---|
yes, on, y | true | chaîne | bool : true |
NO, off, n | false | chaîne | bool : false |
0755 | 493 (octal) | 755 | int : 493 |
0o17 | chaîne | 15 | int : exception |
1:30 | 90 (base 60) | chaîne | int : 90 |
3.10 | 3.1 | 3.1 | double : 3.1 ; string : "3.10" |
2026-09-25 | date | chaîne | DateTime : le 25/09/2026 ; string : "2026-09-25" |
~, null, rien | null | null | string : null |
y et n figurent dans le type booléen de YAML 1.1, mais PyYAML, qui applique pourtant cette version, ne les reconnaît pas : son expression régulière s'en tient à yes, no, true, false, on, off et leurs casses.
De là vient le problème norvégien : une liste de codes pays lue en YAML 1.1 voit la Norvège, NO, devenir false. Les autres pièges de la même famille touchent les numéros de version (22.10 est lu comme le nombre 22.1, en YAML 1.1 comme en 1.2), les droits de fichiers et les ports. La documentation de Compose demande pour cette raison d'écrire tout HOST:CONTAINER entre guillemets, comme le fait le fichier Compose du cours Docker.
# Chaque valeur a ete ecrite comme une chaine. En commentaire, ce qu'en font
# les types de YAML 1.1 (ceux de PyYAML), puis le schema core de YAML 1.2.
pays: [FR, DE, NO, SE] # 1.1 : [FR, DE, false, SE] 1.2 : [FR, DE, NO, SE]
node: [20.10, 22.10] # 1.1 et 1.2 : [20.1, 22.1], deux nombres
droits: 0755 # 1.1 : 493 (octal) 1.2 : 755 (decimal)
ports:
- 22:22 # 1.1 : 1342 (base 60) 1.2 : "22:22"
- 8080:80 # 1.1 et 1.2 : "8080:80", car 80 n'est pas un chiffre de base 60 Là où le parseur déduit le type du texte, le remède est le même en 1.1 et en 1.2 : une valeur qui doit rester du texte s'écrit entre guillemets. yamllint aide à ne pas l'oublier. Sa règle truthy, active par défaut en avertissement, signale les yes, on, False et autres variantes de YAML 1.1 — mais ni y ni n, absents de sa liste. Sa règle octal-values, qui signale les nombres commençant par zéro, est désactivée par défaut : elle est à activer.
# Entre guillemets, un scalaire est une chaine pour tout parseur qui deduit
# le type du texte, en YAML 1.1 comme en 1.2
pays: [FR, DE, "NO", SE]
node: ["20.10", "22.10"]
droits: "0755"
ports:
- "22:22"
- "8080:80" La même règle touche les clés, ce qui produit le cas le plus célèbre : la clé on d'un workflow GitHub Actions. GitHub la lit bien comme un texte, mais PyYAML, qui se présente comme un parseur YAML 1.1 complet, la lit comme le booléen true, puisque son expression régulière des booléens contient on. Les scripts Python qui analysent des workflows tombent dans ce piège, et la règle truthy de yamllint, qui vérifie aussi les clés par défaut, signale ce on:.
# .github/workflows/ci.yml
name: ci
on: # YAML 1.1 (PyYAML) : cette cle est le booleen true
push:
branches: [main]
jobs:
tester:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: dotnet test
# GitHub lit bien la cle "on". Un outil YAML 1.1 (PyYAML) lit {true: {push: ...}},
# et un script qui cherche donc la cle "on" ne la trouve pas. Côté .NET, YamlDotNet donne une troisième réponse. Vers object, il ne résout aucun scalaire et rend des chaînes. Vers une classe, c'est le type de la propriété qui décide, selon les règles de YAML 1.1 : yes pour un bool, l'octal et la base 60 pour un int.
// Projet console .NET 10 avec le paquet YamlDotNet (18.1.0 ici)
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
const string yaml = """
publier: yes
droits: 0755
duree: 1:30
version: 3.10
code: "0755"
""";
var lecteur = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
// Vers object, aucun scalaire n'est interprete : chaque valeur arrive en string
var brut = lecteur.Deserialize<Dictionary<string, object>>(yaml);
foreach (var (cle, valeur) in brut)
Console.WriteLine($"{cle} = {valeur} ({valeur.GetType().Name})");
// publier = yes (String)
// droits = 0755 (String)
// duree = 1:30 (String)
// version = 3.10 (String)
// code = 0755 (String)
// Vers une classe, c'est le type de la propriete qui decide, selon les regles de YAML 1.1
var options = lecteur.Deserialize<Options>(yaml);
Console.WriteLine(options.Publier); // True
Console.WriteLine(options.Droits); // 493
Console.WriteLine(options.Duree); // 90
Console.WriteLine(options.Version); // 3.10 : une propriete string garde le texte
Console.WriteLine(options.Code); // 493 : les guillemets ne protegent pas un int
sealed class Options
{
public bool Publier { get; set; }
public int Droits { get; set; }
public int Duree { get; set; }
public string Version { get; set; } = "";
public int Code { get; set; }
} La dernière ligne renverse le conseil précédent : en désérialisation typée, les guillemets ne protègent rien, "0755" donne 493 dans un int et "yes" donne true dans un bool. Avec YamlDotNet et une classe cible, c'est le type de la propriété qui protège : une valeur qui doit rester du texte se lit dans une string. L'option WithAttemptingUnquotedStringTypeDeserialization() fait l'inverse : vers object, elle devine le type des seuls scalaires nus, à peu près à la manière de YAML 1.2 — 0755 devient 755 et NO reste une chaîne, mais 0o17 aussi, que YAML 1.2 lirait 15.
Ce que le parseur refuse, et ce qu'il laisse passer
Certaines fautes arrêtent la lecture, d'autres passent. La spécification interdit la tabulation dans l'indentation, parce que chaque système la traite différemment, et YamlDotNet la refuse. Elle exige aussi des clés uniques dans une correspondance, mais l'application varie. Le désérialiseur par défaut de YamlDotNet garde la dernière valeur sans rien dire, et PyYAML aussi, d'après un ticket ouvert depuis des années sur son dépôt ; la bibliothèque npm yaml, d'après sa documentation, vérifie l'unicité par défaut. Une clé dupliquée dans un long fichier, c'est une configuration dont la moitié est ignorée sans que personne le sache.
Dans YamlDotNet, seul le désérialiseur par défaut laisse passer le doublon : YamlStream.Load, qui charge le document en arbre de nœuds, lève Duplicate key image, et le désérialiseur retrouve la vérification avec WithDuplicateKeyChecking().
// Projet console .NET 10 avec le paquet YamlDotNet (18.1.0 ici)
using YamlDotNet.Core;
using YamlDotNet.Serialization;
const string yaml = """
image: node:22
image: node:24
""";
// Par defaut, la derniere valeur gagne, sans erreur ni avertissement
var permissif = new DeserializerBuilder().Build();
Console.WriteLine(permissif.Deserialize<Dictionary<string, string>>(yaml)["image"]); // node:24
// WithDuplicateKeyChecking fait du doublon une erreur
var strict = new DeserializerBuilder().WithDuplicateKeyChecking().Build();
try
{
strict.Deserialize<Dictionary<string, string>>(yaml);
}
catch (YamlException e)
{
Console.WriteLine(e.Message); // Encountered duplicate key image
} Deux signes, enfin, changent de sens au milieu d'un scalaire nu. : y ouvre une nouvelle paire, et # y ouvre un commentaire ; un deux-points collé à ce qui suit, comme dans une URL, et un dièse sans espace devant restent du texte.
# Quatre documents, separes par ---, et le verdict de chacun
---
# 1. Une tabulation avant "image" : interdite par la specification, refusee par YamlDotNet
build:
image: node:24
---
# 2. Cle dupliquee : interdite par la specification, mais le deserialiseur par
# defaut de YamlDotNet, comme PyYAML, garde la derniere valeur sans rien dire
image: node:22
image: node:24
---
# 3. ": " dans un scalaire nu ouvre une seconde paire sur la meme ligne :
# refuse (YamlDotNet : "found invalid mapping")
message: Erreur: disque plein
---
# 4. " #" ouvre un commentaire : couleur vaut null
couleur: #0078d4 Les guillemets règlent les deux derniers cas. La tabulation n'a pas besoin d'outil : le parseur la refuse, et un éditeur réglé pour insérer des espaces l'évite. Reste le doublon, le seul qui puisse passer sans bruit : c'est là qu'un passage de yamllint en intégration continue sert, car sa règle key-duplicates, active par défaut, l'attrape quel que soit le parseur de l'outil qui lira le fichier.
build:
image: node:24 # des espaces, jamais de tabulation
message: "Erreur: disque plein" # des guillemets des qu'un ": " apparait
couleur: "#0078d4" # ... ou un " #"
url: http://localhost:8080 # ":" suivi d'autre chose qu'un espace : sans risque