Skip to content
Retour au blog
Tutoriels

UTF-8 vs UTF-16 vs Unicode — Guide complet de l'encodage

UTF-8, UTF-16 et UTF-32 expliqués pour développeurs — codepoints, paires de substitution, BOM, pièges utf8mb4 MySQL. Découvrez comment choisir le bon encodage.

12 min de lecture

UTF-8 vs UTF-16 vs Unicode — Guide complet de l’encodage

Réponse courte pour les recherches sur utf-8 unicode encoding : Unicode et UTF-8 ne sont pas la même chose. Unicode est une table numérotée qui attribue un codepoint (un nombre comme U+1F600) à chaque caractère. UTF-8, UTF-16 et UTF-32 sont des encodages : trois manières de transformer ces codepoints en octets. UTF-8 est presque toujours le bon choix : octet pour octet identique à l’ASCII pour le texte anglais, jusqu’à quatre octets pour un emoji, imposé par JSON, HTML5 et la plupart des protocoles modernes.

Ce guide s’adresse au développeur qui s’est déjà fait piéger : l’erreur MySQL Incorrect string value sur un 😀, la surprise JavaScript de "😀".length === 2, le CSV qui s’ouvre dans cat mais ressort en charabia dans Excel. On part des codepoints, puis la mécanique des octets UTF-8, les paires de substitution, les BOM, le comportement par défaut de neuf langages, et huit pièges en production. Une matrice de décision et une FAQ closent l’article.

Pour vérifier une séquence d’octets en cours de lecture, collez n’importe quelle chaîne dans l’Encodeur et Décodeur Base64 : la charge utile décodée est exactement le flux d’octets UTF-8 dont il est question ici.

Pourquoi l’encodage vous mord encore en 2026

Trois scénarios tirés de vrais bug trackers des douze derniers mois :

  1. MySQL rejette un emoji. Un utilisateur soumet Hello 😀 et le serveur retourne Incorrect string value: '\xF0\x9F\x98\x80'. La table est en utf8, le développeur se dit « mais c’est de l’UTF-8, où est le problème ? ». La réponse est enfouie dans l’histoire de MySQL (section 7).
  2. Un compteur de caractères part en production cassé. Un validateur de tweet de 280 caractères utilise text.length, accepte un message rempli d’emojis, et l’API le rejette. L’inverse arrive aussi : un post valide est refusé par le front. Diagnostic en section 4.
  3. Un HTML local se transforme en « 中文 ». Un développeur enregistre un fichier en Windows-1252, l’ouvre dans un navigateur qui devine UTF-8, et voit du Mojibake. C’est l’histoire de la déclaration BOM / charset en section 5, avec des parallèles dans le guide d’encodage et décodage d’URL où le même décalage octets-vs-caractères saccage les chaînes de requête.

À la fin de l’article, vous saurez (a) distinguer Unicode d’UTF-8 en une phrase, (b) choisir entre UTF-8, UTF-16 et UTF-32 pour un nouveau projet, (c) écrire du code qui compte correctement les emojis dans les principaux langages, et (d) déboguer un bug de charset à partir du seul flux d’octets.

Qu’est-ce qu’Unicode ? Codepoints vs caractères vs glyphes

Unicode est une table de caractères qui attribue un nombre unique, un codepoint comme U+1F600, à chaque caractère. UTF-8, UTF-16 et UTF-32 sont des encodages qui traduisent ces codepoints en octets. Unicode lui-même ne stocke aucun octet ; il définit uniquement la correspondance entre caractère abstrait et entier.

Plusieurs termes voisins brouillent la discussion parce qu’ils désignent souvent la même marque visible :

Les couches à distinguer

  • Codepoint (U+0041, U+1F600) : l’entier qu’Unicode attribue. L’espace s’étend de U+0000 à U+10FFFF, soit environ 1,1 million d’emplacements, dont quelque 150 000 sont actuellement attribués.
  • Caractère (ou caractère abstrait) : l’identité sémantique, par exemple lettre majuscule latine A ou emoji visage souriant.
  • Glyphe : la forme visuelle qu’une police rend à l’écran. Un même caractère possède de nombreux glyphes : un A avec empattement, un A italique, un A dessiné à la main. Unicode ne se soucie pas des glyphes.
  • Grappe de graphèmes : ce que l’utilisateur perçoit comme un seul « caractère ». Souvent un codepoint, parfois plusieurs. La lettre á peut être un codepoint U+00E1 ou deux codepoints a + U+0301 (accent aigu combinatoire). Le guide des limites de caractères par plateforme explore comment Twitter, SMS et SEO tracent chacun cette frontière différemment.

