Skip to content
Zurück zum Blog
Tutorials

UTF-8 vs UTF-16 vs Unicode — Encoding-Guide für Entwickler

UTF-8, UTF-16 und UTF-32 für Entwickler erklärt — Codepoints, Surrogate Pairs, BOM, MySQL utf8mb4-Fallen. Erfahren Sie, wie Sie das richtige Encoding wählen.

12 Min. Lesezeit

UTF-8 vs UTF-16 vs Unicode — Encoding-Guide für Entwickler

Die Kurzantwort, nach der die meisten Suchen zu utf-8 unicode encoding in Wahrheit fragen: Unicode und UTF-8 sind nicht dasselbe. Unicode ist eine durchnummerierte Tabelle, die jedem Zeichen einen Codepoint zuweist — eine Nummer wie U+1F600. UTF-8, UTF-16 und UTF-32 sind dagegen Encodings, also drei verschiedene Verfahren, um diese Codepoints in Bytes zu verwandeln. UTF-8 ist fast immer die richtige Wahl: Für englischen Text bleibt es byteweise identisch mit ASCII, skaliert auf vier Bytes pro Emoji und ist von JSON, HTML5 sowie den meisten modernen Protokollen vorgeschrieben.

Dieser Guide richtet sich an Entwickler, die schon einmal davon gebissen wurden: vom MySQL-Fehler Incorrect string value bei einem 😀, von der JavaScript-Überraschung "😀".length === 2, von der CSV-Datei, die in cat sauber aussieht und in Excel zu Zeichensalat wird. Wir gehen von Codepoints aus, danach durch die UTF-8-Bytemechanik, Surrogate Pairs, BOMs, das Standardverhalten in neun Programmiersprachen und acht Produktionsfallen. Am Ende stehen eine Entscheidungsmatrix und ein FAQ.

Möchten Sie eine Bytesequenz direkt beim Lesen prüfen? Fügen Sie eine beliebige Zeichenkette in den Base64-Dekodierer/Kodierer ein — die dekodierte Payload ist genau jener UTF-8-Bytestrom, den dieser Artikel erklärt.

Warum Encoding Sie auch 2026 noch beißt

Drei Szenarien, alle aus echten Bug-Trackern der letzten zwölf Monate:

  1. MySQL lehnt ein Emoji ab. Ein Nutzer schickt Hello 😀 ab, und der Server antwortet mit Incorrect string value: '\xF0\x9F\x98\x80'. Die Tabelle ist utf8, der Entwickler denkt „Das ist doch UTF-8, wo liegt das Problem?” — und die Antwort liegt tief in der MySQL-Geschichte vergraben (Abschnitt 7).
  2. Ein Zeichenzähler bricht in Produktion. Ein 280-Zeichen-Tweet-Validator nutzt text.length, akzeptiert eine emoji-lastige Nachricht, und die API weist sie ab. Auch der umgekehrte Fall kommt vor: Ein vollständig gültiger Post wird vom Frontend abgelehnt. Die Diagnose folgt in Abschnitt 4.
  3. Lokale HTML wird zu „中文”. Ein Entwickler speichert eine Datei in Windows-1252, öffnet sie in einem Browser, der UTF-8 errät, und sieht Mojibake erblühen. Das ist die BOM- und Charset-Deklarationsgeschichte aus Abschnitt 5, mit Parallelen zum URL-Encoding- und -Decoding-Guide, wo dieselbe Byte-vs-Zeichen-Verwechslung Query-Strings zertrümmert.

Am Ende dieses Texts werden Sie (a) Unicode und UTF-8 in einem Satz voneinander unterscheiden, (b) für jedes neue Projekt zwischen UTF-8, UTF-16 und UTF-32 wählen können, (c) Code schreiben, der Emojis in allen wichtigen Sprachen korrekt zählt, und (d) jeden Charset-Bug allein aus dem Bytestrom heraus debuggen. Das Kaninchenloch der Zeichenkodierung reicht tief, die praktisch relevante Oberfläche bleibt aber überschaubar.

Was ist Unicode? Codepoints vs Zeichen vs Glyphen

Unicode ist eine Zeichentabelle, die jedem Zeichen eine eindeutige Nummer zuweist — einen Codepoint wie U+1F600. UTF-8, UTF-16 und UTF-32 sind Encodings, die diese Codepoints in Bytes übersetzen. Unicode selbst speichert keine Bytes; es definiert nur die Abbildung vom abstrakten Zeichen zur ganzen Zahl.

Drei weitere Begriffe trüben die Diskussion, weil sie oft dasselbe sichtbare Symbol meinen:

