UTF-8 vs UTF-16 vs Unicode — przewodnik dla programistów
Pod hasłem utf-8 unicode encoding kryje się zwykle pytanie, czy Unicode i UTF-8 to to samo. Nie są. Unicode to numerowana tablica, która przypisuje code point (liczbę typu U+1F600) do każdego znaku. UTF-8, UTF-16 i UTF-32 są kodowaniami: sposobami zamiany code pointów na bajty. Z tej trójki UTF-8 sprawdza się w większości zastosowań: dla tekstu angielskiego jest bajt w bajt identyczny z ASCII, skaluje się do czterech bajtów dla każdego emoji i obowiązuje w JSON, HTML5 oraz większości aktualnych protokołów.
Ten przewodnik powstał z myślą o deweloperach, którzy już oberwali: błąd Incorrect string value w MySQL na 😀, javascriptowa niespodzianka "😀".length === 2, CSV poprawnie czytany przez cat, lecz zniekształcony w Excelu. W kolejnych sekcjach omawiamy code pointy, mechanikę bajtową UTF-8, pary zastępcze, BOM-y, domyślne zachowanie w dziewięciu językach oraz osiem produkcyjnych pułapek. Materiał zamykają macierz decyzyjna i FAQ.
Chcesz zweryfikować sekwencję bajtów na bieżąco? Wystarczy wkleić dowolny ciąg do Base64 — kodera i dekodera: zdekodowany payload to ten sam strumień bajtów UTF-8, który opisuje ten artykuł.
Dlaczego kodowanie wciąż gryzie w 2026
Scenariusze z prawdziwych systemów zgłoszeń z ostatnich dwunastu miesięcy:
- MySQL odrzuca emoji. Użytkownik wysyła
Hello 😀, a serwer zwracaIncorrect string value: '\xF0\x9F\x98\x80'. Tabela ma typutf8, deweloper myśli „przecież to UTF-8, co jest nie tak?”, a odpowiedź jest zakopana w historii MySQL (omówione w sekcji 7). - Licznik znaków trafia na produkcję zepsuty. Walidator tweeta na 280 znaków używa
text.length, przyjmuje wiadomość pełną emoji, a API ją odrzuca. Bywa też odwrotnie: poprawny post zostaje odrzucony przez front end. Symptom rozpracowany w sekcji 4. - Lokalny HTML zamienia się w „䏿–‡”. Deweloper zapisuje plik w Windows-1252, otwiera go w przeglądarce, która zgaduje UTF-8, i patrzy, jak rozkwita Mojibake. Historia BOM-u oraz deklaracji charsetu trafia do sekcji 5, z analogiami do przewodnika po kodowaniu i dekodowaniu URL, w którym ta sama rozbieżność bajt vs znak rujnuje query stringi.
Po lekturze powinno być łatwiej odróżnić Unicode od UTF-8 jednym zdaniem, wybrać między UTF-8, UTF-16 i UTF-32 dla nowego projektu, napisać kod, który poprawnie liczy emoji w popularnym języku, oraz zdebugować błąd charsetu wyłącznie ze strumienia bajtów.
Czym jest Unicode? Code pointy vs znaki vs glify
Unicode to tablica znaków, która przypisuje unikalną liczbę — code point, taki jak U+1F600 — do każdego znaku. UTF-8, UTF-16 i UTF-32 to kodowania, które tłumaczą code pointy na bajty. Sam Unicode nie przechowuje żadnych bajtów; definiuje wyłącznie odwzorowanie abstrakcyjny znak → liczba całkowita.
Cztery kolejne pojęcia mącą rozmowę, bo często odnoszą się do tej samej widocznej kreski.
Warstwy, które trzeba rozróżnić
- Code point (
U+0041,U+1F600): liczba całkowita przypisana przez Unicode. Przestrzeń biegnie odU+0000doU+10FFFF, co daje mniej więcej 1,1 miliona miejsc, z czego około 150 000 jest obecnie przypisanych. - Znak (lub znak abstrakcyjny): tożsamość semantyczna, na przykład wielka litera A alfabetu łacińskiego albo emoji uśmiechniętej twarzy.
- Glif: wizualny kształt, który renderuje czcionka. Jeden znak ma wiele glifów: szeryfowe A, kursywne A, A pisane od ręki. Unicode glifami się nie zajmuje.
- Klaster grafemów: to, co użytkownik postrzega jako jeden „znak”. Często jeden code point, czasem kilka. Litera á może być jednym code pointem
U+00E1albo dwomaa + U+0301(łączący akcent ostry). Przewodnik po limitach znaków na platformach pokazuje, jak Twitter, SMS i SEO każde inaczej wyznaczają tę granicę.
Jeśli warto zapamiętać tylko jedno: code point → kodowanie → bajty → renderowanie. Każda strzałka potrafi się zepsuć niezależnie.
Zapis code pointów — U+XXXX i \uXXXX
Code pointy występują w kilku odmianach. U+0041 to kanoniczna notacja Unicode: cztery do sześciu cyfr szesnastkowych z prefiksem U+. W kodzie źródłowym:
- JavaScript / JSON:
"A"(cztery cyfry szesnastkowe, tylko BMP) oraz"\u{1F600}"(klamry z ES6, dowolny code point). - Python:
"A"(4 cyfry),"\U00000041"(8 cyfr, wielka litera U),"\N{LATIN CAPITAL LETTER A}"(po nazwie). - Shell / git log / wyjście sed: w wyjściu pojawiają się surowe bajty UTF-8, takie jak
\xc3\xa9dlaé— to nie code point, to forma zakodowana, co prowadzi do sekcji 3.
17 płaszczyzn — BMP i to, co dalej
Unicode dzieli przestrzeń code pointów na 17 płaszczyzn po 65 536 code pointów każda (17 × 2^16 = 1 114 112).
- Płaszczyzna 0, Basic Multilingual Plane (BMP):
U+0000doU+FFFF. Łacina, ideogramy CJK, cyrylica, alfabet arabski, grecki — większość skryptów spotykanych w starszych tekstach mieszka tutaj. - Płaszczyzny 1-16, płaszczyzny uzupełniające:
U+10000doU+10FFFF. Większość emoji (U+1F600i pokrewne), rzadkie znaki CJK, skrypty historyczne (hieroglify egipskie, pismo klinowe), notacja muzyczna.
Granica BMP / płaszczyzny uzupełniające na U+FFFF to najważniejsza pojedyncza liczba w tym artykule. To w tym miejscu UTF-16 przestaje mieć jedną jednostkę kodu na znak, UTF-8 skacze z trzech bajtów na cztery, a źle nazwane zestawienie utf8 w MySQL poddaje się.
Szybki test poprawności na emoji
"a" → 1 code point U+0061 → 1 grafem
"é" (NFC) → 1 code point U+00E9 → 1 grafem
"é" (NFD) → 2 code pointy U+0065 U+0301 → 1 grafem
"😀" → 1 code point U+1F600 (Plane 1) → 1 grafem
"👨👩👧" → 5 code pointów (3 osoby + 2 ZWJ U+200D) → 1 grafem
Ostatni wiersz jest tu kluczowy. Emoji rodziny to jeden znak postrzegany przez użytkownika, ale pięć code pointów połączonych Zero-Width Joinerami. Każda warstwa stosu może liczyć go inaczej, a pułapka 6 w sekcji 7 zbiera w jeden raport błędu właśnie tę niezgodę.
Mechanika kodowania UTF-8 — jak działa 1-4 bajty
UTF-8 koduje code pointy Unicode w 1 do 4 bajtów. ASCII (U+0000–U+007F) zajmuje 1 bajt i jest bajt w bajt identyczny z ASCII. Wyższe code pointy używają sekwencji wielobajtowych, w których pierwszy bajt sygnalizuje całkowitą długość, a każdy bajt kontynuacji zaczyna się od wzorca bitowego 10xxxxxx. Dzięki temu samoopisującemu się układowi UTF-8 zdominował krajobraz kodowań.
Tabela wzorca bajtów — UTF-8 w jednym diagramie
| Zakres code pointów | Bajty UTF-8 | Wzorzec bajtów |
|---|---|---|
U+0000 – U+007F | 1 bajt | 0xxxxxxx |
U+0080 – U+07FF | 2 bajty | 110xxxxx 10xxxxxx |
U+0800 – U+FFFF | 3 bajty | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 – U+10FFFF | 4 bajty | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Każdy x to bit danych pobrany z binarnej reprezentacji code pointu. Wiodące 0 / 110 / 1110 / 11110 mówi dekoderowi, ile bajtów łącznie; wiodące 10 oznacza każdy bajt kontynuacji. Dzięki tej redundancji UTF-8 jest samosynchronizujące się: po utracie bajtu można wznowić od następnego bajtu startowego, zamiast psuć wszystko poniżej.
Przykład krok po kroku — kodowanie 中 (U+4E2D)
Code point 0x4E2D mieści się w zakresie U+0800–U+FFFF, więc używamy szablonu 3-bajtowego.
- Binarnie:
0x4E2D=0100 1110 0010 1101(16 bitów). - Podział 4-6-6, by wpasować się w sloty
x:0100 / 111000 / 101101. - Wstawienie do
1110xxxx 10xxxxxx 10xxxxxx:11100100 10111000 10101101. - Szesnastkowo:
0xE4 0xB8 0xAD.
Właśnie dlatego 中 po zakodowaniu URL staje się %E4%B8%AD: percent-encoding opakowuje każdy bajt UTF-8 w %XX, nie koduje code pointu bezpośrednio. Pułapka 3 w sekcji 7 opisuje cały łańcuch.
Przykład krok po kroku — kodowanie 😀 (U+1F600)
Code point 0x1F600 wykracza poza BMP, więc używamy szablonu 4-bajtowego.
- Binarnie:
0x1F600=0 0001 1111 0110 0000 0000(21 bitów z dopełnieniem). - Podział 3-6-6-6:
000 / 011111 / 011000 / 000000. - Wstawienie do
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx:11110000 10011111 10011000 10000000. - Szesnastkowo:
0xF0 0x9F 0x98 0x80.
Te cztery bajty to właśnie to, czym dławi się zestawienie utf8 w MySQL: alokuje ono maksymalnie trzy bajty na znak. Pułapka 1 w sekcji 7 podaje rozwiązanie.
Dlaczego UTF-8 wygrał
- Kompatybilność z ASCII. Plik czystego tekstu ASCII jest na poziomie bajtów identyczny ze swoim kodowaniem UTF-8. Dekady narzędzi sprzed Unicode (
grep,awk, klasyczne potoki shellowe) nadal działają dla tego podzbioru. - Samosynchronizacja. Bajty kontynuacji zawsze zaczynają się od
10i nigdy nie kolidują z bajtem startowym. Po utracie bajtu w transferze sieciowym synchronizacja wraca na następnej granicy znaku, zamiast kaskadowo psuć wszystko dalej. - Brak kolejności bajtów. UTF-8 to strumień bajtów, nie jednostek 16- lub 32-bitowych, więc endianness nie ma znaczenia. UTF-16 i UTF-32 potrzebują Byte Order Mark, by zadeklarować, który koniec idzie pierwszy. UTF-8 nie potrzebuje (i zwykle nie powinno; patrz sekcja 5).
Niepoprawne UTF-8 — co zakazuje specyfikacja
Ścisły dekoder odrzuci następujące sekwencje bajtów:
- Sekwencje 5- lub 6-bajtowe. Wczesne RFC je dopuszczały; RFC 3629 (2003) ograniczyło UTF-8 do 4 bajtów, by dopasować do 21-bitowej przestrzeni Unicode.
- Kodowania zbyt długie (overlong). Zakodowanie
/jako trzech bajtów0xE0 0x80 0xAFzamiast jednego bajtu0x2F. Niegdyś żyzne źródło exploitów directory-traversal w walidatorach ścieżek, które dekodowały po sanityzacji. - Samotne code pointy zastępcze (
U+D800–U+DFFF). Są zarezerwowane dla UTF-16 i nigdy nie powinny pojawić się w UTF-8. - Sekwencje obcięte. 3-bajtowy bajt startowy z tylko jednym bajtem kontynuacji. Częste, gdy dane wejściowe użytkownika zostały przycięte na granicy bajtu w środku znaku wielobajtowego.
By którąś z tych rzeczy zobaczyć namacalnie, wystarczy wrzucić ciąg do Base64 — kodera i dekodera, zakodować, a następnie zdekodować z powrotem jako bajty. Tablica bajtów między koderem a dekoderem to strumień UTF-8 opisany w tej sekcji.
UTF-16 i pary zastępcze — dlaczego JavaScript length kłamie
Najczęstsze wyszukiwanie wokół utf-8 vs utf-16 brzmi w rzeczywistości tak: dlaczego "😀".length równa się 2 w moim kodzie? Odpowiedzią są pary zastępcze (surrogate pairs) i decyzja z lat 90., którą odziedziczyły JavaScript, Java, C# i Windows.
UTF-16 w jednym akapicie
UTF-16 reprezentuje Unicode za pomocą 16-bitowych jednostek kodu. Znaki w BMP (U+0000–U+FFFF) zajmują dokładnie jedną jednostkę kodu. Znaki w płaszczyznach uzupełniających (U+10000–U+10FFFF) zajmują dwie jednostki kodu, zwane parą zastępczą (surrogate pair): high surrogate z zakresu U+D800–U+DBFF, po którym następuje low surrogate z zakresu U+DC00–U+DFFF. Blok U+D800–U+DFFF jest w Unicode trwale zarezerwowany, więc żaden prawdziwy znak tam nie mieszka. UTF-16 to wewnętrzny format ciągów dla JavaScript, Java, C# (.NET), API jądra Windows, Objective-C NSString i Qt; wszystkie te platformy zaprojektowano wtedy, gdy 65 536 znaków wyglądało na komfortową rezerwę.
Pułapka String.length
"a".length // 1 — BMP, jedna jednostka kodu
"é".length // 1 — BMP (U+00E9), jedna jednostka kodu
"中".length // 1 — BMP (U+4E2D), jedna jednostka kodu
"😀".length // 2 — płaszczyzna uzupełniająca (U+1F600), para zastępcza!
"a😀".length // 3 — jeden BMP + dwie jednostki zastępcze
String.prototype.length zwraca liczbę jednostek kodu UTF-16, nie liczbę znaków. Każdy znak z płaszczyzny uzupełniającej odczyta się jako 2. Ta sama pułapka istnieje w javowym String.length() oraz w string.Length w C#.
Poprawne liczenie code pointów w JS
[..."😀"].length // 1 — iterator po spreadzie chodzi po code pointach
Array.from("😀").length // 1 — Array.from też chodzi po code pointach
"😀".match(/./gu).length // 1 — flaga /u = regex świadomy Unicode
// "😀".charAt(0) zwraca samotny high surrogate (wizualnie zepsuty)
"😀".codePointAt(0) // 128512 — pełny code point U+1F600
Operator spread i Array.from korzystają z protokołu iteratora, który specyfikacja języka definiuje jako chodzenie po code pointach. Zwykły dostęp przez indeks (str[0], charAt) nadal zwraca jednostki kodu i wrzuci pół pary zastępczej na emoji.
Python — len() już robi dobrze (prawie)
len("😀") # 1 — łańcuchy w Pythonie 3 są indeksowane code pointami
len("👨👩👧") # 5 — code pointy (3 osoby + 2 ZWJ), nie grafemy
# Python 2 domyślnie indeksował bajtami — len("😀") zwracało 4
Python 3 przechowuje łańcuchy w elastycznej reprezentacji 1-, 2- lub 4-bajtowej (PEP 393) i indeksuje po code pointach. len("😀") wynosi 1, ale to nadal nie jest liczba grafemów: emoji rodziny wciąż odczytuje się jako 5. Aby policzyć znaki postrzegane przez użytkownika, potrzebna jest biblioteka grafemów: Intl.Segmenter w JavaScripcie (Node 22+, wszystkie aktualne przeglądarki), grapheme lub regex w Pythonie albo Swift, którego String.count to jedyny mainstreamowy język domyślnie liczący grafemy.
UTF-16 vs UCS-2 — cicha migracja
Przed 1996 rokiem Unicode obiecywał zmieścić się w 16 bitach, a odpowiadającym mu kodowaniem był UCS-2: stałe 2-bajtowe odwzorowanie. Unicode 2.0 złamał tę obietnicę, dodając płaszczyzny uzupełniające. UTF-16 to załatana wersja używająca par zastępczych. Specyfikacja JavaScript w niektórych miejscach wciąż przywołuje starą terminologię UCS-2 i dlatego język toleruje samotne surrogate’y, które powinny być nielegalne. Żarty o „WTF-16” mają realne źródło. API platformy web (DOM, fetch, TextEncoder) odrzucają samotne surrogate’y, bo nie da się ich zakodować do poprawnego UTF-8.
UTF-32, BOM i pytanie o kolejność bajtów
UTF-32 — prosty i rozrzutny
UTF-32 używa stałych 4 bajtów na code point. U+0041 przechowywane jest jako 0x00000041, U+1F600 jako 0x0001F600. Zaletą jest losowy dostęp w stałym czasie: n-ty code point siedzi pod offsetem bajtowym 4n. Wadą jest rozmiar: czysty tekst ASCII pęcznieje do czterokrotności swojego śladu w UTF-8, a nawet tekst CJK się podwaja. Mało który system przechowuje UTF-32 na dysku. Wewnętrznie Python 3 wybiera 1, 2 lub 4 bajty na łańcuch w zależności od najwyższego code pointu; stos fontconfig w Linuksie używa UTF-32 w pamięciowych tablicach glifów.
Kolejność bajtów — dlaczego endianness ma znaczenie dla UTF-16 / UTF-32
UTF-8 to strumień pojedynczych bajtów, więc endianness nie ma zastosowania. UTF-16 i UTF-32 operują na jednostkach wielobajtowych, a różne CPU nie zgadzają się co do tego, który koniec liczby idzie pierwszy.
U+0041 ('A') w UTF-16 BE → 00 41
U+0041 ('A') w UTF-16 LE → 41 00
Procesory x86 i ARM są little-endian; starsze PowerPC oraz „network byte order” są big-endian. Zapisując plik UTF-16, trzeba wybrać jedną z opcji i poinformować o niej czytelnika. Temu właśnie służy BOM.
BOM — czym jest i kiedy go używać
Byte Order Mark to U+FEFF umieszczone na początku pliku. Zakodowane ogłasza zarówno kodowanie, jak i (dla UTF-16 / UTF-32) kolejność bajtów.
| Kodowanie | Bajty BOM |
|---|---|
| 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 |
BOM dla UTF-8 istnieje, ale nie niesie informacji o kolejności bajtów, bo UTF-8 nie ma kolejności bajtów. Jego jedyne zadanie to zadeklarować „ten plik jest w UTF-8”. Przydatne dla narzędzi, które nie mają innego sygnału; szkodliwe dla narzędzi oczekujących, że plik zaczyna się od magicznej liczby lub dyrektywy.
Macierz decyzyjna BOM — czy go dodać?
| Format | UTF-8 BOM | UTF-16 BOM | UTF-32 BOM |
|---|---|---|---|
| HTML | Nie (psuje wykrywanie <!doctype> w starych parserach) | — | — |
| JSON | Nie (RFC 8259 zabrania) | — | — |
| Źródło JavaScript / CSS | Unikać (starsze Node i IE się krztuszą) | — | — |
| CSV otwierane w Excelu | Tak (Excel czyta UTF-8 bez BOM jako ANSI i psuje CJK) | — | — |
| XML | Opcjonalnie (deklaracja XML i tak podaje kodowanie) | Wymagane | Wymagane |
Zwykły tekst .txt | Opcjonalnie (Notatnik Windows dodaje domyślnie) | Wymagane | Wymagane |
Zasada robocza: usunąć BOM UTF-8 ze wszystkiego, co trafia do sieci; dodać go do CSV-ek otwieranych w Excelu; resztę zostawić decyzji czytelnika.
9 języków obok siebie — domyślne zachowanie kodowania
W pracy międzyjęzykowej ta wiedza się zwraca. Ten sam ciąg "a😀é" daje inną długość w każdym runtime wywoływanym ze skryptu Bash.
Tabela zachowania międzyjęzykowego
| Język | Kodowanie pliku źródłowego | Przechowywanie ciągu | Co liczy length / len | Domyślne kodowanie I/O | Bezpieczne dla 4-bajtowego emoji? |
|---|---|---|---|---|---|
| JavaScript (V8 / SpiderMonkey) | UTF-8 | UTF-16 | jednostki kodu UTF-16 | UTF-8 (Node, Web) | Tak, ale .length === 2 |
| Python 3 | UTF-8 (PEP 3120) | dynamicznie 1 / 2 / 4 bajty (PEP 393) | code pointy | UTF-8 (PEP 540 od 3.7) | Tak, len === 1 |
| Java | UTF-8 (domyślne javac) | UTF-16 | jednostki kodu UTF-16 | charset platformy → UTF-8 (JEP 400, JDK 18+) | Tak, ale .length() === 2 |
| Go | UTF-8 | bajty UTF-8 | bajty (utf8.RuneCountInString dla code pointów) | UTF-8 | Tak, len(s) zwraca bajty |
| Rust | UTF-8 | bajty UTF-8 (niezmiennik String) | bajty .len(), code pointy .chars().count() | UTF-8 | Tak, jawnie |
| C# (.NET) | UTF-8 (domyślne od .NET Core 3.0) | UTF-16 | jednostki kodu UTF-16 | UTF-8 (Encoding.Default od .NET 5) | Tak, ale .Length === 2 |
| Ruby | UTF-8 (od 2.0) | tag kodowania per ciąg | code pointy (.length) | UTF-8 | Tak, length === 1 |
| PHP | (brak kodowania źródła) | łańcuch bajtowy | bajty (strlen); code pointy przez mb_strlen | zależne od default_charset | Tak, z rodziną mb_* |
| MySQL | — | charset kolumny | bajty (LENGTH), znaki (CHAR_LENGTH) | zmienne systemowe character_set_* | Tylko z utf8mb4 |
Co tabela naprawdę mówi
Trzy filozofie projektowe oznaczają trzy typy błędów:
- UTF-8 wewnętrznie (Go, Rust, Ruby). Natywny ciąg to bajty;
lengthjest dobrze zdefiniowane, ale liczy to, co liczy. Konwersja do code pointów lub grafemów powinna zachodzić dopiero przy przekraczaniu granicy UI lub walidacji. - UTF-16 wewnętrznie (JavaScript, Java, C#). Odziedziczone po założeniach z lat 90.;
lengthto jednostki kodu, para zastępcza liczy się jako 2. Każdy licznik widoczny dla użytkownika powinien używać iteracji świadomej code pointów. - Indeksowane code pointami (Python 3).
lenzwraca code pointy, co wydaje się słuszne do czasu spotkania z emoji ZWJ — wtedy i tak potrzebna jest biblioteka grafemów.
PHP to przypadek szczególny. Wszystkie wbudowane funkcje str* operują na bajtach, traktując sekwencje UTF-8 jako nieprzezroczyste bloby. Każdy projekt non-ASCII musi używać rodziny mb_* (multibyte), a rok po roku zgłaszane błędy pokazują, jak często się o tym zapomina.
Praktyczna reguła: UTF-8 jako format na drucie wszędzie (pliki, ciała HTTP, kolumny bazy danych), a konwersja do natywnego typu łańcucha runtime’u zachodzi na granicy. To „kanapka UTF-8”, do której wracamy w sekcji 8.
8 produkcyjnych pułapek inżynierskich
Poniższe wzorce wracają na każdym code review w zglobalizowanym kodzie.
Pułapka 1: utf8 w MySQL to 3-bajtowe kłamstwo — przejdź na utf8mb4
Objaw. INSERT INTO users (bio) VALUES ('Hello 😀'); zwraca Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio'.
Przyczyna źródłowa. Historyczne utf8 w MySQL to alias dla utf8mb3: wariant UTF-8 ograniczony do trzech bajtów na znak. Każdy code point powyżej U+FFFF (każde emoji, kilka tysięcy rzadkich znaków CJK, wszystkie skrypty historyczne) wymaga czterech bajtów UTF-8 i zostaje odrzucony.
Rozwiązanie.
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; -- połączenie klienta
# my.cnf
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
MySQL 8.0 nadal dostarcza utf8 jako alias dla utf8mb3. utf8mb3 jest oznaczone jako przestarzałe, ale jeszcze nieusunięte. W nowej kolumnie, nowej bazie i nowym połączeniu należy używać utf8mb4: wariant historyczny nie daje żadnych zalet.
Pułapka 2: fallback do Windows-1252 — tajemnica znaku zapytania
Objaw. Plik .txt wyeksportowany z Notatnika windowsowego kolegi pokazuje na jego maszynie «cudzysłowy» i myślnik. Na docelowym serwerze staje się ? lub U+FFFD (znak zastępczy).
Przyczyna źródłowa. Starszy Notatnik domyślnie używa Windows-1252 (CP-1252), który koduje cudzysłów otwierający „ jako 0x93. Dekoder UTF-8 widzi 0x93 jako zabłąkany bajt kontynuacji (górny bit 10) bez poprzedzającego bajtu startowego i podstawia znak zastępczy.
Rozwiązanie. Wystarczy wykryć kodowanie źródła (file na Uniksie, chardet / charset-normalizer w Pythonie, jschardet w Node), zdekodować poprawnym kodekiem, a następnie zakodować ponownie jako UTF-8 przed zapisem. Standaryzacja na UTF-8 przy wejściu eliminuje nawroty.
Pułapka 3: percent-encoding URL ≠ UTF-8 (ale na nim się opiera)
Objaw. fetch("/search?q=中文") zwraca 404 z jednego backendowego frameworka, a działa z innego.
Przyczyna źródłowa. Percent-encoding działa na bajtach, nie na code pointach. 中 to jeden code point, ale trzy bajty UTF-8 (E4 B8 AD), z których każdy osobno trafia w %E4%B8%AD, czyli dziewięć znaków ASCII w URL-u. Framework, który dekoduje URL jako Latin-1 zamiast UTF-8, poda handlerowi te trzy zniekształcone bajty zinterpretowane jako trzy znaki jednobajtowe.
Rozwiązanie. Po stronie klienta można skorzystać z encodeURIComponent("中文") (przeglądarki robią UTF-8 + percent-encoding w jednym kroku) i upewnić się, że framework serwerowy dekoduje URL-e jako UTF-8 (aktualne frameworki tak robią domyślnie). Wizualne potwierdzenie daje wklejenie 中文 do kodera i dekodera URL i obserwacja, jak ciąg staje się %E4%B8%AD%E6%96%87. Pełny łańcuch opisuje przewodnik po kodowaniu i dekodowaniu URL.
Pułapka 4: wejście Base64 to bajty, a ty wpisałeś ciąg
Objaw. btoa("你好") rzuca InvalidCharacterError: The string contains characters outside the Latin1 range.
Przyczyna źródłowa. btoa zaprojektowano w erze ASCII / Latin-1. Oczekuje, że każdy znak wejściowy zmieści się w jednym bajcie (code pointy 0-255). 你好 to UTF-16 w silniku JS z code pointami U+4F60 U+597D, oba znacznie powyżej 255.
Rozwiązanie. Najpierw zakodować do bajtów UTF-8, potem te bajty zakodować w Base64.
// Źle:
btoa("你好"); // wyjątek
// Poprawnie:
const bytes = new TextEncoder().encode("你好");
// Uint8Array(6) [228, 189, 160, 229, 165, 189]
const b64 = btoa(String.fromCharCode(...bytes));
// "5L2g5aW9"
Dłuższa historia znajduje się w Czym jest kodowanie Base64? oraz w Base64 w produkcji: MIME, data URI, wydajność i bezpieczeństwo; Base64 — koder i dekoder wykonuje konwersję w jednym kroku i pokazuje pośredni strumień bajtów.
Pułapka 5: String.length do walidacji (limity Twittera / SMS-ów)
Objaw. Composer na 280 znaków waliduje po stronie klienta, a API zwraca 422. Bywa też odwrotnie: całkowicie poprawny post zostaje odrzucony przez klienta.
Przyczyna źródłowa. .length w JavaScripcie liczy jednostki kodu UTF-16; pojedyncze emoji liczy się jako 2. Twitter liczy code pointy (emoji = 1). Liczba znaków rozjeżdża się w przeciwnych kierunkach w zależności od tego, któremu API zaufamy.
Rozwiązanie. Dla liczby code pointów sprawdza się [...text].length, a dla prawdziwej liczby grafemów Intl.Segmenter (podejście Bluesky / iMessage). Liczby per-platforma i granice SMS GSM-7 vs UCS-2 są skatalogowane w przewodniku po limitach znaków na platformach.
Pułapka 6: rodziny emoji ze ZWJ liczą się jako N code pointów, 1 grafem
Objaw. "👨👩👧".length === 8. Liczenie code pointów daje 5. Dla użytkownika to jeden obrazek.
Przyczyna źródłowa. Zero-Width Joiner (U+200D) skleja kilka code pointów emoji w jeden wyrenderowany klaster: trzy emoji osób plus dwa ZWJ to pięć code pointów, osiem jednostek kodu UTF-16, jeden grafem.
Rozwiązanie.
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment("👨👩👧")].length; // 1
Intl.Segmenter jest w Node 22+ i w każdej aktualnej przeglądarce. Dla starszych runtime’ów pakiet grapheme-splitter implementuje UAX #29.
Pułapka 7: escape \uXXXX w JSON — code pointy powyżej U+FFFF wymagają pary zastępczej
Objaw. Payload JSON zawiera "😀", a dekoder po stronie odbiorczej albo renderuje go poprawnie jako 😀, albo pokazuje dwa kwadraty, w zależności od tego, czy rozumie pary zastępcze w JSON.
Przyczyna źródłowa. Escape \uXXXX w JSON przyjmuje wyłącznie dokładnie cztery cyfry szesnastkowe (czyli jedną jednostkę kodu UTF-16). Zakodowanie 😀 (U+1F600) wymaga pary zastępczej 😀. W JSON nie ma składni z klamrami \u{...}.
Rozwiązanie. Można zaakceptować parę zastępczą (każdy parser zgodny ze specyfikacją ją obsłuży) albo wpisać emoji dosłownie. JSON dopuszcza dowolny znak UTF-8 poza składnią escape, a większość aktualnych parserów woli tę formę.
Pułapka 8: domyślne Content-Type: charset= HTTP nie są tym, czym myślisz
Objaw. Strona HTML w UTF-8 renderuje się jako Mojibake w jednej przeglądarce i poprawnie w innej.
Przyczyna źródłowa. RFC 2616 pierwotnie nakazywało ISO-8859-1 jako wartość domyślną dla odpowiedzi text/* bez jawnego charsetu. RFC 7231 (2014) usunęło ten default, zostawiając zgadywanie każdej przeglądarce. Część sniffuje zawartość, część wraca do UTF-8, a jeszcze inne biorą locale systemu.
Rozwiązanie. Zawsze wysyłać Content-Type: text/html; charset=utf-8 z serwera oraz <meta charset="utf-8"> w nagłówku dokumentu. Każdy z tych sygnałów wystarcza osobno; oba razem to zabezpieczenie na starsze proxy, które usuwają nagłówki.
Aby zobaczyć którąkolwiek z tych pułapek na żywo na poziomie bajtów, najszybszym mikroskopem jest Base64 — koder i dekoder: wystarczy wkleić ciąg, zakodować do Base64, a zdekodowany payload to strumień UTF-8.
Wybór właściwego kodowania — macierz decyzyjna
W kwestii utf-8 vs utf-16 odpowiedź to niemal zawsze UTF-8. Tabela poniżej obejmuje przypadki brzegowe.
Macierz decyzyjna
| Scenariusz | Wybór | Dlaczego |
|---|---|---|
| Strony web, API JSON, pliki źródłowe | UTF-8 (bez BOM) | Zgodne z ASCII, brak kolejności bajtów, najmniejsze dla tekstu łacińskiego, RFC 8259 wymaga UTF-8 dla JSON |
| Ciężkie magazynowanie CJK (chińska baza, dane gier japońskich) | UTF-8 (utf8mb4) | UTF-8 zajmuje 3 bajty na znak CJK vs 2 w UTF-16, ale narzut ASCII z markupu i kluczy JSON daje w praktyce przewagę UTF-8, a otaczający ekosystem operuje na UTF-8 |
| Natywne API Windows, starszy kod Java / C# | UTF-16 | Domyślne platformy; konwersja przy każdym wywołaniu API zaprasza błędy |
| Indeksowanie intensywne w pamięci | UTF-32 | Stały czas dostępu do code pointów; opłacalne tylko w hot pathach parserów |
| CSV otwierane w Excelu na Windows | UTF-8 z BOM | Excel czyta UTF-8 bez BOM jako ANSI i psuje nagłówki CJK |
| Nowy projekt, brak ograniczeń | UTF-8 (bez BOM) | Spór o kodowanie tekstu jest rozstrzygnięty |
Dwie zasady kciuka
- Domyślnie UTF-8 wszędzie, chyba że platforma wymusza inaczej. W3C, IETF i Unicode Consortium są zgodne.
- Konwersja na granicy, nie w środku. Bajty należy zdekodować do natywnego typu łańcucha języka przy wejściu. W logice biznesowej operować na łańcuchach, a nie na bajtach. Przy wyjściu zakodować z powrotem do UTF-8. Ta „kanapka UTF-8” eliminuje całą klasę błędów mojibake w środku potoku.
Najczęściej zadawane pytania
Czy UTF-8 zawsze jest wstecznie kompatybilne z ASCII?
Tak. Każdy poprawny plik ASCII jest bit w bit identyczny ze swoją reprezentacją UTF-8. Pierwsze 128 code pointów (U+0000–U+007F) koduje się jako pojedynczy bajt z wyzerowanym najwyższym bitem. Starsze narzędzia obsługujące tylko ASCII (wczesny grep, sed, klasyczne potoki shellowe) przetwarzają czyste pliki UTF-8 w ASCII bez modyfikacji. Kłopoty zaczynają się dopiero wtedy, gdy do strumienia wchodzą bajty non-ASCII (z ustawionym najwyższym bitem).
Czy używać BOM UTF-8 w plikach?
Domyślnie nie. Pliki HTML, JSON, JavaScript i CSS psują się lub ostrzegają w części parserów, gdy na początku pojawia się BOM. Standardowy wyjątek to CSV przeznaczone do Excela na Windows: bez BOM Excel zgaduje ANSI i psuje nagłówki chińskie, japońskie lub koreańskie. Patrz macierz decyzyjna BOM w sekcji 5.
Dlaczego "😀".length === 2 w JavaScripcie?
Łańcuchy w JavaScripcie są przechowywane jako UTF-16, a .length zwraca liczbę jednostek kodu, nie znaków. 😀 (U+1F600) mieszka w płaszczyźnie uzupełniającej i wymaga pary zastępczej (dwóch 16-bitowych jednostek kodu), więc .length wynosi 2. Prawdziwą liczbę zwracają [..."😀"].length, Array.from("😀").length lub Intl.Segmenter.
Jaka jest różnica między Unicode a UTF-8?
Unicode to tablica znaków, która przypisuje code point (liczbę typu U+1F600) do każdego znaku. UTF-8 to jedno z kilku kodowań, które tłumaczą code pointy na bajty (1 do 4 bajtów na code point). Unicode definiuje, czym jest znak; UTF-8 definiuje, jak podróżuje przez plik lub sieć. UTF-16 i UTF-32 to alternatywne kodowania tej samej tablicy Unicode.
Czy utf8mb4 jest zawsze bezpieczniejsze niż utf8 w MySQL?
Tak dla nowych projektów. utf8 w MySQL to źle nazwany wariant utf8mb3 ograniczony do 3 bajtów, który nie potrafi przechować żadnego znaku powyżej U+FFFF (żadnego emoji, wielu rzadkich znaków CJK, żadnego skryptu historycznego). utf8mb4 to pełne 4-bajtowe UTF-8. Jedyne zastrzeżenie to długość indeksu: każdy znak utf8mb4 może zająć 4 bajty, więc historyczny limit 767 bajtów na indeks InnoDB ogranicza unikalne indeksy do 191 znaków (rozwiązane przez innodb_large_prefix w MySQL 5.7+ i domyślne w 8.0).
Jak wykryć kodowanie nieznanego pliku?
Sprawdzają się file na Uniksie, chardet lub charset-normalizer w Pythonie albo jschardet w Node. Żadne narzędzie nie jest doskonałe; wszystkie statystycznie zgadują na podstawie rozkładu bajtów. Wykrywanie UTF-8 jest wysoce niezawodne dzięki wzorcowi bajtu kontynuacji. Windows-1252, ISO-8859-1 i inne jednobajtowe kodowania historyczne są niemal nieodróżnialne od siebie, więc detekcja często sprowadza się do heurystyk językowych.
Czy UTF-16 potrafi reprezentować każdy znak Unicode?
Tak. UTF-16 pokrywa wszystkie 1 114 112 code pointów. Znaki BMP (U+0000–U+FFFF) używają jednej 16-bitowej jednostki kodu (2 bajty), a znaki płaszczyzn uzupełniających (U+10000–U+10FFFF) używają par zastępczych (4 bajty). Pokrycie jest identyczne z UTF-8 i UTF-32; różni się tylko układ bajtów i semantyka przetwarzania. Wybór między nimi dotyczy dopasowania do ekosystemu, a nie możliwości.