L’enchaînement utile à mémoriser : codepoint → encodage → octets → rendu. Chaque flèche peut se rompre indépendamment.

Notation des codepoints : U+XXXX et \uXXXX

Vous verrez les codepoints écrits sous plusieurs formes. U+0041 est la notation Unicode canonique : quatre à six chiffres hexadécimaux, préfixés par U+. Dans le code source :

  • JavaScript / JSON : "A" (quatre chiffres hexa, BMP uniquement) et "\u{1F600}" (accolades ES6, n’importe quel codepoint).
  • Python : "A" (4 chiffres), "\U00000041" (8 chiffres, U majuscule), "\N{LATIN CAPITAL LETTER A}" (par nom).
  • Shell / git log / sortie sed : on voit souvent des octets UTF-8 bruts comme \xc3\xa9 pour é. Ce n’est pas un codepoint mais la forme encodée, abordée en section 3.

Les 17 plans : BMP et au-delà

Unicode partitionne son espace de codepoints en 17 plans de 65 536 codepoints chacun (17 × 2^16 = 1 114 112).

  • Plan 0, le Basic Multilingual Plane (BMP) : U+0000 à U+FFFF. Latin, idéogrammes CJK, cyrillique, arabe, grec : presque toutes les écritures rencontrées dans du texte hérité vivent ici.
  • Plans 1-16, les plans supplémentaires : U+10000 à U+10FFFF. La plupart des emojis (U+1F600 et compagnie), caractères CJK rares, écritures historiques (hiéroglyphes égyptiens, cunéiforme), notation musicale.

La frontière BMP / supplémentaire à U+FFFF est la borne décisive de l’article. C’est là qu’UTF-16 cesse d’utiliser une unité de code par caractère, qu’UTF-8 passe de trois à quatre octets, et où la collation utf8 mal nommée de MySQL abandonne.

Contrôle de cohérence avec des emojis

"a"        → 1 codepoint  U+0061             → 1 grapheme
"é" (NFC)  → 1 codepoint  U+00E9             → 1 grapheme
"é" (NFD)  → 2 codepoints U+0065 U+0301      → 1 grapheme
"😀"        → 1 codepoint  U+1F600 (Plane 1)  → 1 grapheme
"👨‍👩‍👧"      → 5 codepoints (3 people + 2 ZWJ U+200D) → 1 grapheme

La dernière ligne illustre le problème. L’emoji famille est un caractère perçu comme unique par l’utilisateur, mais cinq codepoints reliés par des Zero-Width Joiners. Chaque couche de la pile peut le compter différemment, et le piège 6 de la section 7 est le bug report que ce désaccord produit.

Mécanique de l’encodage UTF-8 : comment fonctionnent les 1 à 4 octets

UTF-8 encode les codepoints Unicode sur 1 à 4 octets. L’ASCII (U+0000U+007F) utilise 1 octet et reste octet pour octet identique à l’ASCII. Les codepoints plus élevés utilisent des séquences multi-octets : le premier octet indique la longueur totale, chaque octet de continuation commence par le motif binaire 10xxxxxx. Cette structure auto-descriptive est la raison pour laquelle UTF-8 s’est imposé.

Le tableau des motifs d’octets : UTF-8 en un schéma

Plage de codepointsOctets UTF-8Motif binaire
U+0000U+007F1 octet0xxxxxxx
U+0080U+07FF2 octets110xxxxx 10xxxxxx
U+0800U+FFFF3 octets1110xxxx 10xxxxxx 10xxxxxx
U+10000U+10FFFF4 octets11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Chaque x est un bit de donnée tiré de la représentation binaire du codepoint. Le 0 / 110 / 1110 / 11110 de tête indique au décodeur combien d’octets composent le caractère ; le 10 de tête signale chaque octet de continuation. Cette redondance fait d’UTF-8 un encodage auto-synchronisant : si un octet est perdu, on peut reprendre au prochain octet de début au lieu de tout corrompre en aval.

Exemple détaillé : encodage de (U+4E2D)

Le codepoint 0x4E2D tombe dans U+0800U+FFFF, on utilise donc le gabarit 3 octets.

  1. Binaire : 0x4E2D = 0100 1110 0010 1101 (16 bits).
  2. Découpe 4-6-6 pour remplir les x : 0100 / 111000 / 101101.
  3. Substitution dans 1110xxxx 10xxxxxx 10xxxxxx : 11100100 10111000 10101101.
  4. Hexadécimal : 0xE4 0xB8 0xAD.

