Ouvrez un fichier enregistré en UTF-8 avec un outil qui suppose un autre encodage, et café devient café. Les octets sur le disque n’ont pas changé. Seule la règle utilisée pour les retransformer en caractères a changé, et les deux règles ne sont pas d’accord sur ce que signifie l’octet 0xC3.
La plupart des bugs de “caractères bizarres” partagent la même origine : noms de fichiers cassés, formulaires illisibles, un emoji qui fait échouer un insert en base de données, deux chaînes identiques à l’œil mais différentes à la comparaison. Dans chaque cas, on confond deux choses distinctes : Unicode et UTF-8.
Unicode vs UTF-8 : les points de code ne sont pas des octets
Unicode est une table unique qui attribue à chaque caractère un numéro, appelé point de code, écrit U+ suivi d’un hexadécimal : A est U+0041, é est U+00E9, 😀 est U+1F600. La table va de U+0000 à U+10FFFF. Elle couvre tous les systèmes d’écriture en usage aujourd’hui ou utilisés historiquement, plus les symboles, les emojis et des plages réservées à un usage privé.
Unicode ne dit rien sur les octets. Il répond à une seule question : quel numéro a ce caractère ? Transformer ce numéro en octets stockables dans un fichier ou envoyables sur un socket est un travail à part, effectué par un encodage. UTF-8, UTF-16 et UTF-32 sont trois encodages d’une même table. Ils écrivent les mêmes points de code sous forme de séquences d’octets différentes.
C’est le même genre d’écart qui fait que 0.1 + 0.2 renvoie autre chose que 0.3 : la valeur abstraite et les bits en mémoire sont deux couches distinctes, et le bug apparaît dès qu’on les traite comme interchangeables.
Comment l’UTF-8 transforme un point de code en octets
L’UTF-8 est à largeur variable : chaque point de code prend entre 1 et 4 octets, jamais plus que nécessaire.
| Plage de points de code | Octets utilisés | Motif d’octets |
|---|---|---|
U+0000–U+007F | 1 | 0xxxxxxx |
U+0080–U+07FF | 2 | 110xxxxx 10xxxxxx |
U+0800–U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000–U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Tout octet qui commence par 10 est un octet de continuation : un fragment d’un caractère multi-octets, jamais un caractère à lui seul. Cela rend l’encodage auto-synchronisant. Entrez au milieu d’un flux UTF-8, remontez au-delà des octets de continuation, et vous tombez sur le début d’un caractère.
La première ligne explique pourquoi l’UTF-8 a gagné. Les points de code U+0000–U+007F sont exactement l’ASCII et s’écrivent sur ce même octet unique, donc tout fichier ASCII pur est déjà de l’UTF-8 valide, octet pour octet. Tout ce qui sort de cette plage coûte des octets supplémentaires. é est U+00E9, dans la deuxième ligne, donc il en prend deux :
| |
😀 est U+1F600, dans la quatrième ligne, donc il en prend quatre :
| |
Cette paire 0xC3 0xA9 est la coupable de l’exemple d’ouverture. Décodez-la en UTF-8 et vous obtenez é. Décodez-la en Latin-1, qui mappe chaque octet sur un caractère, et 0xC3 devient à tandis que 0xA9 devient ©. Rien dans un flux d’octets brut n’enregistre quel encodage l’a produit. Les octets ne sont pas faux. C’est l’hypothèse de celui qui les lit qui l’est.
Pourquoi .length ne compte pas les caractères
“Quelle est la longueur de cette chaîne” a trois réponses différentes, parce que “caractère” peut désigner trois unités différentes.
len() en Python compte les points de code :
| |
Un point de code sur le str, quatre octets sur le bytes encodé. Deux questions différentes, deux réponses différentes.
Les chaînes JavaScript sont de l’UTF-16 en interne, et .length compte des unités de code de 16 bits, pas des points de code. Les points de code au-dessus de U+FFFF, ce qui inclut tous les emojis, nécessitent deux unités UTF-16 formant ce qu’on appelle une paire de substitution :
| |
L’emoji de famille "👨👩👧👦" aggrave les choses. Ce sont 7 points de code : 4 emojis de personnes reliés par 3 zero-width joiners. Chacun des 4 emojis est une paire de substitution, donc .length renvoie 11. Ce qu’un lecteur voit comme un caractère est un grapheme cluster, et il peut s’étendre sur n’importe quel nombre de points de code. Découper ou inverser une chaîne par unité de code ou par point de code peut couper cet emoji en deux. Intl.Segmenter compte comme le ferait une personne :
| |
La largeur variable a aussi un coût. Dans un buffer UTF-8, trouver le 50e point de code signifie parcourir depuis le début en comptant, donc indexer par position de caractère est en O(n), pas la recherche en temps constant qu’offre un tableau à largeur fixe. Les langages gèrent cela différemment. Rust et Go stockent les chaînes en UTF-8 et les indexent par octet, donc on itère quand on veut des caractères. CPython stocke chaque str à une largeur fixe par chaîne (1, 2 ou 4 octets par point de code, selon le caractère le plus large qu’elle contient), ce qui garde s[i] en temps constant au prix d’une mémoire supplémentaire dès qu’un seul emoji apparaît.
Bugs d’encodage UTF-8 courants en production
Tronquer par nombre d’octets. Couper une chaîne UTF-8 à exactement 100 octets tombe parfois au milieu d’un caractère multi-octets, laissant une séquence partielle qui ne se décode pas. Tronquez par nombre de points de code, ou tronquez les octets et décodez avec errors="ignore", en acceptant que le dernier caractère partiel soit abandonné. Aucune des deux approches ne respecte les grapheme clusters : une coupe par point de code peut encore séparer un e de son accent combinant ou casser un emoji de famille. Pour du texte visible par l’utilisateur, coupez sur les limites des graphèmes.
Laisser l’encodage implicite dans les entrées-sorties de fichiers. open("data.txt") sans l’argument encoding= utilise l’encodage local du système d’exploitation. Sur Linux et macOS, c’est généralement l’UTF-8. Sur Windows, c’était historiquement une page de code comme cp1252. Lire un fichier UTF-8 avec le mauvais encodage par défaut produit une UnicodeDecodeError ou du mojibake silencieux, selon les octets. La PEP 686 fait de l’UTF-8 l’encodage par défaut partout à partir de Python 3.15, mais beaucoup de code tourne encore sur des versions antérieures ou définit PYTHONUTF8=0. Passez l’encodage explicitement et la question ne se pose plus :
| |
Une colonne MySQL qui n’est UTF-8 que de nom. Le charset utf8 de MySQL (aujourd’hui un alias de utf8mb3) stocke au maximum 3 octets par caractère. Il ne peut pas contenir de points de code au-dessus de U+FFFF, donc aucun emoji et rien hors du Basic Multilingual Plane. Insérez 😀 dans une colonne utf8 et vous obtenez Incorrect string value. La solution est utf8mb4, l’implémentation UTF-8 complète de MySQL et le charset par défaut depuis MySQL 8.0. Convertir une table se fait avec ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4, exécuté table par table, et le charset de la connexion doit aussi être utf8mb4, sinon le client abîme les emojis avant qu’ils n’atteignent la colonne. Sur les anciens formats de ligne InnoDB, attention à la limite de 767 octets du préfixe d’index : un VARCHAR(255) indexé rentre dans utf8mb3 (765 octets) mais pas dans utf8mb4 (1 020 octets).
Deux séquences d’octets pour le même caractère visible. é peut être le point de code précomposé unique U+00E9 (forme NFC), ou la lettre e (U+0065) suivie d’un accent aigu combinant U+0301 (forme NFD). Ils s’affichent de façon identique mais diffèrent octet par octet, donc deux chaînes café peuvent se révéler différentes à la comparaison, selon où chacune a été tapée ou collée. Les noms de fichiers macOS et le texte copié depuis certains PDF arrivent souvent en NFD. Un dictionnaire indexé avec une forme peut rater silencieusement une recherche avec l’autre. Normalisez avant de comparer ou de stocker :
| |
Un byte-order mark intrus. Un fichier UTF-8 peut commencer par les 3 octets EF BB BF, un BOM écrit par certains éditeurs (dont l’ancien Notepad de Windows) pour signaler l’encodage. L’UTF-8 n’a pas d’ordre d’octets à signaler, donc ce BOM ne porte aucune information. Il est invisible dans la plupart des éditeurs, mais le décoder avec utf-8 simple laisse un caractère au début de la chaîne, ce qui fait échouer les parseurs JSON stricts et empêche la reconnaissance d’un shebang #!. Décodez avec le codec utf-8-sig de Python quand vous en attendez un, et n’en écrivez pas vous-même.
Comment arrêter les bugs d’encodage à la frontière
Choisissez l’UTF-8 et déclarez-le explicitement partout où le texte entre ou sort de votre programme : lectures de fichiers, connexions à la base de données, corps des requêtes HTTP, pipes vers des sous-processus. Décodez en chaîne dès que les octets arrivent, travaillez en interne sur des chaînes décodées, et encodez de nouveau en octets seulement à la frontière suivante. Gardez errors="strict" (le réglage par défaut de Python) pour qu’une séquence d’octets invalide lève une exception plutôt que de devenir silencieusement U+FFFD ou de disparaître.
Puis testez avec des entrées qui ne sont pas ASCII. Des fixtures qui ne contiennent que U+0000–U+007F laissent passer les bugs d’encodage en production, parce que chaque chemin de code fonctionne par accident à un octet par caractère jusqu’à ce que le nom d’un vrai utilisateur casse cette hypothèse. Ajoutez deux cas à n’importe quel niveau de test que vous écrivez déjà pour chaque champ de texte, champ de recherche, gestionnaire de noms de fichiers ou export : une chaîne avec une lettre accentuée, écrite en NFC et en NFD, et une avec l’emoji de famille. Si elles survivent à un aller-retour dans votre stockage et reviennent identiques, les frontières font leur travail.