Drei Ebenen, die Sie sauber trennen müssen

  • Codepoint (U+0041, U+1F600): die ganze Zahl, die Unicode zuweist. Der Raum reicht von U+0000 bis U+10FFFF, etwa 1,1 Millionen Slots, von denen aktuell rund 150.000 belegt sind.
  • Zeichen (oder abstraktes Zeichen): die semantische Identität — lateinischer Großbuchstabe A, grinsendes Gesicht.
  • Glyphe: die visuelle Form, die eine Schrift rendert. Ein Zeichen hat viele Glyphen: ein Serifen-A, ein kursives A, ein handgezeichnetes A. Glyphen interessieren Unicode nicht.
  • Graphem-Cluster: was der Nutzer als einzelnes „Zeichen” wahrnimmt. Oft ein Codepoint, manchmal mehrere. Das Zeichen á kann ein einzelner Codepoint U+00E1 sein oder zwei Codepoints a + U+0301 (kombinierender Akut). Der Zeichen- und Wortlimits-Guide zeigt, wie Twitter, SMS und SEO diese Grenze jeweils unterschiedlich ziehen.

Eine Kette zum Mitnehmen: Codepoint → Encoding → Bytes → Rendering. Jeder Pfeil kann für sich allein brechen.

Codepoint-Notation — U+XXXX und \uXXXX

Codepoints begegnen Ihnen in mehreren Geschmacksrichtungen. U+0041 ist die kanonische Unicode-Notation: vier bis sechs Hex-Ziffern, vorangestellt U+. Im Quellcode:

  • JavaScript / JSON: "A" (vier Hex-Ziffern, nur BMP) und "\u{1F600}" (ES6-Klammern, beliebiger Codepoint).
  • Python: "A" (4 Ziffern), "\U00000041" (8 Ziffern, großes U), "\N{LATIN CAPITAL LETTER A}" (über den Namen).
  • Shell / git log / sed-Ausgabe: Hier sieht man häufig rohe UTF-8-Bytes wie \xc3\xa9 für é. Das ist kein Codepoint, sondern die kodierte Form — und das führt direkt zu Abschnitt 3.

Die 17 Planes — BMP und darüber hinaus

Unicode unterteilt seinen Codepoint-Raum in 17 Planes zu je 65.536 Codepoints — 17 × 2^16 = 1.114.112.

  • Plane 0, die Basic Multilingual Plane (BMP): U+0000 bis U+FFFF. Latein, CJK-Ideogramme, Kyrillisch, Arabisch, Griechisch — fast jede Schrift, der Sie in Bestandstext begegnen, lebt hier.
  • Planes 1-16, die Supplementary Planes: U+10000 bis U+10FFFF. Die meisten Emojis (U+1F600 und Verwandte), seltene CJK-Zeichen, historische Schriften (ägyptische Hieroglyphen, Keilschrift), Musiknotation.

Die BMP-/Supplementary-Grenze bei U+FFFF ist die wichtigste einzelne Zahl dieses Artikels. Dort hört UTF-16 auf, eine Code Unit pro Zeichen zu sein, dort springt UTF-8 von drei auf vier Bytes — und dort gibt MySQLs irreführend benannte utf8-Kollation auf.

Schneller Sanity-Check mit 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

Die letzte Zeile ist der Knackpunkt. Das Familien-Emoji ist für den Nutzer ein einzelnes Zeichen, besteht aber aus fünf Codepoints, die durch Zero-Width Joiner zusammengehalten werden. Jede Schicht des Stacks kann das anders zählen — und Abschnitt 7, Falle 6, ist der Bug-Report, den dieser Streit erzeugt.

UTF-8-Encoding-Mechanik — Wie 1 bis 4 Bytes funktionieren

UTF-8 kodiert Unicode-Codepoints in 1 bis 4 Bytes. ASCII (U+0000U+007F) verbraucht 1 Byte und bleibt byteweise identisch mit ASCII. Höhere Codepoints verwenden Multi-Byte-Sequenzen, bei denen das erste Byte die Gesamtlänge signalisiert und jedes Fortsetzungsbyte mit dem Bitmuster 10xxxxxx beginnt. Dieses selbstbeschreibende Layout ist der Grund, warum UTF-8 die Encoding-Wars gewonnen hat.

Die Byte-Muster-Tabelle — UTF-8 in einem Diagramm

Codepoint-BereichUTF-8-BytesBytemuster
U+0000U+007F1 Byte0xxxxxxx
U+0080U+07FF2 Bytes110xxxxx 10xxxxxx
U+0800U+FFFF3 Bytes1110xxxx 10xxxxxx 10xxxxxx
U+10000U+10FFFF4 Bytes11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Jedes x ist ein Datenbit aus der Binärrepräsentation des Codepoints. Das führende 0 / 110 / 1110 / 11110 verrät dem Decoder die Gesamtzahl der Bytes; das führende 10 markiert jedes Fortsetzungsbyte. Diese Redundanz macht UTF-8 selbstsynchronisierend: Fällt ein Byte aus, setzt der Decoder am nächsten Startbyte wieder auf, statt alles Folgende zu zerstören.