C’est pour cela que devient %E4%B8%AD après URL-encoding : le percent-encoding enveloppe chaque octet UTF-8 dans %XX, il n’encode pas directement le codepoint. Le piège 3 de la section 7 détaille la chaîne.

Exemple détaillé : encodage de 😀 (U+1F600)

Le codepoint 0x1F600 dépasse le BMP, on utilise donc le gabarit 4 octets.

  1. Binaire : 0x1F600 = 0 0001 1111 0110 0000 0000 (21 bits, complétés).
  2. Découpe 3-6-6-6 : 000 / 011111 / 011000 / 000000.
  3. Substitution dans 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx : 11110000 10011111 10011000 10000000.
  4. Hexadécimal : 0xF0 0x9F 0x98 0x80.

Ces quatre octets bloquent la collation utf8 de MySQL, plafonnée à trois octets par caractère. Le piège 1 de la section 7 contient le correctif.

Les atouts d’UTF-8

  1. Compatibilité ASCII. Un fichier de texte purement ASCII est, octet pour octet, identique à son encodage UTF-8. Des décennies d’outils antérieurs à Unicode (grep, awk, les pipes shell classiques) continuent de fonctionner pour ce sous-ensemble.
  2. Auto-synchronisation. Les octets de continuation commencent toujours par 10, ce qui n’entre jamais en collision avec un octet de début. Si un octet est perdu dans un transfert réseau, le décodage reprend à la prochaine frontière de caractère au lieu d’enchaîner des déchets en cascade.
  3. Pas d’ordre d’octets. UTF-8 est un flux d’octets, pas d’unités de 16 ou 32 bits, donc l’endianness est sans objet. UTF-16 et UTF-32 ont besoin d’un Byte Order Mark pour déclarer quel bout passe en premier ; UTF-8 non, et généralement ne devrait pas en avoir (voir section 5).

UTF-8 invalide : ce que la spec interdit

Un décodeur strict rejettera ces séquences d’octets :

  • Séquences de 5 ou 6 octets. Les premières RFC les autorisaient ; la RFC 3629 (2003) a plafonné UTF-8 à 4 octets pour correspondre à l’espace Unicode de 21 bits.
  • Encodages trop longs. Coder / en trois octets 0xE0 0x80 0xAF au lieu d’un seul 0x2F. Source autrefois prolifique d’exploits par traversée de répertoires dans les validateurs de chemin qui décodaient après assainissement.
  • Codepoints de substitution isolés (U+D800U+DFFF). Ils sont réservés à UTF-16 et ne devraient jamais apparaître en UTF-8.
  • Séquences tronquées. Un octet de début 3-octets suivi d’un seul octet de continuation : fréquent quand l’entrée utilisateur est coupée à une frontière d’octets au milieu d’un caractère multi-octets.

Pour observer ces motifs concrètement, déposez une chaîne dans l’Encodeur et Décodeur Base64, encodez-la, puis décodez-la en octets : le tableau d’octets entre encodeur et décodeur est le flux UTF-8 décrit dans cette section.

UTF-16 et paires de substitution : pourquoi length ment en JavaScript

La recherche la plus fréquente autour de utf-8 vs utf-16 est en réalité « pourquoi "😀".length vaut-il 2 dans mon code ? ». La réponse tient aux paires de substitution, une décision des années 1990 dont JavaScript, Java, C# et Windows ont tous hérité.

UTF-16 en un paragraphe

UTF-16 représente Unicode au moyen d’unités de code de 16 bits. Les caractères du BMP (U+0000U+FFFF) occupent exactement une unité de code. Les caractères des plans supplémentaires (U+10000U+10FFFF) occupent deux unités de code, appelées paire de substitution : un substitut haut dans U+D800U+DBFF suivi d’un substitut bas dans U+DC00U+DFFF. Ce bloc U+D800U+DFFF est réservé en permanence dans Unicode, aucun caractère réel n’y vit. UTF-16 est le format de chaîne interne de JavaScript, Java, C# (.NET), des API noyau Windows, de NSString d’Objective-C et de Qt, tous conçus à une époque où 65 536 caractères paraissaient amplement suffisants.

Le piège de String.length

