UTF-8 vs UTF-16 vs Unicode — Complete encoding-gids
Hier is het korte antwoord op wat de meeste zoekopdrachten naar utf-8 unicode encoding eigenlijk vragen: Unicode en UTF-8 zijn niet hetzelfde. Unicode is een gigantische genummerde tabel die aan elk teken een codepoint toekent (een getal zoals U+1F600). UTF-8, UTF-16 en UTF-32 zijn encodings — drie verschillende manieren om die codepoints in bytes om te zetten. UTF-8 is bijna altijd wat je wilt: byte-identiek aan ASCII voor Engelse tekst, schaalt naar vier bytes voor elke emoji, en is verplicht volgens JSON, HTML5 en de meeste moderne protocollen.
Deze gids is voor de developer die er zelf last van heeft gehad: de MySQL Incorrect string value-fout op een 😀, de JavaScript-verrassing dat "😀".length === 2, het CSV-bestand dat prima opent in cat maar verminkt in Excel. We beginnen bij codepoints, lopen dan via UTF-8 byte-mechaniek, surrogate pairs en BOM’s naar het standaardgedrag in negen talen en acht productie-valkuilen, met op het eind een beslissingsmatrix en FAQ.
Wil je tijdens het lezen een byte-reeks controleren? Plak een willekeurige string in de Base64 Decoder/Encoder: de gedecodeerde payload is precies de UTF-8 byte-stream die dit artikel uitlegt.
Waarom encoding je in 2026 nog steeds bijt
Drie scenario’s, allemaal uit echte bugtrackers van de afgelopen twaalf maanden:
- MySQL weigert een emoji. Een gebruiker stuurt
Hello 😀in en de server geeftIncorrect string value: '\xF0\x9F\x98\x80'terug. De tabel isutf8, de developer denkt “dat is toch UTF-8, wat is er mis?”, en het antwoord ligt begraven in de geschiedenis van MySQL (zie sectie 7). - Een character counter wordt kapot uitgerold. Een tweet-validator voor 280 tekens gebruikt
text.length, accepteert een bericht vol emoji, en de API weigert het. Omgekeerd gebeurt ook: een geldig bericht wordt door de front-end geweigerd. Het symptoom wordt in sectie 4 ontleed. - Lokale HTML verandert in “䏿–‡”. Een developer slaat een bestand op in Windows-1252, opent het in een browser die UTF-8 raadt, en ziet Mojibake bloeien. Dit is het BOM-/charset-declaratieverhaal uit sectie 5, met parallellen naar de URL-encoderen en -decoderen: developer-gids voor percent encoding, waar dezelfde mismatch tussen bytes en tekens query strings sloopt.
Na deze gids kun je (a) Unicode en UTF-8 in één zin uit elkaar houden, (b) voor elk nieuw project kiezen tussen UTF-8, UTF-16 en UTF-32, (c) code schrijven die in elke grote taal emoji correct telt, en (d) elke charset-bug debuggen aan de hand van de byte-stream alleen. Tekencodering gaat diep, maar het stuk dat je in de praktijk nodig hebt past op een paar pagina’s.
Wat is Unicode? Codepoints vs tekens vs glyphs
Unicode is een tekentabel die aan elk teken een uniek getal toekent: een codepoint, zoals U+1F600. UTF-8, UTF-16 en UTF-32 zijn encodings die die codepoints in bytes vertalen. Unicode zelf slaat geen bytes op; het definieert alleen de afbeelding van abstract teken naar geheel getal.
Drie aanvullende termen vertroebelen het gesprek omdat ze vaak verwijzen naar hetzelfde zichtbare teken:
Drie lagen die je moet scheiden
- Codepoint (
U+0041,U+1F600): het gehele getal dat Unicode toewijst. De ruimte loopt vanU+0000totU+10FFFF, ruwweg 1,1 miljoen plekken, waarvan er momenteel zo’n 150.000 zijn toegewezen. - Teken (of abstract teken): de semantische identiteit, zoals Latijnse hoofdletter A of grijnzend gezicht-emoji.
- Glyph: de visuele vorm die een lettertype rendert. Eén teken heeft veel glyphs: een schreef-A, een cursieve A, een met de hand getekende A. Unicode geeft niets om glyphs.
- Grapheme cluster: wat een gebruiker als één “teken” ervaart. Vaak één codepoint, soms meerdere. De letter á kan één codepoint
U+00E1zijn of twee codepointsa + U+0301(combinerend acuut accent). De Teken- en woordlimieten 2026 — Twitter, SMS, SEO, Instagram bespreekt hoe Twitter, SMS en SEO deze grens elk anders trekken.
De keten is dus: codepoint → encoding → bytes → rendering. Elke pijl kan los van de andere breken.
Codepoint-notatie: U+XXXX en \uXXXX
Je ziet codepoints in verschillende smaken geschreven. U+0041 is de canonieke Unicode-notatie: vier tot zes hex-cijfers, met prefix U+. In broncode:
- JavaScript / JSON:
"A"(vier hex-cijfers, alleen BMP) en"\u{1F600}"(ES6-accolades, elk codepoint). - Python:
"A"(4 cijfers),"\U00000041"(8 cijfers, hoofdletter U),"\N{LATIN CAPITAL LETTER A}"(op naam). - Shell / git log / sed-output: je ziet vaak ruwe UTF-8 bytes zoals
\xc3\xa9vooré. Dat is geen codepoint, dat is de geëncodeerde vorm, wat ons bij sectie 3 brengt.
De 17 planes: BMP en daarbuiten
Unicode verdeelt zijn codepoint-ruimte in 17 planes van elk 65.536 codepoints; samen 17 × 2^16 = 1.114.112.
- Plane 0, de Basic Multilingual Plane (BMP):
U+0000totU+FFFF. Latijn, CJK-ideogrammen, Cyrillisch, Arabisch, Grieks. Bijna elk schrift dat je in legacy-tekst tegenkomt, woont hier. - Planes 1-16, de supplementary planes:
U+10000totU+10FFFF. De meeste emoji (U+1F600en vrienden), zeldzame CJK-tekens, historische schriften (Egyptische hiërogliefen, spijkerschrift), muzieknotatie.
De grens tussen BMP en supplementary op U+FFFF is het belangrijkste getal in dit artikel. Het is waar UTF-16 ophoudt één code unit per teken te zijn, waar UTF-8 van drie bytes naar vier springt, en waar MySQL’s verkeerd benoemde utf8-collation het opgeeft.
Snelle sanity check met emoji
"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
De laatste rij is de kicker. De familie-emoji is één teken zoals de gebruiker het ervaart, maar vijf codepoints aan elkaar geknoopt door Zero-Width Joiners. Elke laag van de stack telt het anders, en valkuil 6 in sectie 7 is het bugrapport dat dit meningsverschil indient.
UTF-8 encoding-mechaniek: hoe 1-4 bytes werken
UTF-8 encodeert Unicode-codepoints in 1 tot 4 bytes. ASCII (U+0000–U+007F) gebruikt 1 byte en is byte-identiek aan ASCII. Hogere codepoints gebruiken multi-byte sequenties waarbij de eerste byte de totale lengte aangeeft en elke vervolg-byte begint met het bitpatroon 10xxxxxx. Die zelfbeschrijvende layout is de reden dat UTF-8 het uiteindelijk heeft gewonnen.
De byte-patroontabel: UTF-8 in één diagram
| Codepoint-bereik | UTF-8 bytes | Byte-patroon |
|---|---|---|
U+0000 – U+007F | 1 byte | 0xxxxxxx |
U+0080 – U+07FF | 2 bytes | 110xxxxx 10xxxxxx |
U+0800 – U+FFFF | 3 bytes | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 – U+10FFFF | 4 bytes | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Elke x is een databit ontleend aan de binaire representatie van het codepoint. De leidende 0 / 110 / 1110 / 11110 vertelt de decoder hoeveel bytes het in totaal zijn; de leidende 10 markeert elke vervolg-byte. Die redundantie is wat UTF-8 zelf-synchroniserend maakt — verlies een byte en je kunt verdergaan bij de volgende start-byte in plaats van alles verderop te corrumperen.
Uitgewerkt voorbeeld: 中 (U+4E2D) encoderen
Codepoint 0x4E2D valt in U+0800–U+FFFF, dus we gebruiken het 3-byte-sjabloon.
- Binair:
0x4E2D=0100 1110 0010 1101(16 bits). - Splits 4-6-6 om in de
x-slots te passen:0100 / 111000 / 101101. - Substitueer in
1110xxxx 10xxxxxx 10xxxxxx:11100100 10111000 10101101. - Hex:
0xE4 0xB8 0xAD.
Dat is precies waarom 中 na URL-encoderen %E4%B8%AD wordt: percent-encoding wikkelt elke UTF-8 byte in %XX, het encodeert het codepoint niet direct. Valkuil 3 in sectie 7 beschrijft de keten in detail.
Uitgewerkt voorbeeld: 😀 (U+1F600) encoderen
Codepoint 0x1F600 overschrijdt de BMP, dus we gebruiken het 4-byte-sjabloon.
- Binair:
0x1F600=0 0001 1111 0110 0000 0000(21 bits, opgevuld). - Splits 3-6-6-6:
000 / 011111 / 011000 / 000000. - Substitueer in
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx:11110000 10011111 10011000 10000000. - Hex:
0xF0 0x9F 0x98 0x80.
Op die vier bytes verslikt MySQL’s utf8-collation zich — die wijst maximaal drie bytes per teken toe. Valkuil 1 in sectie 7 heeft de fix.
Waarom UTF-8 heeft gewonnen
- ASCII-compatibiliteit. Een bestand met pure ASCII-tekst is op byte-niveau identiek aan zijn UTF-8 encoding. Decennia aan tools die ouder zijn dan Unicode (
grep,awk, klassieke shell pipes) blijven werken voor die subset. - Zelf-synchroniserend. Vervolg-bytes beginnen altijd met
10, wat nooit botst met een start-byte. Verlies één byte in een netwerkoverdracht en je synchroniseert opnieuw bij de volgende tekengrens in plaats van een lawine aan rommel. - Geen byte-order. UTF-8 is een stream van losse bytes, geen 16-bit of 32-bit eenheden, dus endianness speelt geen rol. UTF-16 en UTF-32 hebben een Byte Order Mark nodig om aan te geven welk uiteinde eerst komt; UTF-8 niet, en meestal moet je dat ook niet doen (zie sectie 5).
Ongeldig UTF-8: wat de spec verbiedt
Een strikte decoder weigert deze byte-reeksen:
- 5- of 6-byte sequenties. Vroege RFC’s stonden ze toe; RFC 3629 (2003) heeft UTF-8 afgetopt op 4 bytes om bij de 21-bits Unicode-ruimte te passen.
- Overlong encodings.
/als drie bytes0xE0 0x80 0xAFencoderen in plaats van één byte0x2F. Vroeger een vruchtbare bron van directory-traversal-exploits in path-validators die decodeerden na het saniteren. - Eenzame surrogate codepoints (
U+D800–U+DFFF). Deze zijn gereserveerd voor UTF-16 en horen nooit in UTF-8 voor te komen. - Afgekapte sequenties. Een 3-byte start-byte gevolgd door slechts één vervolg-byte. Komt vaak voor wanneer invoer van een gebruiker op een byte-grens midden in een multi-byte teken wordt afgekapt.
Om dit concreet te zien, plak een string in de Base64 Decoder/Encoder, encodeer hem, en decodeer hem dan terug als bytes. De byte-array tussen encoder en decoder is de UTF-8 stream die deze sectie beschrijft.
UTF-16 en surrogate pairs: waarom JavaScript-length liegt
De meestgestelde zoekvraag rond utf-8 vs utf-16 is eigenlijk “waarom is "😀".length 2 in mijn code?” Het antwoord zijn surrogate pairs, en het is een beslissing uit de jaren 90 die JavaScript, Java, C# en Windows allemaal hebben geërfd.
UTF-16 in één paragraaf
UTF-16 representeert Unicode met 16-bits code units. Tekens in de BMP (U+0000–U+FFFF) nemen precies één code unit. Tekens in de supplementary planes (U+10000–U+10FFFF) nemen twee code units, een surrogate pair genoemd: een high surrogate in U+D800–U+DBFF gevolgd door een low surrogate in U+DC00–U+DFFF. Dat blok U+D800–U+DFFF is permanent gereserveerd in Unicode, zodat er nooit een echt teken woont. UTF-16 is het interne stringformaat voor JavaScript, Java, C# (.NET), Windows kernel-API’s, Objective-C NSString en Qt: allemaal ontworpen toen 65.536 tekens nog ruim leken.
De String.length-valkuil
"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 rapporteert het aantal UTF-16 code units, niet het aantal tekens. Alles uit de supplementary plane leest als 2. Dezelfde valkuil bestaat in Java’s String.length() en C#‘s string.Length.
Codepoints correct tellen in 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
De spread-operator en Array.from gebruiken het iterator-protocol, dat in de language spec is gedefinieerd als het lopen over codepoints. Gewone index-toegang (str[0], charAt) geeft nog steeds code units terug en levert je de helft van een surrogate pair op bij emoji.
Python: len() doet het al goed (bijna)
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 slaat strings op in een flexibele representatie van 1, 2 of 4 bytes (PEP 393) en indexeert per codepoint. len("😀") is 1, maar dat is nog steeds geen grapheme-telling: de familie-emoji leest nog steeds als 5. Om door de gebruiker waargenomen tekens te tellen heb je een grapheme-bibliotheek nodig: Intl.Segmenter in JavaScript (Node 22+, alle huidige browsers), grapheme of regex in Python, of gewoon Swift, waar String.count als enige reguliere taal standaard grapheme’s telt.
UTF-16 vs UCS-2: de stille migratie
Vóór 1996 beloofde Unicode in 16 bits te passen, en de bijbehorende encoding was UCS-2: een vaste 2-byte afbeelding. Unicode 2.0 brak die belofte door de supplementary planes toe te voegen. UTF-16 is de gepatchte versie met surrogate pairs. De JavaScript-spec citeert op sommige plekken nog steeds het oude UCS-2 vocabulaire, en daarom tolereert de taal eenzame surrogates die eigenlijk illegaal zouden moeten zijn (de “WTF-16”-grappen zijn echt). Web platform-API’s (DOM, fetch, TextEncoder) weigeren eenzame surrogates omdat ze niet als geldig UTF-8 kunnen worden geëncodeerd.
UTF-32, BOM en de byte-order-vraag
UTF-32: de simpele, verspillende
UTF-32 gebruikt een vaste 4 bytes per codepoint. U+0041 wordt opgeslagen als 0x00000041, U+1F600 als 0x0001F600. Het voordeel is random access in constante tijd: het n-de codepoint zit op byte-offset 4n. Het nadeel is omvang: pure ASCII-tekst zwelt op tot viervoud van zijn UTF-8 voetafdruk, en zelfs CJK-tekst verdubbelt. Bijna geen enkel systeem slaat UTF-32 op schijf op. Intern kiest Python 3 1, 2 of 4 bytes per string op basis van het hoogste codepoint; de Linux fontconfig-stack gebruikt UTF-32 voor zijn glyph-tabellen in het geheugen.
Byte-order: waarom endianness belangrijk is voor UTF-16 / UTF-32
UTF-8 is een stream van losse bytes, dus endianness is niet van toepassing. UTF-16 en UTF-32 werken met multi-byte eenheden, en verschillende CPU’s zijn het oneens over welk uiteinde van een getal eerst komt.
U+0041 ('A') in UTF-16 BE → 00 41
U+0041 ('A') in UTF-16 LE → 41 00
x86- en ARM-CPU’s zijn little-endian; oudere PowerPC en “network byte order” zijn big-endian. Wanneer je een UTF-16 bestand schrijft, moet je je vastleggen op één en de lezer dat vertellen, en daar dient de BOM voor.
De BOM: wat het is, wanneer te gebruiken
Een Byte Order Mark is U+FEFF geplaatst aan het begin van een bestand. Geëncodeerd kondigt hij zowel de encoding aan als (voor UTF-16 / UTF-32) de byte-volgorde.
| Encoding | BOM-bytes |
|---|---|
| UTF-8 | EF BB BF |
| UTF-16 BE | FE FF |
| UTF-16 LE | FF FE |
| UTF-32 BE | 00 00 FE FF |
| UTF-32 LE | FF FE 00 00 |
De utf-8 BOM bestaat, maar hij draagt geen byte-order-informatie omdat UTF-8 geen byte-volgorde heeft. Zijn enige taak is verklaren “dit bestand is UTF-8”. Handig voor tools zonder ander signaal, schadelijk voor tools die verwachten dat het bestand met een magic number of directive begint.
BOM-beslissingsmatrix: moet ik hem toevoegen?
| Formaat | UTF-8 BOM | UTF-16 BOM | UTF-32 BOM |
|---|---|---|---|
| HTML | Nee (breekt <!doctype>-detectie in oude parsers) | — | — |
| JSON | Nee (RFC 8259 verbiedt het) | — | — |
| JavaScript- / CSS-broncode | Vermijden (oudere Node en IE verslikken zich) | — | — |
| CSV geopend in Excel | Ja (Excel leest UTF-8 zonder BOM als ANSI en verminkt CJK) | — | — |
| XML | Optioneel (XML-declaratie geeft de encoding al aan) | Verplicht | Verplicht |
Platte tekst .txt | Optioneel (Windows Notepad voegt er standaard één toe) | Verplicht | Verplicht |
De korte regel: laat de UTF-8 BOM weg uit alles wat via het web wordt geserveerd; voeg hem toe aan CSV’s die je in Excel wilt openen; laat de lezer het voor de rest beslissen.
9 talen naast elkaar: standaardgedrag voor encoding
Cross-language werk is waar deze kennis zich terugverdient. Dezelfde string "a😀é" levert in elke runtime die je vanuit je Bash-script aanroept een andere lengte op.
De cross-language gedragstabel
| Taal | Broncode-encoding | Stringopslag | length / len telt | Standaard I/O-encoding | 4-byte emoji veilig? |
|---|---|---|---|---|---|
| JavaScript (V8 / SpiderMonkey) | UTF-8 | UTF-16 | UTF-16 code units | UTF-8 (Node, Web) | Ja, maar .length === 2 |
| Python 3 | UTF-8 (PEP 3120) | dynamisch 1 / 2 / 4 byte (PEP 393) | codepoints | UTF-8 (PEP 540 sinds 3.7) | Ja, len === 1 |
| Java | UTF-8 (javac default) | UTF-16 | UTF-16 code units | platform charset → UTF-8 (JEP 400, JDK 18+) | Ja, maar .length() === 2 |
| Go | UTF-8 | UTF-8 bytes | bytes (utf8.RuneCountInString voor codepoints) | UTF-8 | Ja, len(s) geeft bytes |
| Rust | UTF-8 | UTF-8 bytes (String-invariant) | .len() bytes, .chars().count() codepoints | UTF-8 | Ja, expliciet |
| C# (.NET) | UTF-8 (default sinds .NET Core 3.0) | UTF-16 | UTF-16 code units | UTF-8 (Encoding.Default sinds .NET 5) | Ja, maar .Length === 2 |
| Ruby | UTF-8 (sinds 2.0) | encoding-tag per string | codepoints (.length) | UTF-8 | Ja, length === 1 |
| PHP | (geen broncode-encoding) | byte-string | bytes (strlen); mb_strlen voor codepoints | hangt af van default_charset | Ja, met mb_*-familie |
| MySQL | — | kolom-charset | bytes (LENGTH), tekens (CHAR_LENGTH) | character_set_*-systeemvariabelen | Alleen met utf8mb4 |
Wat de tabel je echt vertelt
Er lopen grofweg drie filosofieën door deze rijen, elk met eigen valkuilen:
- UTF-8 intern (Go, Rust, Ruby). De native string is bytes;
lengthis goed gedefinieerd maar telt wat hij telt. Zet pas om naar codepoints of grapheme’s wanneer je een UI- of validatiegrens oversteekt. - UTF-16 intern (JavaScript, Java, C#). Geërfd uit aannames van de jaren 90;
lengthtelt code units, een surrogate pair telt als 2. Gebruik codepoint-bewuste iteratie voor elke telling die de gebruiker te zien krijgt. - Codepoint-geïndexeerd (Python 3).
lengeeft codepoints, wat correct aanvoelt totdat je een ZWJ-emoji tegenkomt; dan heb je alsnog een grapheme-bibliotheek nodig.
PHP is het bijzondere geval. De ingebouwde str*-functies werken allemaal op bytes en behandelen UTF-8 sequenties als ondoorzichtige blobs. Elk niet-ASCII project moet de mb_* (multibyte) familie gebruiken, en de jaarlijkse bugrapporten laten zien hoe vaak dat wordt gemist.
De praktische richtlijn: houd UTF-8 overal aan als wire-format (bestanden, HTTP-bodies, database-kolommen) en zet pas op de grens om naar het native stringtype van je runtime. Dat is de “UTF-8 sandwich” waar we in sectie 8 op terugkomen.
8 valkuilen uit de praktijk
De patronen hieronder duiken op in elke code review op een geglobaliseerde codebase.
Valkuil 1: MySQL utf8 is een 3-byte leugen, ga over naar utf8mb4
Symptoom. INSERT INTO users (bio) VALUES ('Hello 😀'); geeft Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio' terug.
Oorzaak. MySQL’s historische utf8 is een alias voor utf8mb3: een UTF-8 variant afgetopt op drie bytes per teken. Elk codepoint boven U+FFFF (elke emoji, enkele duizenden zeldzame CJK-tekens, alle historische schriften) vereist vier UTF-8 bytes en wordt geweigerd.
Fix.
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 levert utf8 nog steeds als de utf8mb3-alias. utf8mb3 is deprecated maar nog niet verwijderd. Gebruik utf8mb4 voor elke nieuwe kolom, elke nieuwe database, elke nieuwe verbinding; de legacy-variant heeft geen voordelen meer.
Valkuil 2: Windows-1252 fallback, het vraagteken-mysterie
Symptoom. Een .txt geëxporteerd uit het Notepad van een Windows-collega leest "smart quotes" en een em-dash op hun machine. Op jouw server wordt het ? of U+FFFD (replacement character).
Oorzaak. Oudere Notepad gaat standaard uit van Windows-1252 (CP-1252), dat het krulaanhalingsteken " als 0x93 encodeert. Een UTF-8 decoder ziet 0x93 als een verdwaalde vervolg-byte (hoogste bit 10) zonder voorafgaande start-byte en vervangt het door het replacement character.
Fix. Detecteer de bron-encoding (file op Unix, chardet / charset-normalizer in Python, jschardet in Node), decodeer met de juiste codec en encodeer opnieuw als UTF-8 voordat je opslaat. Standaardiseren op UTF-8 bij inname voorkomt herhaling.
Valkuil 3: URL percent-encoding ≠ UTF-8 (maar bouwt erop voort)
Symptoom. fetch("/search?q=中文") geeft 404 vanaf het ene backend-framework en werkt vanaf het andere.
Oorzaak. Percent-encoding werkt op bytes, niet op codepoints. 中 is één codepoint maar drie UTF-8 bytes (E4 B8 AD), elk los percent-geëncodeerd als %E4%B8%AD: samen negen ASCII-tekens in de URL. Een framework dat de URL als Latin-1 decodeert in plaats van UTF-8 geeft de handler drie verminkte bytes, geïnterpreteerd als drie single-byte tekens.
Fix. Gebruik encodeURIComponent("中文") op de client (browsers doen UTF-8 + percent-encoderen in één stap) en bevestig dat het server-framework URL’s als UTF-8 decodeert (alle moderne frameworks doen dat standaard). Plak voor visuele bevestiging 中文 in de URL Decoder & Encoder — Decodeer, Encodeer en Analyseer URL’s Online en kijk hoe het %E4%B8%AD%E6%96%87 wordt. De volledige keten wordt behandeld in de URL-encoderen en -decoderen: developer-gids voor percent encoding.
Valkuil 4: Base64-invoer is bytes, maar je typte een string
Symptoom. btoa("你好") gooit InvalidCharacterError: The string contains characters outside the Latin1 range.
Oorzaak. btoa is ontworpen in het tijdperk van ASCII / Latin-1. De functie verwacht dat elk invoerteken in één byte past (codepoints 0-255). 你好 is UTF-16 in de JS-engine met codepoints U+4F60 U+597D, beide ruim boven 255.
Fix. Encodeer eerst naar UTF-8 bytes en Base64-encodeer die bytes vervolgens.
// Wrong:
btoa("你好"); // throws
// Correct:
const bytes = new TextEncoder().encode("你好");
// Uint8Array(6) [228, 189, 160, 229, 165, 189]
const b64 = btoa(String.fromCharCode(...bytes));
// "5L2g5aW9"
Het langere verhaal staat in Wat is Base64-codering? Een beginnersgids en de Base64 in de praktijk: MIME, data-URL’s, prestaties en beveiliging; de Base64 Decoderen & Encoderen Online — Gratis Converter doet de conversie in één stap en toont de tussenliggende byte-stream.
Valkuil 5: String.length voor validatie (Twitter- / SMS-limieten)
Symptoom. Een composer voor 280 tekens valideert client-side, en daarna geeft de API 422 terug. Of omgekeerd: een prima bericht wordt door de client geweigerd.
Oorzaak. JavaScript’s .length telt UTF-16 code units; één emoji telt als 2. Twitter telt codepoints (emoji = 1). De tekentelling klopt in tegengestelde richtingen, afhankelijk van welke API je vertrouwt.
Fix. Gebruik [...text].length voor codepoint-telling, of Intl.Segmenter voor een echte grapheme-telling (de aanpak van Bluesky / iMessage). Cijfers per platform en de GSM-7 versus UCS-2 grens voor SMS zijn gecatalogiseerd in de Teken- en woordlimieten 2026 — Twitter, SMS, SEO, Instagram.
Valkuil 6: ZWJ-emoji-families tellen als N codepoints, 1 grapheme
Symptoom. "👨👩👧".length === 8. Codepoints tellen geeft 5. Voor de gebruiker is het één plaatje.
Oorzaak. Zero-Width Joiner (U+200D) plakt meerdere emoji-codepoints aan elkaar tot één gerenderde cluster: drie personen-emoji plus twee ZWJ’s is vijf codepoints, acht UTF-16 code units, één grapheme.
Fix.
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment("👨👩👧")].length; // 1
Intl.Segmenter zit in Node 22+ en elke huidige browser. Voor oudere runtimes implementeert het grapheme-splitter-pakket UAX #29.
Valkuil 7: JSON \uXXXX-escape: codepoints boven U+FFFF hebben een surrogate pair nodig
Symptoom. Een JSON-payload bevat "😀" en de ontvangende decoder rendert hem ofwel correct als 😀 ofwel toont twee vakjes, afhankelijk van of hij surrogate pairs in JSON begrijpt.
Oorzaak. JSON’s \uXXXX-escape accepteert exact vier hex-cijfers, oftewel één UTF-16 code unit. 😀 (U+1F600) encoderen vereist de surrogate pair 😀. JSON kent geen \u{...}-accolade-syntax.
Fix. Accepteer ofwel de surrogate pair (elke spec-conforme parser kan ermee omgaan) of schrijf de emoji letterlijk. JSON staat elk UTF-8 teken buiten de escape-syntax toe, en de meeste moderne parsers verkiezen die vorm.
Valkuil 8: HTTP Content-Type: charset= defaults zijn niet wat je denkt
Symptoom. Een UTF-8 HTML-pagina rendert als Mojibake in de ene browser en correct in de andere.
Oorzaak. RFC 2616 schreef oorspronkelijk ISO-8859-1 voor als default voor text/*-responses zonder expliciete charset. RFC 7231 (2014) heeft die default verwijderd en laat elke browser zelf raden. Sommige sniffen content, sommige vallen terug op UTF-8, sommige nemen de systeem-locale.
Fix. Stuur altijd Content-Type: text/html; charset=utf-8 vanaf de server en <meta charset="utf-8"> in de document-head. Elk los werkt; beide is bretels-en-riem voor oude proxies die headers strippen.
Om een van deze valkuilen live op byte-niveau te bekijken is de Base64 Decoderen & Encoderen Online — Gratis Converter de snelste microscoop: plak een string, encodeer naar Base64, en de gedecodeerde payload is de UTF-8 stream.
De juiste encoding kiezen: beslissingsmatrix
Op de vraag utf-8 vs utf-16 is het antwoord bijna altijd UTF-8. De onderstaande tabel dekt de randgevallen.
Beslissingsmatrix
| Scenario | Kies | Waarom |
|---|---|---|
| Webpagina’s, API-JSON, broncode-bestanden | UTF-8 (zonder BOM) | ASCII-compatibel, geen byte-volgorde, kleinste voor Latijnse tekst, RFC 8259 schrijft UTF-8 voor JSON voor |
| Zware CJK-opslag (Chinese DB, Japanse game-data) | UTF-8 (utf8mb4) | UTF-8 gebruikt 3 bytes per CJK-teken tegenover 2 in UTF-16, maar ASCII-overhead uit markup en JSON-keys laat UTF-8 in de praktijk toch voorop, en het omringende ecosysteem is UTF-8 |
| Windows native API, legacy Java- / C#-code | UTF-16 | Platform default; converteren bij elke API-aanroep nodigt uit tot bugs |
| Index-zware in-memory tekstverwerking | UTF-32 | Codepoint-toegang in constante tijd; alleen de moeite waard voor hot paths in parsers |
| CSV geopend in Excel op Windows | UTF-8 met BOM | Excel leest UTF-8 zonder BOM als ANSI en verminkt CJK-headers |
| Nieuw project, geen beperkingen | UTF-8 (zonder BOM) | Default voor zo goed als elke moderne stack |
Twee vuistregels
- Ga overal standaard van UTF-8 uit, tenzij een platform iets anders afdwingt. Het W3C, IETF en Unicode Consortium zijn het hierover eens.
- Converteer op de grens, niet in het midden. Decodeer bytes naar het native stringtype van je taal bij binnenkomst. Werk in je business-logica met strings, nooit met bytes. Encodeer aan de uitgang terug naar UTF-8. Die “UTF-8 sandwich” haalt vrijwel alle mojibake-bugs uit het midden van je pipeline weg.
Veelgestelde vragen
Is UTF-8 altijd achterwaarts compatibel met ASCII?
Ja. Elk geldig ASCII-bestand is bit-identiek aan zijn UTF-8 representatie. De eerste 128 codepoints (U+0000–U+007F) encoderen als één byte met het hoogste bit op nul. Legacy ASCII-only tools (vroege grep, sed, klassieke shell pipes) verwerken pure-ASCII UTF-8 bestanden zonder aanpassing. Problemen beginnen pas wanneer niet-ASCII bytes (hoogste bit gezet) de stream binnenkomen.
Moet ik een UTF-8 BOM in mijn bestanden gebruiken?
Standaard niet. HTML-, JSON-, JavaScript- en CSS-bestanden breken of geven een waarschuwing in sommige parsers wanneer er een BOM aan het begin staat. De standaarduitzondering is een CSV bedoeld voor Excel op Windows: zonder de BOM raadt Excel ANSI en verminkt het Chinese, Japanse of Koreaanse headers. Zie de BOM-beslissingsmatrix in sectie 5.
Waarom is "😀".length === 2 in JavaScript?
JavaScript-strings worden opgeslagen als UTF-16, en .length geeft het aantal code units terug, niet het aantal tekens. 😀 (U+1F600) woont in de supplementary plane en vereist een surrogate pair (twee 16-bits code units), dus .length is 2. Gebruik [..."😀"].length, Array.from("😀").length of Intl.Segmenter voor een echte telling.
Wat is het verschil tussen Unicode en UTF-8?
Unicode is de tekentabel die aan elk teken een codepoint (een getal zoals U+1F600) toekent. UTF-8 is een van meerdere encodings die die codepoints in bytes vertalen (1 tot 4 bytes per codepoint). Unicode bepaalt wat een teken is; UTF-8 bepaalt hoe het door een bestand of netwerk reist. UTF-16 en UTF-32 zijn alternatieve encodings van dezelfde Unicode-tabel.
Is utf8mb4 altijd veiliger dan utf8 in MySQL?
Ja voor nieuwe projecten. MySQL’s utf8 is de verkeerd benoemde 3-byte-beperkte variant utf8mb3, die geen enkel teken boven U+FFFF kan opslaan: dus geen emoji, veel zeldzame CJK-tekens, en geen enkel historisch schrift. utf8mb4 is volledige 4-byte UTF-8. De ene kanttekening is index-lengte: elk utf8mb4-teken kan 4 bytes innemen, dus de legacy InnoDB-indexlimiet van 767 bytes tapt unieke indexen af bij 191 tekens (opgelost door innodb_large_prefix in MySQL 5.7+ en default in 8.0).
Hoe detecteer ik de encoding van een onbekend bestand?
Gebruik file op Unix, chardet of charset-normalizer in Python, of jschardet in Node. Geen ervan is perfect; ze raden statistisch op basis van bytedistributie. UTF-8 detectie is zeer betrouwbaar dankzij het vervolg-byte-patroon. Windows-1252, ISO-8859-1 en andere single-byte legacy-encodings zijn vrijwel niet van elkaar te onderscheiden, waardoor detectie vaak neerkomt op heuristieken voor de taal.
Kan UTF-16 elk Unicode-teken representeren?
Ja. UTF-16 dekt alle 1.114.112 codepoints. BMP-tekens (U+0000–U+FFFF) gebruiken één 16-bits code unit (2 bytes), en supplementary plane-tekens (U+10000–U+10FFFF) gebruiken surrogate pairs (4 bytes). De dekking is identiek aan UTF-8 en UTF-32; alleen de byte-layout en verwerkingssemantiek verschillen. De keuze ertussen gaat over passendheid bij het ecosysteem, niet over capaciteit.