Durchgerechnetes Beispiel — Kodierung von (U+4E2D)

Codepoint 0x4E2D fällt in U+0800U+FFFF, also greift das 3-Byte-Template.

  1. Binär: 0x4E2D = 0100 1110 0010 1101 (16 Bit).
  2. In 4-6-6 splitten, damit es in die x-Slots passt: 0100 / 111000 / 101101.
  3. In 1110xxxx 10xxxxxx 10xxxxxx einsetzen: 11100100 10111000 10101101.
  4. Hex: 0xE4 0xB8 0xAD.

Genau aus diesem Grund wird nach URL-Kodierung zu %E4%B8%AD: Percent-Encoding verpackt jedes UTF-8-Byte einzeln in %XX und kodiert nicht etwa den Codepoint direkt. Abschnitt 7, Falle 3, beschreibt die ganze Kette.

Durchgerechnetes Beispiel — Kodierung von 😀 (U+1F600)

Codepoint 0x1F600 liegt jenseits der BMP, also greift das 4-Byte-Template.

  1. Binär: 0x1F600 = 0 0001 1111 0110 0000 0000 (21 Bit, aufgefüllt).
  2. In 3-6-6-6 splitten: 000 / 011111 / 011000 / 000000.
  3. In 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx einsetzen: 11110000 10011111 10011000 10000000.
  4. Hex: 0xF0 0x9F 0x98 0x80.

An diesen vier Bytes verschluckt sich MySQLs utf8-Kollation, denn sie reserviert maximal drei Bytes pro Zeichen. Abschnitt 7, Falle 1, liefert die Lösung.

Warum UTF-8 gewonnen hat

  1. ASCII-Kompatibilität. Eine Datei mit reinem ASCII-Text ist auf Byte-Ebene identisch mit ihrer UTF-8-Kodierung. Jahrzehntealte Werkzeuge, die älter sind als Unicode — grep, awk, klassische Shell-Pipes — funktionieren für diese Teilmenge unverändert weiter.
  2. Selbstsynchronisierend. Fortsetzungsbytes beginnen immer mit 10 und kollidieren nie mit einem Startbyte. Geht ein Byte beim Netzwerktransfer verloren, synchronisiert der Decoder am nächsten Zeichenrand wieder, statt eine Kaskade aus Müll zu produzieren.
  3. Keine Byte-Order. UTF-8 ist ein Bytestrom, keine 16- oder 32-Bit-Einheit, daher spielt die Endianness keine Rolle. UTF-16 und UTF-32 brauchen eine Byte Order Mark zur Deklaration, welches Ende zuerst kommt; UTF-8 nicht (und meist sollte es das auch lassen — siehe Abschnitt 5).

Ungültiges UTF-8 — was die Spezifikation verbietet

Ein strikter Decoder weist diese Byte-Sequenzen zurück:

  • 5- oder 6-Byte-Sequenzen. Frühe RFCs erlaubten sie; RFC 3629 (2003) deckelte UTF-8 auf 4 Bytes, passend zum 21-Bit-Unicode-Raum.
  • Overlong Encodings. / als drei Bytes 0xE0 0x80 0xAF zu kodieren statt als ein Byte 0x2F. Einst eine ergiebige Quelle für Directory-Traversal-Exploits in Pfadvalidierern, die nach der Sanitärung dekodierten.
  • Einzelne Surrogate-Codepoints (U+D800U+DFFF). Diese gehören UTF-16 vorbehalten und sollten in UTF-8 nie auftauchen.
  • Abgeschnittene Sequenzen. Ein 3-Byte-Startbyte gefolgt von nur einem Fortsetzungsbyte — typisch, wenn Nutzereingaben mitten in einem Multi-Byte-Zeichen auf einer Byte-Grenze gekappt werden.

Um all das konkret zu sehen, geben Sie eine Zeichenkette in den Base64-Dekodierer/Kodierer ein, kodieren sie und dekodieren sie wieder als Bytes — das Byte-Array zwischen Encoder und Decoder ist der UTF-8-Strom, den dieser Abschnitt beschreibt.

UTF-16 und Surrogate Pairs — Warum JavaScripts length lügt

Die häufigste Suche rund um utf-8 vs utf-16 lautet in Wahrheit „Warum ergibt "😀".length in meinem Code 2?” Die Antwort heißt Surrogate Pairs, eine Designentscheidung aus den 1990ern, die JavaScript, Java, C# und Windows allesamt geerbt haben.

UTF-16 in einem Absatz