"a".length          // 1   — BMP, single code unit
"é".length          // 1   — BMP (U+00E9), single code unit
"中".length         // 1   — BMP (U+4E2D), single code unit
"😀".length         // 2   — supplementary plane (U+1F600), surrogate pair!
"a😀".length        // 3   — one BMP + two surrogate units

String.prototype.length retourne le nombre d’unités de code UTF-16, pas le nombre de caractères. Tout caractère du plan supplémentaire compte pour 2. Le même piège existe avec String.length() en Java et string.Length en C#.

Compter correctement les codepoints en JS

[..."😀"].length              // 1 — spread iterator walks codepoints
Array.from("😀").length       // 1 — Array.from also walks codepoints
"😀".match(/./gu).length      // 1 — /u flag = unicode-aware regex

// "😀".charAt(0) returns the lone high surrogate (visually broken)
"😀".codePointAt(0)           // 128512 — the full codepoint U+1F600

L’opérateur de décomposition (spread) et Array.from utilisent le protocole d’itération, que la spec du langage définit comme parcourant des codepoints. L’accès par index simple (str[0], charAt) retourne toujours des unités de code et vous remettra la moitié d’une paire de substitution sur un emoji.

Python : len() fait déjà ce qu’il faut (presque)

len("😀")           # 1   — Python 3 strings are codepoint-indexed
len("👨‍👩‍👧")        # 5   — codepoints (3 humans + 2 ZWJ), not graphemes
# Python 2 was byte-indexed by default — len("😀") returned 4

Python 3 stocke les chaînes dans une représentation flexible 1, 2 ou 4 octets (PEP 393) et les indexe par codepoint. len("😀") vaut 1, mais ce n’est toujours pas le compte de graphèmes : l’emoji famille reste à 5. Pour compter les caractères perçus par l’utilisateur, il faut une bibliothèque de graphèmes : Intl.Segmenter en JavaScript (Node 22+, tous les navigateurs actuels), grapheme ou regex en Python, ou Swift, dont String.count est l’un des rares à compter les graphèmes par défaut.

UTF-16 vs UCS-2 : la migration silencieuse

Avant 1996, Unicode promettait de tenir dans 16 bits et l’encodage correspondant était UCS-2, un mapping fixe sur 2 octets. Unicode 2.0 a brisé cette promesse en ajoutant les plans supplémentaires. UTF-16 est la version patchée, qui s’appuie sur les paires de substitution. La spec JavaScript cite encore par endroits le vocabulaire UCS-2, raison pour laquelle le langage tolère les substituts isolés qui devraient être illégaux (les blagues sur « WTF-16 » sont bien réelles). Les API de la plateforme web (DOM, fetch, TextEncoder) rejettent les substituts isolés parce qu’ils ne peuvent pas être encodés en UTF-8 valide.

UTF-32, BOM et la question de l’ordre des octets

UTF-32 : simple et coûteux en espace

UTF-32 utilise 4 octets fixes par codepoint. U+0041 est stocké comme 0x00000041, U+1F600 comme 0x0001F600. L’avantage : un accès aléatoire en temps constant, le n-ième codepoint se trouve à l’offset 4n. L’inconvénient : la taille. Un texte purement ASCII gonfle à quatre fois son empreinte UTF-8, et même un texte CJK double. Quasiment aucun système ne stocke de l’UTF-32 sur disque. En interne, Python 3 choisit 1, 2 ou 4 octets par chaîne en fonction du plus haut codepoint ; la pile fontconfig de Linux utilise UTF-32 pour ses tables de glyphes en mémoire.

Ordre des octets : pourquoi l’endianness compte pour UTF-16 / UTF-32

UTF-8 est un flux d’octets isolés, donc l’endianness ne s’applique pas. UTF-16 et UTF-32 travaillent sur des unités multi-octets, et différents CPU ne s’accordent pas sur l’extrémité d’un nombre qui vient en premier.

U+0041 ('A') in UTF-16 BE → 00 41
U+0041 ('A') in UTF-16 LE → 41 00

Les CPU x86 et ARM sont little-endian ; les anciens PowerPC et le « network byte order » sont big-endian. Quand vous écrivez un fichier UTF-16, vous devez choisir un ordre et le déclarer au lecteur. C’est précisément le rôle du BOM.

Le BOM : définition et usage

Un Byte Order Mark, c’est U+FEFF placé au début d’un fichier. Une fois encodé, il annonce à la fois l’encodage et (pour UTF-16 / UTF-32) l’ordre des octets.

