Skip to content
Powrót do bloga
Poradniki

UTF-8 vs UTF-16 vs Unicode — przewodnik dla programistów

UTF-8, UTF-16 i UTF-32 dla deweloperów: code pointy, pary zastępcze, BOM oraz pułapki MySQL utf8mb4 i JS length. Jak wybrać właściwe kodowanie.

12 min czytania

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:

  1. MySQL odrzuca emoji. Użytkownik wysyła Hello 😀, a serwer zwraca Incorrect string value: '\xF0\x9F\x98\x80'. Tabela ma typ utf8, deweloper myśli „przecież to UTF-8, co jest nie tak?”, a odpowiedź jest zakopana w historii MySQL (omówione w sekcji 7).
  2. 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.
  3. 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 od U+0000 do U+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+00E1 albo dwoma a + 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\xa9 dla é — 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+0000 do U+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+10000 do U+10FFFF. Większość emoji (U+1F600 i 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+0000U+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ówBajty UTF-8Wzorzec bajtów
U+0000U+007F1 bajt0xxxxxxx
U+0080U+07FF2 bajty110xxxxx 10xxxxxx
U+0800U+FFFF3 bajty1110xxxx 10xxxxxx 10xxxxxx
U+10000U+10FFFF4 bajty11110xxx 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+0800U+FFFF, więc używamy szablonu 3-bajtowego.

  1. Binarnie: 0x4E2D = 0100 1110 0010 1101 (16 bitów).
  2. Podział 4-6-6, by wpasować się w sloty x: 0100 / 111000 / 101101.
  3. Wstawienie do 1110xxxx 10xxxxxx 10xxxxxx: 11100100 10111000 10101101.
  4. 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.

  1. Binarnie: 0x1F600 = 0 0001 1111 0110 0000 0000 (21 bitów z dopełnieniem).
  2. Podział 3-6-6-6: 000 / 011111 / 011000 / 000000.
  3. Wstawienie do 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx: 11110000 10011111 10011000 10000000.
  4. 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ł

  1. 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.
  2. Samosynchronizacja. Bajty kontynuacji zawsze zaczynają się od 10 i nigdy nie kolidują z bajtem startowym. Po utracie bajtu w transferze sieciowym synchronizacja wraca na następnej granicy znaku, zamiast kaskadowo psuć wszystko dalej.
  3. 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ów 0xE0 0x80 0xAF zamiast jednego bajtu 0x2F. 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+D800U+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+0000U+FFFF) zajmują dokładnie jedną jednostkę kodu. Znaki w płaszczyznach uzupełniających (U+10000U+10FFFF) zajmują dwie jednostki kodu, zwane parą zastępczą (surrogate pair): high surrogate z zakresu U+D800U+DBFF, po którym następuje low surrogate z zakresu U+DC00U+DFFF. Blok U+D800U+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.

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

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ć?

FormatUTF-8 BOMUTF-16 BOMUTF-32 BOM
HTMLNie (psuje wykrywanie <!doctype> w starych parserach)
JSONNie (RFC 8259 zabrania)
Źródło JavaScript / CSSUnikać (starsze Node i IE się krztuszą)
CSV otwierane w ExceluTak (Excel czyta UTF-8 bez BOM jako ANSI i psuje CJK)
XMLOpcjonalnie (deklaracja XML i tak podaje kodowanie)WymaganeWymagane
Zwykły tekst .txtOpcjonalnie (Notatnik Windows dodaje domyślnie)WymaganeWymagane

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ęzykKodowanie pliku źródłowegoPrzechowywanie ciąguCo liczy length / lenDomyślne kodowanie I/OBezpieczne dla 4-bajtowego emoji?
JavaScript (V8 / SpiderMonkey)UTF-8UTF-16jednostki kodu UTF-16UTF-8 (Node, Web)Tak, ale .length === 2
Python 3UTF-8 (PEP 3120)dynamicznie 1 / 2 / 4 bajty (PEP 393)code pointyUTF-8 (PEP 540 od 3.7)Tak, len === 1
JavaUTF-8 (domyślne javac)UTF-16jednostki kodu UTF-16charset platformy → UTF-8 (JEP 400, JDK 18+)Tak, ale .length() === 2
GoUTF-8bajty UTF-8bajty (utf8.RuneCountInString dla code pointów)UTF-8Tak, len(s) zwraca bajty
RustUTF-8bajty UTF-8 (niezmiennik String)bajty .len(), code pointy .chars().count()UTF-8Tak, jawnie
C# (.NET)UTF-8 (domyślne od .NET Core 3.0)UTF-16jednostki kodu UTF-16UTF-8 (Encoding.Default od .NET 5)Tak, ale .Length === 2
RubyUTF-8 (od 2.0)tag kodowania per ciągcode pointy (.length)UTF-8Tak, length === 1
PHP(brak kodowania źródła)łańcuch bajtowybajty (strlen); code pointy przez mb_strlenzależne od default_charsetTak, z rodziną mb_*
MySQLcharset kolumnybajty (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; length jest 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.; length to 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). len zwraca 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

ScenariuszWybórDlaczego
Strony web, API JSON, pliki źródłoweUTF-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-16Domyślne platformy; konwersja przy każdym wywołaniu API zaprasza błędy
Indeksowanie intensywne w pamięciUTF-32Stały czas dostępu do code pointów; opłacalne tylko w hot pathach parserów
CSV otwierane w Excelu na WindowsUTF-8 z BOMExcel 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

  1. Domyślnie UTF-8 wszędzie, chyba że platforma wymusza inaczej. W3C, IETF i Unicode Consortium są zgodne.
  2. 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+0000U+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+0000U+FFFF) używają jednej 16-bitowej jednostki kodu (2 bajty), a znaki płaszczyzn uzupełniających (U+10000U+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.

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

Powiązane artykuły

Zobacz wszystkie artykuły