UTF-16 repräsentiert Unicode mittels 16-Bit-Code-Units. Zeichen in der BMP (U+0000U+FFFF) belegen genau eine Code Unit. Zeichen in den Supplementary Planes (U+10000U+10FFFF) belegen zwei Code Units, ein sogenanntes Surrogate Pair: ein High Surrogate aus U+D800U+DBFF, gefolgt von einem Low Surrogate aus U+DC00U+DFFF. Den Block U+D800U+DFFF hat Unicode permanent reserviert, dort existiert also kein echtes Zeichen. UTF-16 ist das interne String-Format von JavaScript, Java, C# (.NET), Windows-Kernel-APIs, Objective-C NSString und Qt — alle entworfen, als 65.536 Zeichen reichlich aussahen.

Die String.length-Falle

"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 meldet die Anzahl der UTF-16-Code-Units, nicht die Anzahl der Zeichen. Alles aus den Supplementary Planes liest sich als 2. Dieselbe Falle gibt es in Java mit String.length() und in C# mit string.Length.

Codepoints in JavaScript korrekt zählen

[..."😀"].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

Spread-Operator und Array.from nutzen das Iterator-Protokoll, das die Sprachspezifikation als codepoint-weise definiert. Einfacher Indexzugriff (str[0], charAt) liefert weiterhin Code Units zurück und reicht Ihnen bei Emojis die Hälfte eines Surrogate Pairs.

Python — len() macht es bereits richtig (fast)

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 speichert Strings in einer flexiblen 1-, 2- oder 4-Byte-Repräsentation (PEP 393) und indiziert sie nach Codepoints. len("😀") ergibt 1, ist aber dennoch nicht der Graphem-Count — das Familien-Emoji liest sich weiterhin als 5. Wer die vom Nutzer wahrgenommenen Zeichen zählen will, braucht eine Graphem-Bibliothek: Intl.Segmenter in JavaScript (Node 22+, alle aktuellen Browser), grapheme oder regex in Python — oder einfach Swift, dessen String.count als einzige Mainstream-Sprache standardmäßig Grapheme zählt.

UTF-16 vs UCS-2 — die stille Migration

Vor 1996 versprach Unicode, in 16 Bit zu passen, und das zugehörige Encoding hieß UCS-2: eine feste 2-Byte-Abbildung. Unicode 2.0 brach dieses Versprechen mit den Supplementary Planes. UTF-16 ist die geflickte Fassung mit Surrogate Pairs. Die JavaScript-Spezifikation zitiert an einigen Stellen noch das alte UCS-2-Vokabular, weshalb die Sprache einzelne Surrogates toleriert, die eigentlich illegal sein sollten — die „WTF-16”-Witze haben einen wahren Kern. Web-Plattform-APIs (DOM, fetch, TextEncoder) weisen einzelne Surrogates zurück, weil sie nicht in gültiges UTF-8 kodierbar sind.

UTF-32, BOM und die Frage der Byte-Order

UTF-32 — die einfache, verschwenderische Variante

UTF-32 nutzt feste 4 Bytes pro Codepoint. U+0041 steht als 0x00000041 im Speicher, U+1F600 als 0x0001F600. Der Vorteil ist konstanter Random-Access: Der n-te Codepoint sitzt am Byte-Offset 4n. Der Nachteil ist die Größe — reiner ASCII-Text bläht sich auf das Vierfache seines UTF-8-Fußabdrucks auf, und selbst CJK-Text verdoppelt sich. Kaum ein System speichert UTF-32 auf Disk. Intern wählt Python 3 1, 2 oder 4 Bytes pro String, basierend auf dem höchsten Codepoint; der Linux-Fontconfig-Stack nutzt UTF-32 für seine In-Memory-Glyphen-Tabellen.

Byte-Order — warum Endianness für UTF-16 / UTF-32 zählt

UTF-8 ist ein Strom einzelner Bytes, Endianness greift also nicht. UTF-16 und UTF-32 arbeiten mit Multi-Byte-Einheiten, und verschiedene CPUs sind sich uneinig, welches Ende einer Zahl zuerst kommt.

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

x86- und ARM-CPUs sind Little-Endian; ältere PowerPC und die „Network Byte Order” sind Big-Endian. Wer eine UTF-16-Datei schreibt, muss sich auf eine Variante festlegen und dem Leser mitteilen, welche es ist — genau dafür gibt es die BOM.

Die BOM — was sie ist, wann sie zum Einsatz kommt

Eine Byte Order Mark ist U+FEFF, platziert am Dateianfang. Kodiert verkündet sie sowohl das Encoding als auch (für UTF-16 / UTF-32) die Byte-Order.

EncodingBOM-Bytes
UTF-8EF BB BF
UTF-16 BEFE FF
UTF-16 LEFF FE
UTF-32 BE00 00 FE FF
UTF-32 LEFF FE 00 00