EncodageOctets BOM
UTF-8EF BB BF
UTF-16 BEFE FF
UTF-16 LEFF FE
UTF-32 BE00 00 FE FF
UTF-32 LEFF FE 00 00

Le BOM UTF-8 existe, mais il ne porte aucune information d’ordre d’octets puisque UTF-8 n’a pas d’ordre. Sa seule fonction est de déclarer « ce fichier est en UTF-8 » : utile pour les outils sans autre signal, nuisible pour ceux qui s’attendent à un nombre magique ou à une directive en début de fichier.

Matrice de décision BOM : faut-il l’ajouter ?

FormatBOM UTF-8BOM UTF-16BOM UTF-32
HTMLNon (casse la détection <!doctype> dans d’anciens parseurs)
JSONNon (RFC 8259 l’interdit)
Source JavaScript / CSSÀ éviter (les vieux Node et IE s’étouffent)
CSV ouvert dans ExcelOui (Excel lit l’UTF-8 sans BOM comme de l’ANSI et massacre le CJK)
XMLOptionnel (la déclaration XML précise déjà l’encodage)ObligatoireObligatoire
Texte brut .txtOptionnel (le Bloc-notes Windows en ajoute un par défaut)ObligatoireObligatoire

La règle courte : ôtez le BOM UTF-8 de tout ce que vous servez sur le web, ajoutez-le aux CSV destinés à Excel, laissez le lecteur décider pour le reste.

9 langages côte à côte : comportement d’encodage par défaut

Le travail multi-langage est l’endroit où cette connaissance se rentabilise. La même chaîne "a😀é" produit une longueur différente dans chaque runtime appelé depuis votre script Bash.

Le tableau comparatif inter-langages

LangageEncodage des fichiers sourceStockage des chaînesQue compte length / lenEncodage I/O par défautEmoji 4 octets OK ?
JavaScript (V8 / SpiderMonkey)UTF-8UTF-16unités de code UTF-16UTF-8 (Node, Web)Oui, mais .length === 2
Python 3UTF-8 (PEP 3120)dynamique 1 / 2 / 4 octets (PEP 393)codepointsUTF-8 (PEP 540 depuis 3.7)Oui, len === 1
JavaUTF-8 (par défaut de javac)UTF-16unités de code UTF-16charset plateforme → UTF-8 (JEP 400, JDK 18+)Oui, mais .length() === 2
GoUTF-8octets UTF-8octets (utf8.RuneCountInString pour les codepoints)UTF-8Oui, len(s) retourne des octets
RustUTF-8octets UTF-8 (invariant de String).len() en octets, .chars().count() en codepointsUTF-8Oui, explicite
C# (.NET)UTF-8 (par défaut depuis .NET Core 3.0)UTF-16unités de code UTF-16UTF-8 (Encoding.Default depuis .NET 5)Oui, mais .Length === 2
RubyUTF-8 (depuis 2.0)étiquette d’encodage par chaînecodepoints (.length)UTF-8Oui, length === 1
PHP(pas d’encodage source)chaîne d’octetsoctets (strlen) ; mb_strlen pour les codepointsdépend de default_charsetOui, avec la famille mb_*
MySQLcharset de colonneoctets (LENGTH), caractères (CHAR_LENGTH)variables système character_set_*Uniquement avec utf8mb4

Lecture du tableau

