UTF-8 Byte by Byte: How Characters Become BytesUTF-8 バイト解剖: 文字がバイトになるまで
A visual, byte-level walkthrough of UTF-8 encoding showing exactly how code points map to 1-4 bytes.UTF-8 エンコーディングのバイトレベル解説。コードポイントが1〜4バイトにマップされる仕組み。
The Four Ranges of UTF-8
UTF-8 is a variable-length encoding that represents every Unicode code point using 1 to 4 bytes. The number of bytes depends on the code point range:
| Bytes | Code point range | Leading byte pattern | Total bits for CP |
|---|---|---|---|
| 1 | U+0000 .. U+007F | 0xxxxxxx | 7 |
| 2 | U+0080 .. U+07FF | 110xxxxx 10xxxxxx | 11 |
| 3 | U+0800 .. U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx | 16 |
| 4 | U+10000 .. U+10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | 21 |
The first 128 code points (ASCII) use a single byte identical to ASCII itself. This backward compatibility was a deliberate design decision by Ken Thompson and Rob Pike — any valid ASCII file is automatically valid UTF-8.
The 2-byte range covers Latin extended characters, Greek, Cyrillic, Arabic, and Hebrew. Most European languages fit entirely within 1-2 bytes per character.
Bit-Level Anatomy of UTF-8
Let's trace exactly how a code point becomes bytes. Take é (U+00E9, Latin small letter e with acute), which falls in the 2-byte range (U+0080..U+07FF):
U+00E9 in binary: 000 1110 1001 (11 bits) Split into groups: [00011] [101001] Insert into template: 110xxxxx 10xxxxxx Result: 11000011 10101001 Hex: 0xC3 0xA9
Now consider 漢 (U+6F22, CJK ideograph), which falls in the 3-byte range:
U+6F22 in binary: 0110 1111 0010 0010 (16 bits) Split into groups: [0110] [111100] [100010] Insert into template: 1110xxxx 10xxxxxx 10xxxxxx Result: 11100110 10111100 10100010 Hex: 0xE6 0xBC 0xA2
The key insight: the leading byte's high bits tell the decoder exactly how many bytes follow. A byte starting with 0 is a standalone ASCII byte. A byte starting with 110 means “read 1 more continuation byte.” Starting with 1110 means “read 2 more.” And 11110 means “read 3 more.” Continuation bytes always start with 10.
Why CJK = 3 Bytes and Emoji = 4 Bytes
One of the most common questions: why do Chinese, Japanese, and Korean characters take 3 bytes in UTF-8? The answer lies in code point allocation:
| Script | Range | UTF-8 bytes | Example |
|---|---|---|---|
| ASCII / Latin basic | U+0000..U+007F | 1 byte | A = 0x41 |
| Latin extended / Cyrillic | U+0080..U+07FF | 2 bytes | é = 0xC3 0xA9 |
| CJK Ideographs | U+4E00..U+9FFF | 3 bytes | 漢 = 0xE6 0xBC 0xA2 |
| Emoji / SMP | U+10000..U+10FFFF | 4 bytes | 🌍 = 0xF0 0x9F 0x8C 0x8D |
The CJK Unified Ideographs block spans U+4E00 to U+9FFF (over 20,000 characters), placing them squarely in the 3-byte zone. This means a Chinese or Japanese text file in UTF-8 is roughly 50% larger than the same file in a dedicated CJK encoding like GB2312 or Shift_JIS (which use 2 bytes per character).
Emoji live in the Supplementary Multilingual Plane (U+1F000 and above), which requires 4 bytes. A single emoji like 🌍 (U+1F30D) takes 4 bytes in UTF-8, 4 bytes in UTF-32, but only 2 bytes worth of “logical space” as a surrogate pair in UTF-16.
Self-Synchronizing: UTF-8's Killer Feature
UTF-8 has a remarkable property: you can jump to any arbitrary byte in a stream and determine whether you are at the start of a character or in the middle of one. This is called self-synchronization.
The rules are simple: if a byte starts with 0, it's a single-byte character. If it starts with 10, it's a continuation byte — scan backward to find the leading byte. If it starts with 11, it's the leading byte of a multi-byte sequence.
Byte pattern Meaning 0xxxxxxx Single-byte character (ASCII) 10xxxxxx Continuation byte (never a start) 110xxxxx Start of 2-byte sequence 1110xxxx Start of 3-byte sequence 11110xxx Start of 4-byte sequence
This design means that if a single byte is corrupted or lost, at most one character is destroyed — the decoder can resynchronize at the next leading byte. Compare this with Shift_JIS, where losing a single byte can cause all subsequent characters to be misinterpreted (the “mojibake cascade” problem).
Another benefit: you can search for an ASCII substring (like / in a file path) using simple byte comparison without any risk of false matches inside multi-byte characters. This is impossible with Shift_JIS, where a trail byte can coincidentally equal an ASCII byte value (e.g., the infamous 0x5C backslash problem).
UTF-8 vs UTF-16: Size Comparison
Which encoding is more space-efficient depends entirely on the content:
| Content type | UTF-8 bytes | UTF-16 bytes | Winner |
|---|---|---|---|
| ASCII text (English code) | 1 per char | 2 per char | UTF-8 (50% smaller) |
| European text (Latin ext.) | 1-2 per char | 2 per char | UTF-8 (slightly smaller) |
| CJK text (Chinese/Japanese) | 3 per char | 2 per char | UTF-16 (33% smaller) |
| Emoji-heavy text | 4 per char | 4 per char (surrogate) | Tie |
| Mixed (HTML with CJK) | ~2.2 avg | 2 per char (+ BOM) | Close to tie |
For web content, UTF-8 almost always wins because HTML markup, CSS, JavaScript, and URLs are ASCII-heavy. Even a Japanese web page has so much ASCII in its markup that UTF-8 tends to be comparable or smaller than UTF-16. This is one reason the WHATWG HTML specification mandates UTF-8 as the default encoding.
UTF-16 retains an advantage for in-memory string processing of CJK-heavy text (which is why Java, JavaScript, and Windows chose it as their internal string format in the 1990s). However, for storage and network transfer, UTF-8 has become the universal standard — over 98% of web pages use UTF-8 as of 2024.
// Quick size comparison in Node.js:
const text = "漢字とASCII mixed テキスト";
console.log(Buffer.byteLength(text, "utf8")); // 39 bytes
console.log(Buffer.byteLength(text, "utf16le")); // 36 bytes
// UTF-16 wins slightly for CJK-heavy mixed text
const code = "function hello() { return 42; }";
console.log(Buffer.byteLength(code, "utf8")); // 31 bytes
console.log(Buffer.byteLength(code, "utf16le")); // 62 bytes
// UTF-8 wins decisively for ASCIIUTF-8 の 4 つのバイト範囲
UTF-8 は可変長エンコーディングで、すべての Unicode コードポイントを 1〜4 バイトで表現します。バイト数はコードポイントの範囲で決まります:
| バイト数 | コードポイント範囲 | 先頭バイトパターン | CP用ビット数 |
|---|---|---|---|
| 1 | U+0000 .. U+007F | 0xxxxxxx | 7 |
| 2 | U+0080 .. U+07FF | 110xxxxx 10xxxxxx | 11 |
| 3 | U+0800 .. U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx | 16 |
| 4 | U+10000 .. U+10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | 21 |
最初の 128 コードポイント(ASCII)は ASCII と同一の 1 バイトで表現されます。この後方互換性は Ken Thompson と Rob Pike による意図的な設計判断でした。有効な ASCII ファイルはそのまま有効な UTF-8 ファイルです。
2 バイト範囲はラテン拡張文字、ギリシャ文字、キリル文字、アラビア文字、ヘブライ文字をカバーします。ほとんどのヨーロッパ言語は 1〜2 バイト/文字に収まります。
ビットレベルの UTF-8 解剖
コードポイントがどのようにバイト列になるか、正確にたどってみましょう。é(U+00E9、アキュートアクセント付き e)は 2 バイト範囲(U+0080..U+07FF)に該当します:
U+00E9 のバイナリ: 000 1110 1001 (11ビット) グループに分割: [00011] [101001] テンプレートに挿入: 110xxxxx 10xxxxxx 結果: 11000011 10101001 16進数: 0xC3 0xA9
次に 漢(U+6F22、CJK 統合漢字)を見ましょう。3 バイト範囲に該当します:
U+6F22 のバイナリ: 0110 1111 0010 0010 (16ビット) グループに分割: [0110] [111100] [100010] テンプレートに挿入: 1110xxxx 10xxxxxx 10xxxxxx 結果: 11100110 10111100 10100010 16進数: 0xE6 0xBC 0xA2
重要なポイント: 先頭バイトの上位ビットが、後続バイト数を正確に示します。0 で始まれば単独の ASCII バイト。110 で始まれば「あと 1 バイト続く」。1110 は「あと 2 バイト」。11110 は「あと 3 バイト」。継続バイトは必ず 10 で始まります。
なぜ CJK は 3 バイト、絵文字は 4 バイトなのか
よくある疑問: なぜ日中韓の漢字は UTF-8 で 3 バイト必要なのか? 答えはコードポイントの配置にあります:
| 文字種 | 範囲 | UTF-8 バイト数 | 例 |
|---|---|---|---|
| ASCII / 基本ラテン | U+0000..U+007F | 1 バイト | A = 0x41 |
| ラテン拡張 / キリル | U+0080..U+07FF | 2 バイト | é = 0xC3 0xA9 |
| CJK 統合漢字 | U+4E00..U+9FFF | 3 バイト | 漢 = 0xE6 0xBC 0xA2 |
| 絵文字 / SMP | U+10000..U+10FFFF | 4 バイト | 🌍 = 0xF0 0x9F 0x8C 0x8D |
CJK 統合漢字ブロックは U+4E00 から U+9FFF まで(2 万文字以上)あり、完全に 3 バイトゾーンに収まります。つまり、中国語や日本語のテキストは UTF-8 では、GB2312 や Shift_JIS(1 文字 2 バイト)と比べて約 50% サイズが大きくなります。
絵文字は追加多言語面(U+1F000 以降)にあるため、4 バイト必要です。🌍(U+1F30D)1 つで UTF-8 は 4 バイト、UTF-32 も 4 バイト、UTF-16 ではサロゲートペアとして 4 バイト(2 つの 16 ビットユニット)です。
自己同期: UTF-8 最大の強み
UTF-8 には注目すべき特性があります: バイト列の任意の位置にジャンプしても、そこが文字の先頭か途中かを即座に判定できます。これを自己同期(self-synchronization)と呼びます。
ルールは単純です: バイトが 0 で始まれば 1 バイト文字。10 で始まれば継続バイトなので、先頭バイトを見つけるまで後退。11 で始まればマルチバイト列の先頭バイトです。
バイトパターン 意味 0xxxxxxx 1バイト文字(ASCII) 10xxxxxx 継続バイト(開始位置にはならない) 110xxxxx 2バイト列の先頭 1110xxxx 3バイト列の先頭 11110xxx 4バイト列の先頭
この設計により、1 バイトが破損・欠落しても壊れるのは最大 1 文字だけです。デコーダは次の先頭バイトで再同期できます。Shift_JIS と比較すると、Shift_JIS では 1 バイトの欠落が後続の全文字を誤解釈させる可能性があります(「文字化けの連鎖」問題)。
もう一つの利点: ファイルパスの / のような ASCII 部分文字列を単純なバイト比較で検索でき、マルチバイト文字の内部で誤マッチする危険がありません。Shift_JIS ではこれが不可能です — 後続バイトが偶然 ASCII バイト値と一致することがあるためです(有名な 0x5C バックスラッシュ問題)。
UTF-8 と UTF-16 のサイズ比較
どちらのエンコーディングが効率的かは、コンテンツの内容次第です:
| コンテンツ種別 | UTF-8 バイト数 | UTF-16 バイト数 | 有利な方 |
|---|---|---|---|
| ASCII テキスト(英語コード) | 1/文字 | 2/文字 | UTF-8(50% 小さい) |
| ヨーロッパ語テキスト | 1-2/文字 | 2/文字 | UTF-8(やや小さい) |
| CJK テキスト(中国語/日本語) | 3/文字 | 2/文字 | UTF-16(33% 小さい) |
| 絵文字多めテキスト | 4/文字 | 4/文字(サロゲート) | 同等 |
| 混在(HTML + CJK) | ~2.2 平均 | 2/文字(+ BOM) | ほぼ同等 |
Web コンテンツでは UTF-8 がほぼ常に有利です。HTML マークアップ、CSS、JavaScript、URL は ASCII が大部分を占めるためです。日本語の Web ページでも、マークアップの ASCII 部分が多いため、UTF-8 のサイズは UTF-16 と同等かそれ以下になります。WHATWG HTML 仕様が UTF-8 をデフォルトエンコーディングに定めているのもこの理由です。
UTF-16 は CJK 中心のテキストのメモリ内文字列処理では優位性を保ちます(Java、JavaScript、Windows が 1990 年代に内部文字列形式として採用した理由)。しかし保存やネットワーク転送では UTF-8 が事実上の標準となり、2024 年時点で Web ページの 98% 以上が UTF-8 を使用しています。
// Node.js でのサイズ比較:
const text = "漢字とASCII mixed テキスト";
console.log(Buffer.byteLength(text, "utf8")); // 39 バイト
console.log(Buffer.byteLength(text, "utf16le")); // 36 バイト
// CJK 混在テキストでは UTF-16 がやや有利
const code = "function hello() { return 42; }";
console.log(Buffer.byteLength(code, "utf8")); // 31 バイト
console.log(Buffer.byteLength(code, "utf16le")); // 62 バイト
// ASCII では UTF-8 が圧勝