Die UTF-8-BOM existiert, trägt aber keine Byte-Order-Information, weil UTF-8 keine Byte-Order hat. Ihre einzige Aufgabe ist die Erklärung „Diese Datei ist UTF-8” — hilfreich für Tools ohne anderes Signal, schädlich für Tools, die am Dateianfang eine Magic Number oder Direktive erwarten.

BOM-Entscheidungsmatrix — soll ich sie hinzufügen?

FormatUTF-8-BOMUTF-16-BOMUTF-32-BOM
HTMLNein (bricht die <!doctype>-Erkennung in alten Parsern)
JSONNein (RFC 8259 verbietet sie)
JavaScript- / CSS-QuellcodeVermeiden (ältere Node-Versionen und IE verschlucken sich)
CSV, geöffnet in ExcelJa (Excel liest BOM-loses UTF-8 als ANSI und verstümmelt CJK)
XMLOptional (XML-Deklaration nennt das Encoding bereits)ErforderlichErforderlich
Klartext .txtOptional (Windows Notepad fügt sie standardmäßig hinzu)ErforderlichErforderlich

Faustregel: Die UTF-8-BOM aus allem entfernen, was im Web ausgeliefert wird; sie zu CSVs hinzufügen, die Excel öffnen soll; und sonst den Leser entscheiden lassen.

9 Sprachen im direkten Vergleich — Standardverhalten beim Encoding

Sprachübergreifende Arbeit ist der Punkt, an dem sich dieses Wissen auszahlt. Dieselbe Zeichenkette "a😀é" ergibt in jeder Laufzeit, die Sie aus Ihrem Bash-Skript aufrufen, eine andere Länge.

Tabelle des sprachübergreifenden Verhaltens

SpracheQuelldatei-EncodingString-Speicherunglength / len zähltStandard-I/O-Encoding4-Byte-Emoji sicher?
JavaScript (V8 / SpiderMonkey)UTF-8UTF-16UTF-16-Code-UnitsUTF-8 (Node, Web)Ja, aber .length === 2
Python 3UTF-8 (PEP 3120)dynamisch 1 / 2 / 4 Byte (PEP 393)CodepointsUTF-8 (PEP 540 seit 3.7)Ja, len === 1
JavaUTF-8 (javac-Default)UTF-16UTF-16-Code-UnitsPlattform-Charset → UTF-8 (JEP 400, JDK 18+)Ja, aber .length() === 2
GoUTF-8UTF-8-BytesBytes (utf8.RuneCountInString für Codepoints)UTF-8Ja, len(s) liefert Bytes
RustUTF-8UTF-8-Bytes (String-Invariante).len() Bytes, .chars().count() CodepointsUTF-8Ja, explizit
C# (.NET)UTF-8 (Default seit .NET Core 3.0)UTF-16UTF-16-Code-UnitsUTF-8 (Encoding.Default seit .NET 5)Ja, aber .Length === 2
RubyUTF-8 (seit 2.0)Encoding-Tag pro StringCodepoints (.length)UTF-8Ja, length === 1
PHP(kein Source-Encoding)Byte-StringBytes (strlen); mb_strlen für Codepointsabhängig von default_charsetJa, mit mb_*-Familie
MySQLSpalten-CharsetBytes (LENGTH), Zeichen (CHAR_LENGTH)character_set_*-SystemvariablenNur mit utf8mb4

Was die Tabelle aussagt