Les langages se rangent en trois familles :

  • UTF-8 en interne (Go, Rust, Ruby). La chaîne native est en octets ; length est bien défini mais compte ce qu’il compte. Ne convertissez vers les codepoints ou les graphèmes qu’aux frontières d’UI ou de validation.
  • UTF-16 en interne (JavaScript, Java, C#). Hérité des hypothèses des années 1990 ; length est en unités de code, une paire de substitution compte pour 2. Utilisez une itération consciente des codepoints pour tout comptage destiné à l’utilisateur.
  • Indexé par codepoint (Python 3). len donne des codepoints, ce qui semble juste jusqu’à rencontrer un emoji ZWJ : il faut alors encore une bibliothèque de graphèmes.

PHP est le cas particulier. Ses fonctions str* intégrées travaillent toutes sur des octets, traitant les séquences UTF-8 comme des blobs opaques. Tout projet non-ASCII doit utiliser la famille mb_* (multibyte), et les bug reports année après année montrent à quelle fréquence on l’oublie.

Le conseil pratique : conservez UTF-8 comme format de transport partout (fichiers, corps HTTP, colonnes de base de données) et convertissez vers le type chaîne natif de votre runtime à la frontière. C’est le « sandwich UTF-8 » repris en section 8.

8 pièges d’ingénierie dans la vraie vie

Les schémas ci-dessous reviennent dans chaque revue de code sur une base mondialisée.

Piège 1 : le utf8 de MySQL est un alias 3 octets, passez à utf8mb4

Symptôme. INSERT INTO users (bio) VALUES ('Hello 😀'); retourne Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio'.

Cause racine. Le utf8 historique de MySQL est un alias de utf8mb3 : une variante d’UTF-8 plafonnée à trois octets par caractère. Tout codepoint au-delà de U+FFFF (chaque emoji, plusieurs milliers de caractères CJK rares, toutes les écritures historiques) demande quatre octets UTF-8 et est rejeté.

Correctif.

ALTER DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4;  -- client connection
# my.cnf
[mysqld]
character-set-server = utf8mb4
collation-server     = utf8mb4_unicode_ci

MySQL 8.0 livre encore utf8 comme alias de utf8mb3. utf8mb3 est déprécié mais pas encore supprimé. Utilisez utf8mb4 pour chaque nouvelle colonne, base et connexion ; conserver la variante héritée n’apporte rien.

Piège 2 : le repli Windows-1252, le mystère du point d’interrogation

Symptôme. Un .txt exporté depuis le Bloc-notes d’un collègue Windows affiche « guillemets typographiques » et un tiret cadratin sur sa machine. Sur votre serveur, il devient ? ou U+FFFD (caractère de remplacement).

Cause racine. Les anciennes versions du Bloc-notes utilisent par défaut Windows-1252 (CP-1252), qui encode le guillemet courbe " en 0x93. Un décodeur UTF-8 voit 0x93 comme un octet de continuation égaré (bit de poids fort 10) sans octet de début précédent et le remplace par le caractère de remplacement.

Correctif. Détectez l’encodage source (file sous Unix, chardet / charset-normalizer en Python, jschardet en Node), décodez avec le bon codec, puis ré-encodez en UTF-8 avant de sauvegarder. Normaliser sur UTF-8 dès l’ingestion empêche la récidive.

Piège 3 : le percent-encoding URL ≠ UTF-8 (mais s’appuie dessus)

Symptôme. fetch("/search?q=中文") retourne 404 depuis un framework backend et fonctionne depuis un autre.

Cause racine. Le percent-encoding opère sur des octets, pas sur des codepoints. est un codepoint mais trois octets UTF-8 (E4 B8 AD), chacun percent-encodé séparément en %E4%B8%AD, soit neuf caractères ASCII dans l’URL. Un framework qui décode l’URL en Latin-1 au lieu d’UTF-8 livrera au handler les trois octets brouillés interprétés comme trois caractères mono-octet.

Correctif. Utilisez encodeURIComponent("中文") côté client (les navigateurs font UTF-8 + percent-encode en une seule étape) et vérifiez que le framework serveur décode les URL en UTF-8 (tous les frameworks modernes l’utilisent par défaut). Pour une vérification visuelle, collez 中文 dans le Décodeur et Encodeur URL : la chaîne devient %E4%B8%AD%E6%96%87. La chaîne complète est traitée dans le guide d’encodage et décodage d’URL.

Piège 4 : l’entrée Base64 est en octets, mais vous avez tapé une chaîne

Symptôme. btoa("你好") lève InvalidCharacterError: The string contains characters outside the Latin1 range.

Cause racine. btoa a été conçu à l’époque ASCII / Latin-1. Il attend que chaque caractère d’entrée tienne dans un seul octet (codepoints 0-255). 你好 est en UTF-16 dans le moteur JS avec les codepoints U+4F60 U+597D, tous deux au-delà de 255.

Correctif. Encodez d’abord en octets UTF-8, puis Base64-encodez ces octets.

// Wrong:
btoa("你好");  // throws

// Correct:
const bytes = new TextEncoder().encode("你好");
// Uint8Array(6) [228, 189, 160, 229, 165, 189]
const b64 = btoa(String.fromCharCode(...bytes));
// "5L2g5aW9"

L’histoire longue est dans Qu’est-ce que l’encodage Base64 ? et le Guide avancé Base64 ; l’Encodeur et Décodeur Base64 fait la conversion en une étape et affiche le flux d’octets intermédiaire.

Piège 5 : String.length pour la validation (limites Twitter / SMS)

Symptôme. Un éditeur de 280 caractères valide côté client, puis l’API retourne 422. Ou l’inverse : un post valable est refusé par le client.

Cause racine. Le .length de JavaScript compte les unités de code UTF-16 ; un seul emoji compte pour 2. Twitter, lui, compte les codepoints (emoji = 1). Le compte de caractères est faux dans des directions opposées selon l’API à laquelle vous faites confiance.

Correctif. Utilisez [...text].length pour un compte en codepoints, ou Intl.Segmenter pour un compte de graphèmes (l’approche Bluesky / iMessage). Les chiffres plateforme par plateforme et les frontières SMS GSM-7 versus UCS-2 sont catalogués dans le guide des limites de caractères par plateforme.

Piège 6 : les emojis ZWJ comptent pour N codepoints, 1 graphème

Symptôme. "👨‍👩‍👧".length === 8. Compter les codepoints donne 5. Pour l’utilisateur, c’est une seule image.

Cause racine. Le Zero-Width Joiner (U+200D) colle plusieurs codepoints d’emoji en une seule grappe rendue. Trois emojis de personnes plus deux ZWJ donnent cinq codepoints, huit unités de code UTF-16, un graphème.

Correctif.

const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment("👨‍👩‍👧")].length;  // 1

