Les limites de caractères qui font vraiment mal : unités de code, points de code et graphèmes
Publié le 01/07/2026 · 12 min de lecture · Outils texte & langage
Daniel Okonkwo — Développeur front-end et rédacteur Tech chez OneKitly
Performance web · Formats de fichiers
Vérifié à partir de 7 sources
Le mot caractère désigne trois unités différentes, et chaque limite que tu rencontres est exprimée dans l'une d'elles sans jamais dire laquelle. Une unité de code UTF-16, c'est ce que renvoient .length en JavaScript et String.length() en Java. Un point de code, c'est une valeur scalaire Unicode, ce que renvoie len() en Python 3. Un graphème, c'est ce qu'un lecteur appelle un caractère, ce que renvoie String.count en Swift. Ils divergent dès que le texte quitte l'ASCII pur. L'emoji pouce levé avec teinte de peau moyenne vaut 1 graphème, 2 points de code et 4 unités UTF-16, et pèse 8 octets en UTF-8. L'emoji famille de quatre vaut 1 graphème, 7 points de code, 11 unités UTF-16 et 25 octets. La chaîne Shipping to, drapeau français, today, pouce levé teinté, tiret cadratin et thanks mesure 37 unités UTF-16, 33 points de code, 31 graphèmes et 47 octets UTF-8 : quatre nombres pour une seule chaîne. Les plateformes choisissent des unités différentes : X compte une longueur pondérée, Bluesky impose 300 graphèmes et 3 000 octets à la fois, Mastodon compte des points de code. Un SMS tient 160 caractères en GSM-7 mais seulement 70 en UCS-2, et une seule apostrophe courbe convertit tout le message. Et VARCHAR(255) signifie des caractères sous PostgreSQL et MySQL, des octets sous Oracle par défaut.
Un caractère, c'est trois choses à la fois. Un emoji avec teinte de peau vaut 1 graphème, 2 points de code et 4 unités UTF-16. Tous les comptages de ce guide ont été mesurés dans Node, avec en prime pourquoi un SMS tombe de 160 à 70 et pourquoi VARCHAR(255) ne fait pas 255 de quoi que ce soit de précis.
Trois unités, toutes appelées caractère
Une unité de code UTF-16 fait 16 bits de stockage. Tout ce qui dépasse U+FFFF - c'est-à-dire tous les emojis, toutes les écritures anciennes et une bonne partie du CJC - en exige deux, formant une paire de substitution. Les chaînes JavaScript sont définies comme des suites d'unités de code UTF-16 : .length compte donc celles-ci, tout comme String.length() en Java et le type string de .NET en C#.
Un point de code est une entrée de la base de caractères Unicode, notée U+0041 ou U+1F44D. C'est l'unité que renvoie len() en Python 3 et celle qu'emploient l'opérateur de décomposition et l'itération for-of en JavaScript. Elle est plus proche de l'intuition qu'une unité de code, mais reste différente de ce que voit un lecteur, car plusieurs points de code se combinent couramment en un seul signe visible.
Un graphème - la norme parle de graphème étendu, défini dans l'annexe 29 d'Unicode - est le caractère perçu par l'utilisateur. C'est ce que franchit le curseur, ce qu'efface une touche retour arrière, et ce qu'une personne compte. String.count en Swift renvoie des graphèmes ; Intl.Segmenter en JavaScript aussi, avec la granularité grapheme. Aucun autre langage courant ne les renvoie par défaut, et c'est la cause première de presque tous les bugs d'emoji que tu as croisés.
Une chaîne, quatre nombres
Prends un message court et parfaitement ordinaire : Shipping to, un emoji drapeau français, today, un pouce levé avec teinte de peau moyenne, un tiret cadratin, puis thanks et un point d'exclamation. Mesuré dans Node 22, il fait 37 unités de code UTF-16, 33 points de code, 31 graphèmes et 47 octets en UTF-8. Une personne qui le lit dirait 31 caractères. JavaScript te dira 37. Une colonne de base de données limitée en octets en verra 47.
Les écarts viennent de deux endroits. L'emoji drapeau est une paire d'indicateurs régionaux - deux points de code, quatre unités UTF-16, un seul drapeau visible - et le pouce levé est un emoji de base plus un modificateur de teinte, là encore deux points de code et quatre unités UTF-16 pour un seul signe visible. À eux deux, ils expliquent 37 contre 31. Rien d'exotique ici : c'est à quoi ressemble un message normal envoyé depuis un téléphone normal.
Le même texte visible peut avoir deux longueurs différentes
Le mot Cafe avec accent aigu, suivi d'une espace, d'un pouce levé teinté et d'un point d'exclamation, mesure 10 unités UTF-16, 8 points de code, 7 graphèmes et 15 octets UTF-8 quand la lettre accentuée est le point de code précomposé U+00E9. Saisis la chaîne d'apparence identique sur un Mac qui émet plutôt un e suivi d'un accent aigu combinant, et elle passe à 11 unités UTF-16, 9 points de code, toujours 7 graphèmes, et 16 octets UTF-8.
Mêmes pixels, longueur différente, et un contrôle de taille en octets qui passe dans un cas échoue dans l'autre. La solution consiste à normaliser à l'entrée : applique la forme de normalisation Unicode C, qui recompose e plus accent combinant en U+00E9, avant de mesurer, stocker, comparer ou hacher. Presque tous les bugs de détection de doublons portant sur des noms accentués remontent à la comparaison d'une chaîne NFC avec une chaîne NFD.
La troncature, c'est là que l'abstraction casse en public
Coupe cette chaîne Cafe à six unités UTF-16 avec un slice ordinaire et tu obtiens C, a, f, le e accentué, une espace, puis U+D83D tout seul - la moitié haute d'une paire de substitution sans partenaire. Ce substitut isolé n'est pas un caractère valide. Les moteurs de rendu affichent un carré de remplacement, les encodeurs JSON produisent une séquence d'échappement que certains analyseurs rejettent, et les bases de données avec validation UTF-8 stricte refusent purement et simplement l'écriture.
Tronquer aux points de code évite le plantage du substitut isolé, mais coupe encore un emoji famille en deux adultes et un enfant, ou retire le modificateur de teinte d'un pouce levé qui redevient jaune. La seule troncature sûre devant un utilisateur est fondée sur les graphèmes : segmente avec Intl.Segmenter, prends les n premiers segments, réassemble. Si ta limite est exprimée en octets, fais les deux : compte les graphèmes pour décider où une coupe est légale, et les octets UTF-8 pour décider combien tu peux en garder.
Quelle plateforme compte quelle unité
X ne compte pas des caractères du tout : il calcule une longueur pondérée où les caractères des plages latin, supplément latin-1 et ponctuation générale comptent 1, et tout le reste - CJC, arabe, cyrillique au-delà du bloc de base, et chaque emoji - compte 2. Un post limité à 280 tient donc 280 lettres latines ou 140 emojis. Bluesky impose deux limites simultanées sur le même champ : 300 graphèmes et 3 000 octets ; un post de 300 emojis drapeau passe la limite en graphèmes et échoue en octets. Le plafond par défaut de 500 chez Mastodon se compte en points de code, toute URL étant facturée forfaitairement 23 quelle que soit sa longueur réelle.
Conséquence pratique : un compteur unique ne peut pas servir toutes les destinations. Si tu programmes le même texte sur quatre réseaux, il te faut quatre comptages, et un aperçu qui montre la chaîne telle que le lecteur la verra, pas telle que la couche de stockage la mesure. Dans le doute, compte les graphèmes pour la limite côté humain et les octets UTF-8 pour la limite côté machine, et méfie-toi de tout chiffre unique étiqueté caractères tant que tu ignores quelle unité l'a produit.
SMS : 160 devient 70 dès qu'un caractère sort du GSM-7
Le corps d'un SMS fait 140 octets. Encodé dans l'alphabet GSM 7 bits défini par la norme 3GPP TS 23.038, à sept bits par caractère, cela donne 160 caractères. Mais cet alphabet ne compte qu'environ 128 emplacements plus une petite table d'extension : si un seul caractère du message en sort, tout le message doit être ré-encodé en UCS-2 à 16 bits par caractère, soit 140 octets divisés par 2, donc 70 caractères. Pas 70 pour le caractère fautif : 70 pour le message entier.
Prenons un exemple. Une confirmation de réservation de 145 caractères tient en un seul segment GSM-7, puisque 145 est inférieur à 160. Colle-la depuis un traitement de texte qui a silencieusement converti l'apostrophe droite en apostrophe typographique, ou ajoute un tiret cadratin, ou un emoji : le message bascule en UCS-2. Or 145 dépasse 70, il est donc découpé en parties concaténées, et la concaténation ponctionne six octets par partie pour l'en-tête de segmentation, ne laissant que 67 caractères chacune. 145 divisé par 67 s'arrondit à 3. Une substitution invisible a transformé un message facturé en trois.
Savoir quels caractères sont sûrs n'a rien d'intuitif. Plusieurs lettres accentuées figurent dans le jeu GSM-7 de base - e accent aigu, e accent grave, a accent grave, u accent grave, le s dur allemand et les voyelles à tréma en font partie - si bien qu'un SMS français ou allemand n'est pas automatiquement en UCS-2. Mais a, i, o et u accent aigu n'y sont pas, ce qui veut dire que les accents espagnols et portugais forcent en général la bascule. Le signe euro se trouve dans la table d'extension et coûte deux septets au lieu d'un. Guillemets courbes, tirets demi-cadratin et cadratin, caractère de points de suspension et tous les emojis en sont purement absents.
VARCHAR(255) ne fait pas 255 de quoi que ce soit de précis
Sous PostgreSQL, varchar(255) fait 255 caractères, c'est-à-dire des points de code, et le stockage grandit selon ce qu'exige UTF-8. Sous MySQL, VARCHAR(255) compte des caractères et non des octets depuis la version 5.0 : avec le jeu utf8mb4, la même colonne peut donc occuper jusqu'à 1 020 octets. Sous Oracle, VARCHAR2(255) signifie 255 octets sauf si tu écris VARCHAR2(255 CHAR) ou modifie NLS_LENGTH_SEMANTICS - d'où le rejet silencieux d'un nom de 200 caractères écrit dans un alphabet accentué par un champ limité à 255 dans un système Oracle. Sous SQL Server, VARCHAR compte des octets dans un classement mono-octet et NVARCHAR compte des unités UTF-16 de deux octets chacune.
Il existe un piège de second ordre propre à MySQL. L'ancienne limite de préfixe d'index InnoDB de 767 octets faisait qu'une colonne VARCHAR(255) en utf8mb4 - jusqu'à 1 020 octets - ne pouvait pas être entièrement indexée : d'où le folklore du VARCHAR(191), car 191 fois 4 égale 764, juste sous la limite. InnoDB moderne avec le format de ligne DYNAMIC porte cette limite à 3 072 octets et le contournement est obsolète, mais les colonnes de longueur 191 qu'il a engendrées traînent encore partout dans les schémas en production.
| Chaîne | Unités UTF-16 (.length) | Points de code | Graphèmes | Octets UTF-8 |
|---|---|---|---|---|
| La lettre a | 1 | 1 | 1 | 1 |
| e accent aigu, écrit en un seul point de code U+00E9 | 1 | 1 | 1 | 2 |
| La même lettre saisie comme e plus un accent combinant U+0065 U+0301 | 2 | 2 | 1 | 3 |
| Emoji pouce levé avec teinte de peau moyenne (U+1F44D U+1F3FD) | 4 | 2 | 1 | 8 |
| Emoji famille : homme, femme, fille, garçon reliés par trois jointeurs de largeur nulle | 11 | 7 | 1 | 25 |
Questions fréquentes
- Pourquoi JavaScript dit-il que mon emoji fait 2 caractères ?
- Parce que .length compte des unités de code UTF-16, et que tout emoji se situe au-dessus de U+FFFF : il lui faut donc une paire de substitution, soit deux unités. Ajoute un modificateur de teinte et tu en as quatre. Utilise [...str].length pour compter les points de code, ou Intl.Segmenter avec la granularité grapheme pour compter ce qu'un lecteur compterait. En une ligne : new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(str) te donne un itérable de caractères visibles.
- Combien d'octets occupe un caractère en UTF-8 ?
- De un à quatre, selon le point de code. L'ASCII jusqu'à U+007F prend 1 octet. Les accents latins, le grec et le cyrillique jusqu'à U+07FF en prennent 2. L'essentiel du plan multilingue de base, CJC compris, en prend 3. Tout ce qui dépasse U+FFFF, c'est-à-dire tous les emojis, en prend 4. Un pouce levé teinté fait donc 8 octets, car c'est deux points de code de 4 octets, et l'emoji famille de quatre fait 25 octets : quatre emojis à 4 octets plus trois jointeurs de largeur nulle à 3 octets chacun.
- Pourquoi mon SMS de 145 caractères a-t-il été facturé trois messages ?
- Parce qu'un caractère n'appartenait pas à l'alphabet GSM 7 bits : tout le message est passé en UCS-2, à 70 caractères par segment. Au-delà de 70, les segments portent un en-tête de concaténation qui coûte six octets, laissant 67 caractères chacun, et 145 divisé par 67 s'arrondit à 3. Les coupables habituels sont une apostrophe typographique, un tiret cadratin, un caractère de points de suspension ou un emoji, tous insérés automatiquement par les traitements de texte et les claviers de téléphone. Passe le texte dans un compteur d'octets et d'encodage avant d'envoyer une campagne, pas après.
- Faut-il stocker une limite en caractères ou en octets ?
- Impose deux limites, pas une. Une limite en graphèmes pour ce que tu montres à l'utilisateur, car c'est le chiffre qu'il peut vérifier à l'œil. Une limite en octets pour le stockage et le transport, car c'est ce que contraignent réellement la colonne, la charge utile et le protocole. Si tu ne peux en avoir qu'une, prends les octets et rendez l'interface honnête à ce sujet : un dépassement d'un seul octet est un échec d'écriture, tandis qu'un dépassement de graphèmes n'est qu'un problème esthétique.
- Ces règles valent-elles aussi pour une méta-description ou une balise title ?
- Non, et c'est précisément le cas où compter les caractères est le mauvais réflexe. Les moteurs de recherche tronquent titres et descriptions selon la largeur en pixels rendue, pas selon le nombre de caractères : un titre de 60 lettres étroites peut passer là où 50 lettres larges ne passent pas, et un emoji dans un titre occupe bien plus de largeur que ne le laisse croire son unique graphème. Les cibles de 60 et 155 caractères sont des approximations d'un budget de pixels. Écris pour le budget de pixels, vérifie dans un aperçu de résultat de recherche, et sers-toi du compteur de caractères uniquement pour éviter de dériver vers des longueurs manifestement irrécupérables.
Articles qui pourraient t'intéresser
Tous les guides →Outils similaires
Sources
- Unicode Consortium — UAX #29: Unicode Text Segmentation (grapheme cluster boundaries)
- Unicode Consortium — UAX #15: Unicode Normalization Forms (NFC, NFD, NFKC, NFKD)
- Unicode Consortium — UTS #51: Unicode Emoji (emoji modifiers and ZWJ sequences)
- Ecma International — ECMAScript Language Specification: String values are sequences of 16-bit code units
- 3GPP — TS 23.038: Alphabets and language-specific information (GSM 7-bit default alphabet)
- Oracle / MySQL — MySQL Reference Manual: The CHAR and VARCHAR Types
- PostgreSQL Global Development Group — PostgreSQL Documentation: Character Types
Tu as repéré une erreur dans cet article ?