Drei Philosophien, drei Sätze von Bugs:

  • UTF-8 intern (Go, Rust, Ruby). Der native String besteht aus Bytes; length ist wohldefiniert, zählt aber, was es zählt. Konvertieren Sie zu Codepoints oder Graphemen erst dann, wenn Sie eine UI- oder Validierungsgrenze überschreiten.
  • UTF-16 intern (JavaScript, Java, C#). Erbe der 1990er-Annahmen; length sind Code Units, ein Surrogate Pair zählt als 2. Verwenden Sie für jeden nutzerseitigen Count eine codepoint-bewusste Iteration.
  • Codepoint-indiziert (Python 3). len liefert Codepoints, was sich richtig anfühlt — bis ZWJ-Emojis auftauchen, dann brauchen Sie wieder eine Graphem-Bibliothek.

PHP ist der Sonderfall. Seine eingebauten str*-Funktionen operieren ausnahmslos auf Bytes und behandeln UTF-8-Sequenzen als undurchsichtige Blobs. Jedes nicht-ASCII-Projekt muss die mb_*-Familie (Multibyte) einsetzen — die Bug-Reports Jahr für Jahr zeigen, wie oft das übersehen wird.

Die praktische Leitlinie: UTF-8 als Wire Format überall einsetzen — Dateien, HTTP-Bodies, Datenbankspalten — und am Rand zum nativen String-Typ Ihrer Laufzeit konvertieren. Dieses „UTF-8-Sandwich” greifen wir in Abschnitt 8 wieder auf.

8 Engineering-Fallen aus der Praxis

Die folgenden Muster begegnen einem in jedem Code-Review einer globalisierten Codebasis.

Falle 1: MySQLs utf8 ist eine 3-Byte-Lüge — wechseln Sie auf utf8mb4

Symptom. INSERT INTO users (bio) VALUES ('Hello 😀'); liefert Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio'.

Ursache. MySQLs historisches utf8 ist ein Alias für utf8mb3: eine UTF-8-Variante, gedeckelt auf drei Bytes pro Zeichen. Jeder Codepoint oberhalb von U+FFFF (sämtliche Emojis, mehrere tausend seltene CJK-Zeichen, alle historischen Schriften) benötigt vier UTF-8-Bytes und wird abgelehnt.

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 liefert utf8 weiterhin als Alias für utf8mb3 aus. utf8mb3 ist deprecated, aber noch nicht entfernt. Verwenden Sie utf8mb4 für jede neue Spalte, jede neue Datenbank, jede neue Verbindung — die Legacy-Variante hat keinen Vorteil.

Falle 2: Windows-1252-Fallback — das Mysterium der Fragezeichen

Symptom. Eine .txt-Datei, von einem Windows-Kollegen aus Notepad exportiert, zeigt bei ihm "smart quotes" und einen Geviertstrich. Auf Ihrem Server wird daraus ? oder U+FFFD (Ersatzzeichen).

Ursache. Älteres Notepad nutzt standardmäßig Windows-1252 (CP-1252) und kodiert das geschwungene Anführungszeichen " als 0x93. Ein UTF-8-Decoder sieht 0x93 als verirrtes Fortsetzungsbyte (oberstes Bit 10) ohne vorangehendes Startbyte und ersetzt es durch das Ersatzzeichen.

Fix. Das Quell-Encoding erkennen (file auf Unix, chardet / charset-normalizer in Python, jschardet in Node), mit dem passenden Codec dekodieren, dann vor dem Speichern als UTF-8 neu kodieren. Eine Standardisierung auf UTF-8 bereits beim Ingest verhindert das Wiederauftreten.

Falle 3: URL-Percent-Encoding ≠ UTF-8 (baut aber darauf auf)

Symptom. fetch("/search?q=中文") liefert von einem Backend-Framework einen 404 und vom nächsten ein korrektes Ergebnis.

Ursache. Percent-Encoding arbeitet auf Bytes, nicht auf Codepoints. ist ein Codepoint, aber drei UTF-8-Bytes (E4 B8 AD), jedes einzeln als %E4%B8%AD percent-kodiert — neun ASCII-Zeichen in der URL. Ein Framework, das die URL als Latin-1 statt UTF-8 dekodiert, übergibt dem Handler die drei verstümmelten Bytes als drei Single-Byte-Zeichen.

Fix. Auf Clientseite encodeURIComponent("中文") verwenden (Browser erledigen UTF-8 + Percent-Encoding in einem Schritt) und prüfen, dass das Server-Framework URLs als UTF-8 dekodiert (alle modernen Frameworks tun das standardmäßig). Zur visuellen Bestätigung 中文 in den URL-Dekodierer/Kodierer einfügen und zusehen, wie daraus %E4%B8%AD%E6%96%87 wird. Die vollständige Kette behandelt der URL-Encoding- und -Decoding-Guide.

Falle 4: Base64-Input besteht aus Bytes, aber Sie haben einen String getippt

Symptom. btoa("你好") wirft InvalidCharacterError: The string contains characters outside the Latin1 range.

Ursache. btoa entstand in der ASCII-/Latin-1-Ära. Es erwartet, dass jedes Eingabezeichen in ein einzelnes Byte passt (Codepoints 0-255). 你好 liegt als UTF-16 in der JS-Engine vor, mit den Codepoints U+4F60 U+597D — beide weit über 255.

Fix. Zuerst in UTF-8-Bytes kodieren, dann diese Bytes Base64-kodieren.

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

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

Die längere Geschichte erzählen Was ist Base64-Kodierung? und Base64 für Fortgeschrittene; der Base64-Dekodierer/Kodierer erledigt die Konvertierung in einem Schritt und zeigt den dazwischenliegenden Bytestrom.

Falle 5: String.length zur Validierung (Twitter- / SMS-Limits)

Symptom. Ein 280-Zeichen-Composer validiert clientseitig, anschließend liefert die API ein 422. Oder umgekehrt — ein vollkommen gültiger Post wird vom Client abgewiesen.

Ursache. JavaScripts .length zählt UTF-16-Code-Units; ein einzelnes Emoji zählt als 2. Twitter zählt Codepoints (Emoji = 1). Der Zeichen-Count irrt in entgegengesetzte Richtungen, je nachdem, welcher API Sie vertrauen.

Fix. [...text].length für den Codepoint-Count oder Intl.Segmenter für den echten Graphem-Count verwenden (der Bluesky-/iMessage-Ansatz). Plattformspezifische Zahlen und die Grenze zwischen SMS-GSM-7 und UCS-2 katalogisiert der Zeichen- und Wortlimits-Guide.

Falle 6: ZWJ-Emoji-Familien zählen als N Codepoints, 1 Graphem

Symptom. "👨‍👩‍👧".length === 8. Das Zählen der Codepoints ergibt 5. Für den Nutzer ist es ein einziges Bild.

Ursache. Zero-Width Joiner (U+200D) verklebt mehrere Emoji-Codepoints zu einem gerenderten Cluster — drei Personen-Emojis plus zwei ZWJs ergibt fünf Codepoints, acht UTF-16-Code-Units, ein Graphem.

Fix.

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

Intl.Segmenter ist in Node 22+ und jedem aktuellen Browser verfügbar. Für ältere Laufzeiten implementiert das Paket grapheme-splitter UAX #29.

Falle 7: JSON-\uXXXX-Escape — Codepoints oberhalb von U+FFFF brauchen ein Surrogate Pair

Symptom. Eine JSON-Payload enthält "😀", und der empfangende Decoder rendert es entweder korrekt als 😀 oder zeigt zwei Boxen — je nachdem, ob er Surrogate Pairs in JSON versteht.

Ursache. JSONs \uXXXX-Escape akzeptiert genau vier Hex-Ziffern, also eine UTF-16-Code-Unit. Die Kodierung von 😀 (U+1F600) erfordert das Surrogate Pair 😀. Eine \u{...}-Klammersyntax kennt JSON nicht.

Fix. Entweder das Surrogate Pair akzeptieren (jeder spezifikationskonforme Parser kann das) oder das Emoji literal hinschreiben — JSON erlaubt jedes UTF-8-Zeichen außerhalb der Escape-Syntax, und die meisten modernen Parser bevorzugen diese Form.

Falle 8: HTTP-Content-Type: charset=-Defaults sind nicht das, was Sie denken

Symptom. Eine UTF-8-HTML-Seite rendert in einem Browser als Mojibake, im anderen korrekt.

Ursache. RFC 2616 schrieb ursprünglich ISO-8859-1 als Default für text/*-Antworten ohne explizites Charset vor. RFC 7231 (2014) hat diesen Default entfernt und überlässt jedem Browser das Raten. Manche schnüffeln am Inhalt, manche fallen auf UTF-8 zurück, manche nehmen das System-Locale.

Fix. Vom Server immer Content-Type: text/html; charset=utf-8 senden und <meta charset="utf-8"> im Dokument-Head setzen. Beides alleine genügt; beides gemeinsam ist Gürtel und Hosenträger für Legacy-Proxies, die Header strippen.

Um jede dieser Fallen live auf Byte-Ebene zu beobachten, ist der Base64-Dekodierer/Kodierer das schnellste Mikroskop: Zeichenkette einfügen, in Base64 kodieren — die dekodierte Payload ist der UTF-8-Strom.

Das richtige Encoding wählen — Entscheidungsmatrix

Für die Frage utf-8 vs utf-16 lautet die Antwort fast immer UTF-8. Die Tabelle deckt die Randfälle ab.

Entscheidungsmatrix

SzenarioWahlWarum
Webseiten, API-JSON, QuelldateienUTF-8 (ohne BOM)ASCII-kompatibel, keine Byte-Order, am kompaktesten für lateinischen Text, RFC 8259 schreibt UTF-8 für JSON vor
CJK-lastige Speicherung (chinesische DB, japanische Game-Daten)UTF-8 (utf8mb4)UTF-8 verbraucht 3 Bytes pro CJK-Zeichen gegenüber 2 in UTF-16, doch der ASCII-Overhead aus Markup und JSON-Keys lässt UTF-8 in der Praxis vorn — und das umliegende Ökosystem ist UTF-8
Native Windows-APIs, Legacy-Java-/-C#-CodeUTF-16Plattform-Default; an jedem API-Aufruf zu konvertieren lädt Bugs ein
Indexlastige In-Memory-TextverarbeitungUTF-32Konstanter Codepoint-Zugriff; nur für die heißen Pfade von Parsern lohnenswert
CSV, geöffnet in Excel unter WindowsUTF-8 mit BOMExcel liest BOM-loses UTF-8 als ANSI und verstümmelt CJK-Header
Neues Projekt, keine VorgabenUTF-8 (ohne BOM)Die Encoding-Wars sind entschieden vorbei

Zwei Daumenregeln

  1. Standardmäßig überall UTF-8, wenn keine Plattform es anders erzwingt. W3C, IETF und Unicode-Konsortium sind sich einig.
  2. Am Rand konvertieren, nicht in der Mitte. Bytes beim Ingest in den nativen String-Typ Ihrer Sprache dekodieren. In der Geschäftslogik immer auf Strings operieren, nie auf Bytes. Beim Output wieder zu UTF-8 kodieren. Dieses „UTF-8-Sandwich” eliminiert die gesamte Klasse von Mojibake-Bugs in der Pipeline-Mitte.

Häufig gestellte Fragen

Ist UTF-8 immer abwärtskompatibel zu ASCII?

Ja. Jede gültige ASCII-Datei ist bitweise identisch mit ihrer UTF-8-Darstellung. Die ersten 128 Codepoints (U+0000U+007F) kodieren sich als einzelnes Byte mit gelöschtem oberstem Bit. Klassische ASCII-Tools — frühe grep, sed, alte Shell-Pipes — verarbeiten reine ASCII-UTF-8-Dateien unverändert. Probleme beginnen erst, wenn Nicht-ASCII-Bytes (oberstes Bit gesetzt) in den Strom geraten.

Soll ich die UTF-8-BOM in meinen Dateien verwenden?

Standard: nein. HTML-, JSON-, JavaScript- und CSS-Dateien brechen oder warnen in manchen Parsern, sobald am Anfang eine BOM steht. Die Standardausnahme ist CSV für Excel unter Windows — ohne BOM rät Excel ANSI und verstümmelt chinesische, japanische oder koreanische Header. Siehe die BOM-Entscheidungsmatrix in Abschnitt 5.

Warum ergibt "😀".length === 2 in JavaScript?

JavaScript speichert Strings als UTF-16, und .length gibt die Anzahl der Code Units zurück, nicht der Zeichen. 😀 (U+1F600) lebt in der Supplementary Plane und benötigt ein Surrogate Pair — zwei 16-Bit-Code-Units — also liefert .length 2. Verwenden Sie [..."😀"].length, Array.from("😀").length oder Intl.Segmenter für einen echten Count.

Worin unterscheiden sich Unicode und UTF-8?

Unicode ist die Zeichentabelle, die jedem Zeichen einen Codepoint zuweist (eine Nummer wie U+1F600). UTF-8 ist eines von mehreren Encodings, das diese Codepoints in Bytes übersetzt (1 bis 4 Bytes pro Codepoint). Unicode definiert, was ein Zeichen ist; UTF-8 definiert, wie es durch Datei oder Netzwerk reist. UTF-16 und UTF-32 sind alternative Encodings derselben Unicode-Tabelle.

Ist utf8mb4 in MySQL immer sicherer als utf8?

Ja, für neue Projekte. MySQLs utf8 ist die irreführend benannte 3-Byte-begrenzte Variante utf8mb3, die kein Zeichen oberhalb von U+FFFF speichern kann — sämtliche Emojis, viele seltene CJK-Zeichen, alle historischen Schriften. utf8mb4 ist volles 4-Byte-UTF-8. Der einzige Vorbehalt betrifft die Indexlänge: Jedes utf8mb4-Zeichen kann 4 Bytes belegen, sodass das alte InnoDB-Limit von 767 Bytes Unique-Indexe auf 191 Zeichen deckelt (gelöst durch innodb_large_prefix ab MySQL 5.7 und Default in 8.0).

Wie erkenne ich das Encoding einer unbekannten Datei?

Mit file auf Unix, chardet oder charset-normalizer in Python, oder jschardet in Node. Keines ist perfekt — sie raten statistisch aus der Byte-Verteilung. UTF-8-Erkennung ist dank des Fortsetzungsbyte-Musters hochgradig zuverlässig. Windows-1252, ISO-8859-1 und andere Single-Byte-Legacy-Encodings sind voneinander kaum zu unterscheiden, sodass die Erkennung oft auf sprachliche Heuristiken hinausläuft.

Kann UTF-16 jedes Unicode-Zeichen darstellen?

Ja. UTF-16 deckt alle 1.114.112 Codepoints ab. BMP-Zeichen (U+0000U+FFFF) belegen eine 16-Bit-Code-Unit (2 Bytes), und Zeichen der Supplementary Planes (U+10000U+10FFFF) nutzen Surrogate Pairs (4 Bytes). Die Abdeckung ist identisch mit UTF-8 und UTF-32; nur das Byte-Layout und die Verarbeitungssemantik unterscheiden sich. Die Wahl zwischen ihnen ist eine Frage des Ökosystem-Fits, nicht der Fähigkeit.

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

Verwandte Artikel

Alle Artikel anzeigen