Intl.Segmenter est dans Node 22+ et tous les navigateurs actuels. Pour les runtimes plus anciens, le paquet grapheme-splitter implémente UAX #29.

Piège 7 : l’échappement JSON \uXXXX exige une paire de substitution au-delà de U+FFFF

Symptôme. Une charge utile JSON contient "😀" et le décodeur côté réception le rend correctement en 😀 ou affiche deux carrés, selon qu’il comprend ou non les paires de substitution dans JSON.

Cause racine. L’échappement \uXXXX de JSON n’accepte que quatre chiffres hexadécimaux, soit une unité de code UTF-16. Encoder 😀 (U+1F600) demande la paire de substitution 😀. Il n’existe pas de syntaxe \u{...} à accolades en JSON.

Correctif. Acceptez la paire de substitution (tout parseur conforme à la spec la gère) ou écrivez l’emoji littéralement. JSON autorise n’importe quel caractère UTF-8 en dehors de la syntaxe d’échappement, et la plupart des parseurs modernes préfèrent cette forme.

Piège 8 : les défauts du Content-Type: charset= HTTP sont contre-intuitifs

Symptôme. Une page HTML UTF-8 s’affiche en Mojibake dans un navigateur et correctement dans un autre.

Cause racine. La RFC 2616 imposait à l’origine ISO-8859-1 par défaut pour les réponses text/* sans charset explicite. La RFC 7231 (2014) a supprimé ce défaut, laissant chaque navigateur deviner. Certains reniflent le contenu, d’autres retombent sur UTF-8, d’autres encore prennent la locale système.

Correctif. Envoyez toujours Content-Type: text/html; charset=utf-8 depuis le serveur et <meta charset="utf-8"> dans la tête du document. L’un ou l’autre suffit ; les deux protègent des anciens proxies qui retirent les en-têtes.

Pour observer chacun de ces pièges au niveau octet, l’Encodeur et Décodeur Base64 reste l’outil le plus rapide : collez une chaîne, encodez en Base64, la charge utile décodée est le flux UTF-8.

Choisir le bon encodage : matrice de décision

Pour la question utf-8 vs utf-16, la réponse est presque toujours UTF-8. Le tableau ci-dessous couvre les cas limites.

Matrice de décision

ScénarioChoisirPourquoi
Pages web, JSON d’API, fichiers sourceUTF-8 (sans BOM)Compatible ASCII, pas d’ordre d’octets, le plus compact pour le texte latin, RFC 8259 impose UTF-8 pour JSON
Stockage CJK lourd (BDD chinoise, données de jeu japonaises)UTF-8 (utf8mb4)UTF-8 utilise 3 octets par caractère CJK contre 2 pour UTF-16, mais le surcoût ASCII du balisage et des clés JSON laisse UTF-8 devant en pratique, et l’écosystème environnant est en UTF-8
API natives Windows, code legacy Java / C#UTF-16Défaut de la plateforme ; convertir à chaque appel d’API invite les bugs
Traitement de texte en mémoire à fort accès indexéUTF-32Accès en temps constant aux codepoints ; ne vaut le coup que pour les hot paths d’un parseur
CSV ouvert dans Excel sous WindowsUTF-8 avec BOMExcel lit l’UTF-8 sans BOM comme de l’ANSI et massacre les en-têtes CJK
Nouveau projet, aucune contrainteUTF-8 (sans BOM)Le consensus de l’industrie est établi

Deux règles d’or

  1. Par défaut UTF-8 partout, sauf si une plateforme l’impose autrement. Le W3C, l’IETF et le consortium Unicode sont alignés.
  2. Convertissez aux frontières, pas au milieu. Décodez les octets vers le type chaîne natif de votre langage à l’ingestion. Opérez sur des chaînes, jamais sur des octets, dans la logique métier. Ré-encodez en UTF-8 en sortie. Ce « sandwich UTF-8 » élimine la classe entière des bugs de mojibake en milieu de pipeline.

Foire aux questions

UTF-8 est-il toujours rétrocompatible avec ASCII ?

Oui. Tout fichier ASCII valide est, bit pour bit, identique à sa représentation UTF-8. Les 128 premiers codepoints (U+0000U+007F) s’encodent sur un seul octet avec le bit de poids fort à 0. Les outils ASCII anciens (premiers grep, sed, pipes shell classiques) traitent les fichiers UTF-8 purement ASCII sans modification. Les ennuis ne commencent qu’avec des octets non-ASCII (bit de poids fort à 1) dans le flux.

Faut-il utiliser un BOM UTF-8 dans mes fichiers ?

Par défaut, non. Les fichiers HTML, JSON, JavaScript et CSS cassent ou émettent un avertissement dans certains parseurs lorsqu’un BOM apparaît en tête. L’exception standard est le CSV destiné à Excel sous Windows : sans BOM, Excel devine ANSI et massacre les en-têtes chinois, japonais ou coréens. Voir la matrice de décision BOM en section 5.

Pourquoi "😀".length === 2 en JavaScript ?

Les chaînes JavaScript sont stockées en UTF-16, et .length retourne le nombre d’unités de code, pas de caractères. 😀 (U+1F600) vit dans le plan supplémentaire et exige une paire de substitution (deux unités de code de 16 bits), donc .length vaut 2. Utilisez [..."😀"].length, Array.from("😀").length ou Intl.Segmenter pour un compte juste.

Quelle est la différence entre Unicode et UTF-8 ?

Unicode est la table de caractères qui attribue un codepoint (un nombre comme U+1F600) à chaque caractère. UTF-8 est l’un des encodages qui traduisent ces codepoints en octets (1 à 4 octets par codepoint). Unicode définit ce qu’est un caractère ; UTF-8 définit comment il voyage dans un fichier ou sur un réseau. UTF-16 et UTF-32 sont des encodages alternatifs de la même table Unicode.

utf8mb4 est-il toujours plus sûr que utf8 dans MySQL ?

Oui pour tout nouveau projet. Le utf8 de MySQL est la variante utf8mb3 mal nommée, limitée à 3 octets, qui ne peut stocker aucun caractère au-delà de U+FFFF (chaque emoji, beaucoup de caractères CJK rares, toutes les écritures historiques). utf8mb4 est de l’UTF-8 complet sur 4 octets. La seule réserve concerne la longueur d’index : chaque caractère utf8mb4 peut occuper 4 octets, si bien que la limite historique de 767 octets pour les index InnoDB plafonne les index uniques à 191 caractères (résolu par innodb_large_prefix à partir de MySQL 5.7+ et par défaut en 8.0).

Comment détecter l’encodage d’un fichier inconnu ?

Utilisez file sous Unix, chardet ou charset-normalizer en Python, ou jschardet en Node. Aucun n’est parfait : tous devinent statistiquement à partir de la distribution des octets. La détection d’UTF-8 est très fiable grâce au motif des octets de continuation. Windows-1252, ISO-8859-1 et les autres encodages mono-octet hérités sont quasiment indiscernables les uns des autres, et la détection retombe alors sur des heuristiques de langue.

UTF-16 peut-il représenter tous les caractères Unicode ?

Oui. UTF-16 couvre la totalité des 1 114 112 codepoints. Les caractères du BMP (U+0000U+FFFF) tiennent dans une unité de code 16 bits (2 octets), et les caractères des plans supplémentaires (U+10000U+10FFFF) utilisent des paires de substitution (4 octets). La couverture est identique à UTF-8 et UTF-32 ; seules la disposition des octets et la sémantique du traitement diffèrent. Le choix entre les trois relève de l’adéquation à l’écosystème, pas de la capacité.

Tags: unicode utf-8 utf-16 character-encoding surrogate-pair encoding

Articles connexes

Voir tous les articles