UTF-8 vs UTF-16 vs Unicode — Panduan Encoding Lengkap untuk Developer
Jawaban singkat yang dicari kebanyakan pencarian utf-8 unicode encoding: Unicode dan UTF-8 itu dua hal berbeda. Unicode adalah tabel bernomor raksasa yang memberi code point (angka seperti U+1F600) ke setiap karakter. UTF-8, UTF-16, dan UTF-32 adalah encoding, yaitu cara mengubah code point tersebut menjadi byte. UTF-8 hampir selalu pilihan yang Anda butuhkan: byte-nya identik dengan ASCII untuk teks Inggris, sanggup memanjang hingga empat byte untuk setiap emoji, dan diwajibkan oleh JSON, HTML5, serta protokol modern pada umumnya.
Panduan ini ditulis untuk developer yang pernah kena masalahnya: error MySQL Incorrect string value saat menyimpan 😀, kejutan JavaScript di mana "😀".length === 2, atau CSV yang rapi di cat namun berantakan ketika dibuka di Excel. Kita akan menelusuri dari code point ke mekanika byte UTF-8, surrogate pair, BOM, default sembilan bahasa pemrograman, dan delapan jebakan produksi, lalu ditutup dengan matriks keputusan dan FAQ.
Ingin memverifikasi urutan byte sambil membaca? Tempelkan string apa pun ke Base64 Encode & Decode Online Gratis — Konverter Instan. Payload yang ter-decode persis adalah stream byte UTF-8 yang dibahas artikel ini.
Mengapa Encoding Masih Menggigit Anda di 2026
Tiga skenario dari bug tracker nyata dalam dua belas bulan terakhir:
- MySQL menolak emoji. User mengirim
Hello 😀dan server membalasIncorrect string value: '\xF0\x9F\x98\x80'. Tabelnyautf8, dan developer berpikir “kan itu UTF-8, masalahnya apa?”. Jawabannya terkubur di sejarah MySQL (dibahas di bagian 7). - Character counter rilis dengan bug. Validator tweet 280 karakter pakai
text.length, menerima pesan penuh emoji, lalu API menolaknya. Kebalikannya juga terjadi: postingan yang valid ditolak front-end. Gejala lengkapnya ada di bagian 4. - HTML lokal berubah jadi “䏿–‡”. Developer menyimpan file dalam Windows-1252, membukanya di browser yang menebak UTF-8, lalu menyaksikan mojibake. Ini cerita BOM / deklarasi charset di bagian 5, dengan paralel ke URL Encoding & Decoding: Panduan Praktis Percent Encoding di mana ketidakcocokan byte-vs-karakter yang sama merusak query string.
Yang akan Anda bawa pulang: setelah halaman terakhir Anda bisa (a) membedakan Unicode dari UTF-8 dalam satu kalimat, (b) memilih antara UTF-8, UTF-16, dan UTF-32 untuk proyek baru, (c) menulis kode yang menghitung emoji dengan benar di setiap bahasa utama, dan (d) men-debug bug charset apa pun hanya dari stream byte-nya. Permukaan praktis character encoding sebenarnya tidak luas, walaupun terlihat menyeramkan.
Apa Itu Unicode? Code Point vs Karakter vs Glyph
Unicode adalah tabel karakter yang memberi nomor unik (sebuah code point seperti U+1F600) ke setiap karakter. UTF-8, UTF-16, dan UTF-32 adalah encoding yang menerjemahkan code point tersebut jadi byte. Unicode sendiri tidak menyimpan byte; ia hanya mendefinisikan pemetaan dari karakter abstrak ke integer.
Tiga istilah tambahan sering membuat pembicaraan keruh karena ketiganya kerap merujuk pada tanda visual yang sama:
Tiga lapisan yang harus Anda pisahkan
- Code point (
U+0041,U+1F600): integer yang diberikan Unicode. RentangnyaU+0000sampaiU+10FFFF, kira-kira 1,1 juta slot, sekitar 150.000 di antaranya sudah teralokasi sekarang. - Karakter (atau abstract character): identitas semantiknya, misal huruf kapital Latin A, emoji wajah menyeringai.
- Glyph: bentuk visual yang dirender font. Satu karakter punya banyak glyph: serif A, italic A, A tulisan tangan. Unicode tidak peduli soal glyph.
- Grapheme cluster: apa yang user persepsikan sebagai satu “karakter”. Sering satu code point, kadang beberapa. Huruf á bisa berupa satu code point
U+00E1atau dua code pointa + U+0301(combining acute accent). Batas Karakter & Kata 2026: Twitter, SMS, SEO, Instagram Online mengupas bagaimana Twitter, SMS, dan SEO masing-masing menarik garis ini secara berbeda.
Kalau Anda hanya bisa mengingat satu hal, ingat ini: code point → encoding → byte → rendering. Setiap panah bisa rusak secara independen.
Notasi code point: U+XXXX dan \uXXXX
Code point ditulis dalam beberapa varian. U+0041 adalah notasi Unicode kanonik: empat sampai enam digit hex, diawali U+. Di kode sumber:
- JavaScript / JSON:
"A"(empat digit hex, hanya BMP) dan"\u{1F600}"(kurung kurawal ES6, untuk code point apa pun). - Python:
"A"(4 digit),"\U00000041"(8 digit, huruf U kapital),"\N{LATIN CAPITAL LETTER A}"(berdasarkan nama). - Shell / git log / output sed: Anda sering melihat byte UTF-8 mentah seperti
\xc3\xa9untuké. Itu bukan code point, melainkan bentuk ter-encode, yang membawa kita ke bagian 3.
17 plane: BMP dan seterusnya
Unicode membagi ruang code point-nya menjadi 17 plane, masing-masing berisi 65.536 code point: 17 × 2^16 = 1.114.112.
- Plane 0, Basic Multilingual Plane (BMP):
U+0000sampaiU+FFFF. Latin, ideograf CJK, Cyrillic, Arab, Yunani, hampir setiap aksara yang Anda temui di teks legacy ada di sini. - Plane 1-16, supplementary plane:
U+10000sampaiU+10FFFF. Mayoritas emoji (U+1F600dan kawan-kawan), karakter CJK langka, aksara historis (hieroglif Mesir, cuneiform), notasi musik.
Batas BMP / supplementary di U+FFFF adalah angka terpenting di seluruh artikel ini. Di situlah UTF-16 berhenti menjadi satu code unit per karakter, di situlah UTF-8 melompat dari tiga byte ke empat byte, dan di situlah collation utf8 MySQL yang salah nama itu menyerah.
Cek cepat dengan emoji
"a" → 1 code point U+0061 → 1 grapheme
"é" (NFC) → 1 code point U+00E9 → 1 grapheme
"é" (NFD) → 2 code point U+0065 U+0301 → 1 grapheme
"😀" → 1 code point U+1F600 (Plane 1) → 1 grapheme
"👨👩👧" → 5 code point (3 orang + 2 ZWJ U+200D) → 1 grapheme
Baris terakhir paling penting. Emoji keluarga itu adalah satu karakter yang dipersepsikan user, tapi lima code point yang dihubungkan Zero-Width Joiner. Setiap lapisan stack bisa menghitungnya berbeda, dan jebakan 6 di bagian 7 adalah bug report yang lahir dari perbedaan ini.
Mekanika Encoding UTF-8: Cara Kerja 1-4 Byte
UTF-8 meng-encode code point Unicode dalam 1 sampai 4 byte. ASCII (U+0000–U+007F) pakai 1 byte dan byte-nya identik dengan ASCII. Code point lebih tinggi pakai urutan multi-byte di mana byte pertama menandakan total panjang dan setiap continuation byte dimulai dengan pola bit 10xxxxxx. Layout self-describing inilah yang membuat UTF-8 memenangkan perang encoding.
Tabel pola byte: UTF-8 dalam satu diagram
| Rentang code point | Byte UTF-8 | Pola byte |
|---|---|---|
U+0000 – U+007F | 1 byte | 0xxxxxxx |
U+0080 – U+07FF | 2 byte | 110xxxxx 10xxxxxx |
U+0800 – U+FFFF | 3 byte | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 – U+10FFFF | 4 byte | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Setiap x adalah bit data yang diambil dari representasi biner code point. Bit awal 0 / 110 / 1110 / 11110 memberi tahu decoder berapa total byte; bit awal 10 menandai setiap continuation byte. Redundansi itulah yang membuat UTF-8 self-synchronizing: kalau satu byte hilang, Anda bisa melanjutkan di start byte berikutnya, bukan merusak semua yang ada di hilir.
Contoh kerja: meng-encode 中 (U+4E2D)
Code point 0x4E2D berada di rentang U+0800–U+FFFF, jadi kita pakai template 3-byte.
- Biner:
0x4E2D=0100 1110 0010 1101(16 bit). - Bagi 4-6-6 agar pas di slot
x:0100 / 111000 / 101101. - Substitusikan ke
1110xxxx 10xxxxxx 10xxxxxx:11100100 10111000 10101101. - Hex:
0xE4 0xB8 0xAD.
Itulah persisnya kenapa 中 jadi %E4%B8%AD setelah URL-encoding: percent-encoding membungkus setiap byte UTF-8 dalam %XX, ia tidak meng-encode code point secara langsung. Jebakan 3 di bagian 7 menjelaskan rantainya secara rinci.
Contoh kerja: meng-encode 😀 (U+1F600)
Code point 0x1F600 melampaui BMP, jadi kita pakai template 4-byte.
- Biner:
0x1F600=0 0001 1111 0110 0000 0000(21 bit, di-padding). - Bagi 3-6-6-6:
000 / 011111 / 011000 / 000000. - Substitusikan ke
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx:11110000 10011111 10011000 10000000. - Hex:
0xF0 0x9F 0x98 0x80.
Empat byte itulah yang membuat collation utf8 MySQL tersedak: ia mengalokasikan maksimum tiga byte per karakter. Jebakan 1 di bagian 7 berisi solusinya.
Mengapa UTF-8 menang
- Kompatibilitas ASCII. File teks ASCII murni identik di level byte dengan encoding UTF-8-nya. Tool yang sudah ada puluhan tahun sebelum Unicode (
grep,awk, classic shell pipe) terus berfungsi untuk subset itu. - Self-synchronizing. Continuation byte selalu dimulai dengan
10, yang tidak pernah bertabrakan dengan start byte mana pun. Kehilangan satu byte di transfer jaringan, Anda akan resync di batas karakter berikutnya, bukan sampah yang menumpuk. - Tanpa byte order. UTF-8 adalah stream byte, bukan unit 16-bit atau 32-bit, jadi endianness tidak relevan. UTF-16 dan UTF-32 butuh Byte Order Mark untuk menyatakan ujung mana yang lebih dulu; UTF-8 tidak butuh (dan biasanya sebaiknya tidak; lihat bagian 5).
UTF-8 invalid: apa yang dilarang spesifikasi
Decoder yang ketat akan menolak urutan byte berikut:
- Urutan 5 atau 6 byte. RFC awal mengizinkannya; RFC 3629 (2003) membatasi UTF-8 sampai 4 byte agar sesuai dengan ruang Unicode 21-bit.
- Overlong encoding. Meng-encode
/sebagai tiga byte0xE0 0x80 0xAFalih-alih satu byte0x2F. Dulu sering jadi sumber exploit directory traversal di validator path yang melakukan decode setelah sanitasi. - Code point surrogate yang berdiri sendiri (
U+D800–U+DFFF). Ini dicadangkan untuk UTF-16 dan tidak boleh pernah muncul di UTF-8. - Urutan yang terpotong. Start byte 3-byte diikuti hanya satu continuation byte. Umum ketika input user dipotong di batas byte di tengah karakter multi-byte.
Untuk melihat ini secara konkret, jatuhkan string ke Base64 Encode & Decode Online Gratis — Konverter Instan, encode, lalu decode kembali sebagai byte. Array byte di antara encoder dan decoder adalah stream UTF-8 yang dijelaskan bagian ini.
UTF-16 dan Surrogate Pair: Mengapa length JavaScript Berbohong
Pencarian paling umum seputar utf-8 vs utf-16 sebetulnya adalah “kenapa "😀".length sama dengan 2 di kode saya?”. Jawabannya adalah surrogate pair, dan ini keputusan era 1990-an yang diwarisi JavaScript, Java, C#, dan Windows.
UTF-16 dalam satu paragraf
UTF-16 merepresentasikan Unicode menggunakan code unit 16-bit. Karakter di BMP (U+0000–U+FFFF) memakai tepat satu code unit. Karakter di supplementary plane (U+10000–U+10FFFF) memakai dua code unit, yang disebut surrogate pair: high surrogate di U+D800–U+DBFF diikuti low surrogate di U+DC00–U+DFFF. Blok U+D800–U+DFFF dicadangkan permanen di Unicode sehingga tidak ada karakter nyata yang menempati area itu. UTF-16 adalah format string internal untuk JavaScript, Java, C# (.NET), Windows kernel API, Objective-C NSString, dan Qt, semua dirancang ketika 65.536 karakter terlihat lebih dari cukup.
Jebakan String.length
"a".length // 1 — BMP, satu code unit
"é".length // 1 — BMP (U+00E9), satu code unit
"中".length // 1 — BMP (U+4E2D), satu code unit
"😀".length // 2 — supplementary plane (U+1F600), surrogate pair!
"a😀".length // 3 — satu BMP + dua surrogate unit
String.prototype.length melaporkan jumlah code unit UTF-16, bukan jumlah karakter. Apa pun dari supplementary plane terbaca sebagai 2. Jebakan yang sama ada di String.length() Java dan string.Length C#.
Menghitung code point dengan benar di JS
[..."😀"].length // 1 — spread iterator menelusuri code point
Array.from("😀").length // 1 — Array.from juga menelusuri code point
"😀".match(/./gu).length // 1 — flag /u = regex unicode-aware
// "😀".charAt(0) mengembalikan high surrogate sendirian (visual rusak)
"😀".codePointAt(0) // 128512 — code point penuh U+1F600
Spread operator dan Array.from memakai protokol iterator, yang oleh spesifikasi bahasa didefinisikan sebagai menelusuri code point. Akses indeks polos (str[0], charAt) tetap mengembalikan code unit dan akan memberi Anda separuh surrogate pair pada emoji.
Python: len() sudah melakukan hal yang benar (hampir)
len("😀") # 1 — string Python 3 di-index per code point
len("👨👩👧") # 5 — code point (3 manusia + 2 ZWJ), bukan grapheme
# Python 2 di-index per byte secara default — len("😀") mengembalikan 4
Python 3 menyimpan string dalam representasi fleksibel 1-, 2-, atau 4-byte (PEP 393) dan meng-index per code point. len("😀") adalah 1, tapi itu masih bukan jumlah grapheme. Emoji keluarga tetap terbaca sebagai 5. Untuk menghitung karakter yang dipersepsikan user, Anda perlu library grapheme: Intl.Segmenter di JavaScript (Node 22+, semua browser modern), grapheme atau regex di Python, atau sekalian pakai Swift, yang String.count-nya adalah satu-satunya bahasa mainstream yang default-nya menghitung grapheme.
UTF-16 vs UCS-2: migrasi diam-diam
Sebelum 1996, Unicode menjanjikan akan muat dalam 16 bit dan encoding-nya adalah UCS-2, pemetaan tetap 2-byte. Unicode 2.0 mengingkari janji itu dengan menambahkan supplementary plane. UTF-16 adalah versi tambalannya yang memakai surrogate pair. Spesifikasi JavaScript masih menyebut kosakata UCS-2 lama di beberapa tempat, itulah sebabnya bahasa ini menoleransi lone surrogate yang seharusnya ilegal. Lelucon “WTF-16” itu benar adanya. Web platform API (DOM, fetch, TextEncoder) menolak lone surrogate karena tidak bisa di-encode menjadi UTF-8 yang valid.
UTF-32, BOM, dan Pertanyaan Byte Order
UTF-32: sederhana namun boros
UTF-32 memakai 4 byte tetap per code point. U+0041 disimpan sebagai 0x00000041, U+1F600 sebagai 0x0001F600. Keuntungannya adalah akses acak waktu-konstan: code point ke-n berada di offset byte 4n. Kerugiannya adalah ukuran. Teks ASCII murni membengkak menjadi empat kali jejak UTF-8-nya, dan bahkan teks CJK pun berlipat ganda. Hampir tidak ada sistem yang menyimpan UTF-32 di disk. Secara internal, Python 3 memilih 1, 2, atau 4 byte per string berdasarkan code point tertinggi; stack fontconfig Linux memakai UTF-32 untuk tabel glyph in-memory-nya.
Byte order: kenapa endianness penting untuk UTF-16 / UTF-32
UTF-8 adalah stream byte tunggal, jadi endianness tidak berlaku. UTF-16 dan UTF-32 beroperasi pada unit multi-byte, dan CPU yang berbeda tidak sepakat soal ujung angka mana yang lebih dulu.
U+0041 ('A') di UTF-16 BE → 00 41
U+0041 ('A') di UTF-16 LE → 41 00
CPU x86 dan ARM adalah little-endian; PowerPC lama dan “network byte order” adalah big-endian. Ketika Anda menulis file UTF-16, Anda harus memilih salah satu dan memberi tahu pembaca yang mana. Itulah fungsi BOM.
BOM: apa itu, kapan dipakai
Byte Order Mark adalah U+FEFF yang ditempatkan di awal file. Setelah di-encode, ia mengumumkan baik encoding maupun (untuk UTF-16 / UTF-32) byte order-nya.
| Encoding | Byte 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 utf-8 ada, tapi tidak membawa informasi byte-order karena UTF-8 tidak punya byte order. Satu-satunya tugasnya adalah menyatakan “file ini UTF-8”. Berguna untuk tool yang tidak punya sinyal lain, merusak untuk tool yang mengharapkan file dimulai dengan magic number atau direktif.
Matriks keputusan BOM: perlu saya tambahkan?
| Format | BOM UTF-8 | BOM UTF-16 | BOM UTF-32 |
|---|---|---|---|
| HTML | Tidak (merusak deteksi <!doctype> di parser lama) | — | — |
| JSON | Tidak (RFC 8259 melarangnya) | — | — |
| Source JavaScript / CSS | Hindari (Node lama dan IE tersedak) | — | — |
| CSV dibuka di Excel | Ya (Excel membaca UTF-8 non-BOM sebagai ANSI dan mengacak CJK) | — | — |
| XML | Opsional (deklarasi XML sudah menyatakan encoding) | Wajib | Wajib |
Plain text .txt | Opsional (Windows Notepad menambahkannya secara default) | Wajib | Wajib |
Aturan singkatnya: hilangkan BOM UTF-8 dari apa pun yang dilayani di web; tambahkan ke CSV yang ingin Anda buka di Excel; biarkan pembaca yang memutuskan untuk sisanya.
9 Bahasa Berdampingan: Perilaku Encoding Default
Pekerjaan lintas bahasa adalah ladang ranjau yang membuat pengetahuan ini berharga. String yang sama "a😀é" menghasilkan panjang berbeda di setiap runtime yang Anda panggil dari skrip Bash Anda.
Tabel perilaku lintas bahasa
| Bahasa | Encoding source file | Penyimpanan string | length / len menghitung | Encoding I/O default | Aman untuk emoji 4-byte? |
|---|---|---|---|---|---|
| JavaScript (V8 / SpiderMonkey) | UTF-8 | UTF-16 | code unit UTF-16 | UTF-8 (Node, Web) | Ya, tapi .length === 2 |
| Python 3 | UTF-8 (PEP 3120) | dinamis 1 / 2 / 4 byte (PEP 393) | code point | UTF-8 (PEP 540 sejak 3.7) | Ya, len === 1 |
| Java | UTF-8 (default javac) | UTF-16 | code unit UTF-16 | platform charset → UTF-8 (JEP 400, JDK 18+) | Ya, tapi .length() === 2 |
| Go | UTF-8 | byte UTF-8 | byte (utf8.RuneCountInString untuk code point) | UTF-8 | Ya, len(s) mengembalikan byte |
| Rust | UTF-8 | byte UTF-8 (invariant String) | .len() byte, .chars().count() code point | UTF-8 | Ya, eksplisit |
| C# (.NET) | UTF-8 (default sejak .NET Core 3.0) | UTF-16 | code unit UTF-16 | UTF-8 (Encoding.Default sejak .NET 5) | Ya, tapi .Length === 2 |
| Ruby | UTF-8 (sejak 2.0) | tag encoding per-string | code point (.length) | UTF-8 | Ya, length === 1 |
| PHP | (tidak ada encoding source) | byte string | byte (strlen); mb_strlen untuk code point | tergantung default_charset | Ya, dengan keluarga mb_* |
| MySQL | — | charset kolom | byte (LENGTH), karakter (CHAR_LENGTH) | variabel sistem character_set_* | Hanya dengan utf8mb4 |
Apa yang sebenarnya tabel ini katakan
Pola yang muncul:
- Internal UTF-8 (Go, Rust, Ruby). String native-nya adalah byte;
lengthterdefinisi jelas tapi menghitung apa yang ia hitung. Konversi ke code point atau grapheme hanya saat melewati batas UI atau validasi. - Internal UTF-16 (JavaScript, Java, C#). Warisan dari asumsi era 1990-an;
lengthadalah code unit, surrogate pair dihitung sebagai 2. Pakai iterasi yang code-point-aware untuk perhitungan apa pun yang ditampilkan ke user. - Indeks per code point (Python 3).
lenmemberikan code point, yang terasa benar sampai Anda bertemu ZWJ emoji. Saat itu Anda tetap butuh library grapheme.
PHP adalah kasus khusus. Semua fungsi str* bawaannya beroperasi pada byte, memperlakukan urutan UTF-8 sebagai blob buram. Setiap proyek non-ASCII harus pakai keluarga mb_* (multibyte), dan laporan bug tahun demi tahun menunjukkan seberapa sering hal itu terlewatkan.
Panduan praktisnya: pertahankan UTF-8 sebagai format wire di mana-mana (file, body HTTP, kolom database) dan konversi ke tipe string native runtime Anda di batas (boundary). Itu yang disebut “UTF-8 sandwich” dan akan kita bahas lagi di bagian 8.
8 Jebakan Engineering di Dunia Nyata
Pola di bawah ini muncul di setiap code review pada codebase yang sudah ter-globalisasi.
Jebakan 1: utf8 MySQL adalah kebohongan 3-byte, beralihlah ke utf8mb4
Gejala. INSERT INTO users (bio) VALUES ('Hello 😀'); mengembalikan Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio'.
Akar masalah. utf8 historis MySQL adalah alias untuk utf8mb3: varian UTF-8 yang dibatasi tiga byte per karakter. Code point apa pun di atas U+FFFF (setiap emoji, beberapa ribu karakter CJK langka, semua aksara historis) memerlukan empat byte UTF-8 dan akan ditolak.
Solusi.
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 masih mengirim utf8 sebagai alias utf8mb3. utf8mb3 deprecated tapi belum dihapus. Pakai utf8mb4 untuk setiap kolom baru, setiap database baru, setiap koneksi baru. Tidak ada keuntungan dari varian legacy.
Jebakan 2: Fallback Windows-1252, misteri tanda tanya
Gejala. Sebuah .txt yang di-export dari Notepad kolega Windows menampilkan "smart quotes" dan em dash di mesin mereka. Di server Anda, ia berubah jadi ? atau U+FFFD (replacement character).
Akar masalah. Notepad versi lama default ke Windows-1252 (CP-1252), yang meng-encode curly quote " sebagai 0x93. Decoder UTF-8 melihat 0x93 sebagai continuation byte yang tersesat (high bit 10) tanpa start byte sebelumnya, lalu mensubstitusinya dengan replacement character.
Solusi. Deteksi encoding sumber (file di Unix, chardet / charset-normalizer di Python, jschardet di Node), decode dengan codec yang benar, lalu re-encode sebagai UTF-8 sebelum disimpan. Standarisasi ke UTF-8 saat ingestion menghilangkan kekambuhan masalah ini.
Jebakan 3: Percent-encoding URL ≠ UTF-8 (tapi dibangun di atasnya)
Gejala. fetch("/search?q=中文") mengembalikan 404 dari satu framework backend dan berfungsi dari yang lain.
Akar masalah. Percent-encoding beroperasi pada byte, bukan code point. 中 adalah satu code point tapi tiga byte UTF-8 (E4 B8 AD), masing-masing di-percent-encode secara terpisah sebagai %E4%B8%AD, sembilan karakter ASCII di URL. Framework yang men-decode URL sebagai Latin-1 alih-alih UTF-8 akan memberikan handler tiga byte berantakan yang ditafsirkan sebagai tiga karakter satu-byte.
Solusi. Pakai encodeURIComponent("中文") di sisi client (browser melakukan UTF-8 + percent-encode dalam satu langkah) dan konfirmasi framework server men-decode URL sebagai UTF-8 (semua framework modern default ke ini). Untuk konfirmasi visual, tempel 中文 ke URL Decoder & Encoder Online Gratis — Decode & Parse URL dan lihat ia berubah jadi %E4%B8%AD%E6%96%87. Rantai lengkapnya dibahas di URL Encoding & Decoding: Panduan Praktis Percent Encoding.
Jebakan 4: Input Base64 adalah byte, tetapi Anda mengetik string
Gejala. btoa("你好") melempar InvalidCharacterError: The string contains characters outside the Latin1 range.
Akar masalah. btoa dirancang di era ASCII / Latin-1. Ia mengharapkan setiap karakter input muat dalam satu byte (code point 0-255). 你好 adalah UTF-16 di engine JS dengan code point U+4F60 U+597D, keduanya jauh di atas 255.
Solusi. Encode ke byte UTF-8 dulu, lalu Base64-encode byte tersebut.
// Salah:
btoa("你好"); // throws
// Benar:
const bytes = new TextEncoder().encode("你好");
// Uint8Array(6) [228, 189, 160, 229, 165, 189]
const b64 = btoa(String.fromCharCode(...bytes));
// "5L2g5aW9"
Cerita panjangnya ada di Apa Itu Base64 Encoding? Panduan untuk Pemula dan Base64 Tingkat Lanjut: MIME, Data URL, Performa & Keamanan; Base64 Encode & Decode Online Gratis — Konverter Instan melakukan konversi dalam satu langkah dan menampilkan stream byte perantara.
Jebakan 5: String.length untuk validasi (batas Twitter / SMS)
Gejala. Composer 280 karakter divalidasi di sisi client, lalu API mengembalikan 422. Atau sebaliknya, postingan yang sempurna ditolak oleh client.
Akar masalah. .length JavaScript menghitung code unit UTF-16; satu emoji terhitung sebagai 2. Twitter menghitung code point (emoji = 1). Jumlah karakter salah ke arah yang berlawanan tergantung API mana yang Anda percayai.
Solusi. Pakai [...text].length untuk jumlah code point, atau Intl.Segmenter untuk jumlah grapheme sejati (pendekatan Bluesky / iMessage). Angka per-platform dan batas SMS GSM-7 vs UCS-2 dikatalogkan di Batas Karakter & Kata 2026: Twitter, SMS, SEO, Instagram Online.
Jebakan 6: Emoji keluarga ZWJ terhitung sebagai N code point, 1 grapheme
Gejala. "👨👩👧".length === 8. Menghitung code point menghasilkan 5. Bagi user itu adalah satu gambar.
Akar masalah. Zero-Width Joiner (U+200D) merekatkan beberapa code point emoji menjadi satu cluster yang dirender: tiga emoji orang ditambah dua ZWJ sama dengan lima code point, delapan code unit UTF-16, satu grapheme.
Solusi.
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment("👨👩👧")].length; // 1
Intl.Segmenter ada di Node 22+ dan setiap browser modern. Untuk runtime yang lebih lama, package grapheme-splitter mengimplementasikan UAX #29.
Jebakan 7: Escape JSON \uXXXX, code point di atas U+FFFF butuh surrogate pair
Gejala. Payload JSON berisi "😀" dan decoder penerima entah merender-nya dengan benar sebagai 😀 atau menampilkan dua karakter kotak, tergantung apakah ia memahami surrogate pair di JSON.
Akar masalah. Escape \uXXXX JSON hanya menerima persis empat digit hex, yaitu satu code unit UTF-16. Meng-encode 😀 (U+1F600) memerlukan surrogate pair 😀. Tidak ada sintaks kurung kurawal \u{...} di JSON.
Solusi. Entah menerima surrogate pair (setiap parser yang patuh spesifikasi menanganinya) atau menulis emoji secara literal. JSON mengizinkan karakter UTF-8 apa pun di luar sintaks escape, dan mayoritas parser modern lebih menyukai bentuk itu.
Jebakan 8: Default Content-Type: charset= HTTP bukan seperti yang Anda pikirkan
Gejala. Halaman HTML UTF-8 dirender sebagai mojibake di satu browser dan dengan benar di browser lain.
Akar masalah. RFC 2616 awalnya mewajibkan ISO-8859-1 sebagai default untuk respons text/* tanpa charset eksplisit. RFC 7231 (2014) menghapus default tersebut, membiarkan setiap browser menebak. Sebagian melakukan sniff konten, sebagian fallback ke UTF-8, sebagian default ke locale sistem.
Solusi. Selalu kirim Content-Type: text/html; charset=utf-8 dari server dan <meta charset="utf-8"> di head dokumen. Salah satu saja sudah berfungsi; keduanya adalah pengaman ganda untuk proxy legacy yang menghapus header.
Untuk melihat salah satu jebakan ini secara langsung di level byte, Base64 Encode & Decode Online Gratis — Konverter Instan adalah mikroskop tercepat: tempel string, encode ke Base64, dan payload yang ter-decode adalah stream UTF-8-nya.
Memilih Encoding yang Tepat: Matriks Keputusan
Untuk pertanyaan utf-8 vs utf-16, jawabannya hampir selalu UTF-8. Tabel di bawah mencakup kasus pengecualiannya.
Matriks keputusan
| Skenario | Pilih | Mengapa |
|---|---|---|
| Halaman web, JSON API, source file | UTF-8 (tanpa BOM) | Kompatibel ASCII, tanpa byte order, paling kecil untuk teks Latin, RFC 8259 mewajibkan UTF-8 untuk JSON |
| Penyimpanan CJK berat (DB Cina, data game Jepang) | UTF-8 (utf8mb4) | UTF-8 memakai 3 byte per karakter CJK vs 2 byte UTF-16, namun overhead ASCII dari markup dan key JSON tetap menempatkan UTF-8 di depan secara praktis, dan ekosistem sekitarnya berbasis UTF-8 |
| API native Windows, kode Java / C# legacy | UTF-16 | Default platform; mengonversi di setiap pemanggilan API mengundang bug |
| Pemrosesan teks in-memory yang index-heavy | UTF-32 | Akses code point waktu-konstan; sepadan hanya untuk hot path parser |
| CSV dibuka di Excel pada Windows | UTF-8 dengan BOM | Excel membaca UTF-8 tanpa BOM sebagai ANSI dan mengacak header CJK |
| Proyek baru, tanpa kendala | UTF-8 (tanpa BOM) | W3C, IETF, dan Unicode Consortium sepakat |
Dua aturan praktis
- Default ke UTF-8 di mana-mana kecuali platform memaksa sebaliknya. W3C, IETF, dan Unicode Consortium sepakat.
- Konversi di batas (boundary), bukan di tengah. Decode byte ke tipe string native bahasa Anda saat ingest. Operasikan pada string, jangan pada byte, di logika bisnis. Encode kembali ke UTF-8 saat output. Pola “UTF-8 sandwich” ini menghilangkan seluruh kelas bug mojibake di tengah-pipeline.
Pertanyaan yang Sering Diajukan
Apakah UTF-8 selalu backward compatible dengan ASCII?
Ya. Setiap file ASCII yang valid bit-nya identik dengan representasi UTF-8-nya. 128 code point pertama (U+0000–U+007F) di-encode sebagai satu byte dengan high bit kosong. Tool legacy yang hanya ASCII (grep, sed awal, classic shell pipe) memproses file UTF-8 berisi ASCII murni tanpa modifikasi. Masalah baru muncul ketika byte non-ASCII (high bit di-set) masuk ke stream.
Haruskah saya memakai BOM UTF-8 di file saya?
Default-nya tidak. File HTML, JSON, JavaScript, dan CSS rusak atau memunculkan peringatan di beberapa parser ketika BOM ada di awal. Pengecualian standarnya adalah CSV yang ditujukan untuk Excel di Windows. Tanpa BOM, Excel menebak ANSI dan mengacak header dalam bahasa Mandarin, Jepang, atau Korea. Lihat matriks keputusan BOM di bagian 5.
Mengapa "😀".length === 2 di JavaScript?
String JavaScript disimpan sebagai UTF-16, dan .length mengembalikan jumlah code unit, bukan karakter. 😀 (U+1F600) ada di supplementary plane dan memerlukan surrogate pair (dua code unit 16-bit), sehingga .length-nya 2. Pakai [..."😀"].length, Array.from("😀").length, atau Intl.Segmenter untuk perhitungan yang benar.
Apa perbedaan antara Unicode dan UTF-8?
Unicode adalah tabel karakter yang memberi code point (angka seperti U+1F600) ke setiap karakter. UTF-8 adalah salah satu dari beberapa encoding yang menerjemahkan code point tersebut menjadi byte (1 sampai 4 byte per code point). Unicode mendefinisikan apa itu karakter; UTF-8 mendefinisikan bagaimana ia bepergian melalui file atau jaringan. UTF-16 dan UTF-32 adalah encoding alternatif untuk tabel Unicode yang sama.
Apakah utf8mb4 selalu lebih aman daripada utf8 di MySQL?
Ya untuk proyek baru. utf8 MySQL adalah varian utf8mb3 yang salah nama dan dibatasi 3-byte, yang tidak bisa menyimpan karakter apa pun di atas U+FFFF (setiap emoji, banyak karakter CJK langka, semua aksara historis). utf8mb4 adalah UTF-8 4-byte penuh. Satu catatan kecilnya adalah panjang index: setiap karakter utf8mb4 mungkin memakai 4 byte, sehingga batas index legacy InnoDB 767-byte membatasi index unik hingga 191 karakter (diatasi oleh innodb_large_prefix di MySQL 5.7+ dan menjadi default di 8.0).
Bagaimana cara mendeteksi encoding file yang tidak diketahui?
Pakai file di Unix, chardet atau charset-normalizer di Python, atau jschardet di Node. Tidak ada yang sempurna; semuanya menebak secara statistik dari distribusi byte. Deteksi UTF-8 sangat andal berkat pola continuation byte. Windows-1252, ISO-8859-1, dan encoding legacy single-byte lainnya nyaris tidak bisa dibedakan satu sama lain, sehingga deteksi sering bermuara pada heuristik bahasa.
Apakah UTF-16 bisa merepresentasikan setiap karakter Unicode?
Ya. UTF-16 mencakup semua 1.114.112 code point. Karakter BMP (U+0000–U+FFFF) memakai satu code unit 16-bit (2 byte), dan karakter supplementary plane (U+10000–U+10FFFF) memakai surrogate pair (4 byte). Cakupannya identik dengan UTF-8 dan UTF-32; hanya layout byte dan semantik pemrosesannya yang berbeda. Pilihan di antaranya adalah soal kecocokan ekosistem, bukan kapabilitas.