📦

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:

BytesCode point rangeLeading byte patternTotal bits for CP
1U+0000 .. U+007F0xxxxxxx7
2U+0080 .. U+07FF110xxxxx 10xxxxxx11
3U+0800 .. U+FFFF1110xxxx 10xxxxxx 10xxxxxx16
4U+10000 .. U+10FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx21

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:

ScriptRangeUTF-8 bytesExample
ASCII / Latin basicU+0000..U+007F1 byteA = 0x41
Latin extended / CyrillicU+0080..U+07FF2 bytesé = 0xC3 0xA9
CJK IdeographsU+4E00..U+9FFF3 bytes漢 = 0xE6 0xBC 0xA2
Emoji / SMPU+10000..U+10FFFF4 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 typeUTF-8 bytesUTF-16 bytesWinner
ASCII text (English code)1 per char2 per charUTF-8 (50% smaller)
European text (Latin ext.)1-2 per char2 per charUTF-8 (slightly smaller)
CJK text (Chinese/Japanese)3 per char2 per charUTF-16 (33% smaller)
Emoji-heavy text4 per char4 per char (surrogate)Tie
Mixed (HTML with CJK)~2.2 avg2 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 ASCII

UTF-8 の 4 つのバイト範囲

UTF-8 は可変長エンコーディングで、すべての Unicode コードポイントを 1〜4 バイトで表現します。バイト数はコードポイントの範囲で決まります:

バイト数コードポイント範囲先頭バイトパターンCP用ビット数
1U+0000 .. U+007F0xxxxxxx7
2U+0080 .. U+07FF110xxxxx 10xxxxxx11
3U+0800 .. U+FFFF1110xxxx 10xxxxxx 10xxxxxx16
4U+10000 .. U+10FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx21

最初の 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+007F1 バイトA = 0x41
ラテン拡張 / キリルU+0080..U+07FF2 バイトé = 0xC3 0xA9
CJK 統合漢字U+4E00..U+9FFF3 バイト漢 = 0xE6 0xBC 0xA2
絵文字 / SMPU+10000..U+10FFFF4 バイト🌍 = 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 が圧勝