UTF-8 vs UTF-16 vs Unicode エンコーディングガイド
utf-8 unicode encoding で検索する人がまず知りたい一文から始めよう。Unicode と UTF-8 は別物だ。Unicode は巨大な番号付きテーブルで、各文字に「コードポイント」(U+1F600 のような番号)を割り当てる。UTF-8、UTF-16、UTF-32 は、そのコードポイントをバイト列に変換するための別々の エンコーディング である。ほぼ常に選ぶべきは UTF-8 だ。英語テキストでは ASCII とバイト単位で完全に同一で、絵文字なら 4 バイトまで拡張でき、JSON、HTML5、現代のプロトコルではこれを前提にしている。
このガイドは、痛い目に遭った開発者のために書いた。😀 を入れたら MySQL に Incorrect string value と怒られた人、JavaScript で "😀".length === 2 という結果に驚いた人、cat ではきれいに見える CSV が Excel で文字化けする人。コードポイントから始めて、UTF-8 のバイト機構、サロゲートペア、BOM、9 言語のデフォルト挙動、本番で踏みがちな 8 つの落とし穴までを順にたどり、最後に判断マトリクスと FAQ で締める。
読みながらバイト列を検証したい? 文字列を Base64デコード・エンコード 無料オンライン変換ツール に貼り付けてみよう。デコード後のペイロードが、まさにこの記事で説明する UTF-8 バイト列そのものだ。
2026 年になってもエンコーディングが噛みついてくる理由
直近 12 か月の実際のバグトラッカーから、3 つのシナリオを紹介する。
- MySQL が絵文字を拒否する。 ユーザーが
Hello 😀を送信すると、サーバーがIncorrect string value: '\xF0\x9F\x98\x80'を返す。テーブルはutf8で、開発者は「UTF-8 のはずなのに何が悪い?」と頭を抱える。答えは MySQL の歴史の奥に埋もれている(第 7 章で扱う)。 - 文字数カウンターが壊れたまま出荷される。 280 文字制限のツイートバリデーターが
text.lengthを使い、絵文字だらけのメッセージを通してしまい、API に弾かれる。逆に、有効な投稿がフロントエンドで拒否されることもある。この症状は第 4 章で解明する。 - ローカル HTML が「䏿–‡」に化ける。 開発者が Windows-1252 でファイルを保存し、UTF-8 と推測するブラウザで開き、文字化けが咲き乱れる。これは第 5 章の BOM/charset 宣言の話で、URLエンコード・デコード実践ガイド:パーセントエンコーディングの仕組みと落とし穴 でも同じバイト対文字の食い違いがクエリ文字列を壊している。
このガイドの約束はこうだ。最後のページを読み終える頃には、(a) Unicode と UTF-8 を一文で区別でき、(b) 新規プロジェクトで UTF-8、UTF-16、UTF-32 のどれを選ぶか判断でき、(c) 主要言語すべてで絵文字を正しく数えるコードを書け、(d) バイト列だけから charset バグをデバッグできる。文字コードの穴は深いが、実務で必要な範囲は意外と狭い。
Unicode とは何か? コードポイント vs 文字 vs グリフ
Unicode は文字テーブルだ。各文字に唯一の番号——コードポイント(U+1F600 のような)を割り当てる。UTF-8、UTF-16、UTF-32 はそのコードポイントをバイト列に変換するエンコーディングである。Unicode 自体はバイトを保存しない。抽象的な文字から整数へのマッピングを定義するだけだ。
会話を濁す用語があと三つある。これらはしばしば同じ「目に見える印」を指してしまう。
区別すべき 3 つのレイヤー
- コードポイント(
U+0041、U+1F600):Unicode が割り当てる整数。空間はU+0000からU+10FFFFまで、約 110 万枠あり、現時点で約 15 万が割り当て済みだ。 - 文字(または抽象文字):意味的な同一性。ラテン大文字 A、にっこり顔の絵文字 など。
- グリフ:フォントが描画する視覚的な形。1 文字には多数のグリフがある。セリフ体の A、イタリック体の A、手書きの A など。Unicode はグリフには関知しない。
- 書記素クラスター:ユーザーが「1 文字」と知覚する単位。1 つのコードポイントの場合もあれば、複数の場合もある。文字 á は 1 つのコードポイント
U+00E1でも、2 つのコードポイントa + U+0301(結合アキュートアクセント)でも表せる。文字数・単語数の制限 2026 — Twitter、SMS、SEO、Instagram では、Twitter、SMS、SEO がそれぞれこの線をどう引くかを掘り下げている。
他のことは忘れてもよいが、これだけは覚えておこう。コードポイント → エンコーディング → バイト → 描画。それぞれの矢印が独立に壊れうる。
コードポイント表記——U+XXXX と \uXXXX
コードポイントは複数の書き方で目にする。U+0041 は正規の Unicode 表記で、U+ を接頭辞とした 4〜6 桁の 16 進数だ。ソースコード内では次のようになる。
- JavaScript / JSON:
"A"(4 桁 16 進、BMP のみ)と"\u{1F600}"(ES6 のブレース、任意のコードポイント)。 - Python:
"A"(4 桁)、"\U00000041"(8 桁、大文字 U)、"\N{LATIN CAPITAL LETTER A}"(名前指定)。 - シェル/git log/sed 出力:
éに対する\xc3\xa9のような生の UTF-8 バイト列をよく目にする。これはコードポイントではなくエンコード後の形であり、第 3 章へとつながる。
17 のプレーン——BMP とその先
Unicode はコードポイント空間を 17 個の プレーン に分割し、各プレーンは 65,536 コードポイントを持つ(17 × 2^16 = 1,114,112)。
- プレーン 0、基本多言語面(BMP):
U+0000からU+FFFFまで。ラテン、CJK 漢字、キリル、アラビア、ギリシャなど、レガシーテキストで遭遇するほぼすべての文字体系はここに住んでいる。 - プレーン 1〜16、追加面(supplementary planes):
U+10000からU+10FFFFまで。ほとんどの絵文字(U+1F600とその仲間)、稀少な CJK 文字、歴史的文字(エジプトのヒエログリフ、楔形文字)、音楽記号などが含まれる。
U+FFFF という BMP と追加面の境界は、この記事で最も重要な数字だ。UTF-16 が「1 文字 1 コードユニット」をやめる地点であり、UTF-8 が 3 バイトから 4 バイトに跳ね上がる地点であり、MySQL の名前を誤った utf8 コレーションが諦める地点でもある。
絵文字でクイックチェック
"a" → 1 codepoint U+0061 → 1 grapheme
"é" (NFC) → 1 codepoint U+00E9 → 1 grapheme
"é" (NFD) → 2 codepoints U+0065 U+0301 → 1 grapheme
"😀" → 1 codepoint U+1F600 (Plane 1) → 1 grapheme
"👨👩👧" → 5 codepoints (3 people + 2 ZWJ U+200D) → 1 grapheme
最後の行が肝だ。家族絵文字はユーザーから見れば 1 文字だが、Zero-Width Joiner で接合された 5 コードポイントである。スタックのどのレイヤーも数え方が違いうるし、第 7 章の罠 6 はまさにこの食い違いから起票されたバグ報告である。
UTF-8 のエンコーディング機構——1〜4 バイトの仕組み
UTF-8 は Unicode コードポイントを 1〜4 バイトでエンコードする。ASCII(U+0000〜U+007F)は 1 バイトで、ASCII とバイト単位で同一だ。それより高いコードポイントは複数バイト列を使い、先頭バイトが全長を示し、後続バイトはすべて 10xxxxxx のビットパターンで始まる。この自己記述的なレイアウトが、UTF-8 がエンコーディング戦争で生き残った最大の理由だ。
バイトパターン表——UTF-8 を 1 枚の図で
| コードポイント範囲 | UTF-8 バイト数 | バイトパターン |
|---|---|---|
U+0000 – U+007F | 1 バイト | 0xxxxxxx |
U+0080 – U+07FF | 2 バイト | 110xxxxx 10xxxxxx |
U+0800 – U+FFFF | 3 バイト | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 – U+10FFFF | 4 バイト | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
各 x はコードポイントの 2 進数表現から取られるデータビットだ。先頭の 0 / 110 / 1110 / 11110 はデコーダーに全何バイトかを伝え、先頭の 10 はそれが後続バイトであることを示す。この冗長性のおかげで UTF-8 は自己同期可能になる。1 バイト落としても次の開始バイトから再開でき、後続すべてが壊滅することはない。
ワークドエグザンプル——中(U+4E2D)をエンコードする
コードポイント 0x4E2D は U+0800〜U+FFFF の範囲に入るので、3 バイトテンプレートを使う。
- 2 進数:
0x4E2D=0100 1110 0010 1101(16 ビット)。 xスロットに収まるよう 4-6-6 で分割:0100 / 111000 / 101101。1110xxxx 10xxxxxx 10xxxxxxに代入:11100100 10111000 10101101。- 16 進:
0xE4 0xB8 0xAD。
これがまさに 中 が URL エンコード後に %E4%B8%AD になる理由だ。パーセントエンコーディングは各 UTF-8 バイトを %XX で包むのであって、コードポイントを直接エンコードするわけではない。第 7 章の罠 3 でこの連鎖を詳しく見る。
ワークドエグザンプル——😀(U+1F600)をエンコードする
コードポイント 0x1F600 は BMP を超えるので、4 バイトテンプレートを使う。
- 2 進数:
0x1F600=0 0001 1111 0110 0000 0000(21 ビット、パディング込み)。 - 3-6-6-6 で分割:
000 / 011111 / 011000 / 000000。 11110xxx 10xxxxxx 10xxxxxx 10xxxxxxに代入:11110000 10011111 10011000 10000000。- 16 進:
0xF0 0x9F 0x98 0x80。
この 4 バイトこそ MySQL の utf8 コレーションが詰まるものだ。1 文字あたり最大 3 バイトしか割り当てないからである。第 7 章の罠 1 に修正方法がある。
UTF-8 が生き残った 3 つの理由
- ASCII 互換性。 純粋な ASCII テキストのファイルは、その UTF-8 エンコーディングとバイト単位で同一だ。Unicode 以前の数十年にわたるツール群(
grep、awk、古典的なシェルパイプ)は、その部分集合に対して動き続ける。 - 自己同期。 後続バイトは常に
10で始まり、これはどの開始バイトとも衝突しない。ネットワーク転送で 1 バイト失っても次の文字境界で再同期でき、ゴミが連鎖することはない。 - バイト順序なし。 UTF-8 はバイトの流れであり、16 ビットや 32 ビット単位ではないので、エンディアンは無関係だ。UTF-16 と UTF-32 はどちらの端が先かを宣言するためにバイトオーダーマークを必要とするが、UTF-8 にはそれが要らない(むしろ通常は付けるべきではない。第 5 章を参照)。
不正な UTF-8——仕様が禁じるもの
厳密なデコーダーは以下のバイト列を拒否する。
- 5 バイトまたは 6 バイト列。 初期の RFC は許容していたが、RFC 3629(2003 年)は 21 ビットの Unicode 空間に合わせて UTF-8 を 4 バイトに制限した。
- 過剰長エンコーディング。
/を 1 バイト0x2Fではなく 3 バイト0xE0 0x80 0xAFでエンコードする形。かつて、サニタイズ後にデコードするパスバリデーターでディレクトリトラバーサル攻撃の温床となった。 - 孤立したサロゲートコードポイント(
U+D800〜U+DFFF)。これらは UTF-16 のために予約されており、UTF-8 に現れてはならない。 - 切り詰められた列。 3 バイト開始バイトの後に後続バイトが 1 つしか続かない場合など。マルチバイト文字の途中でユーザー入力がバイト境界で切られたときによく起きる。
具体的に見たいなら、文字列を Base64デコード・エンコード 無料オンライン変換ツール に入れ、エンコードし、バイトとしてデコードし直してみよう。エンコーダーとデコーダーの間にあるバイト配列が、まさにこの章で説明している UTF-8 ストリームである。
UTF-16 とサロゲートペア——なぜ JavaScript の length は嘘をつくのか
utf-8 vs utf-16 周辺の最頻検索は、実は「なぜ私のコードで "😀".length が 2 になるのか?」だ。答えはサロゲートペアで、JavaScript、Java、C#、Windows がすべて受け継いだ 1990 年代の決定である。
UTF-16 を一段落で
UTF-16 は Unicode を 16 ビットの コードユニット で表現する。BMP(U+0000〜U+FFFF)の文字はちょうど 1 コードユニットを取る。追加面(U+10000〜U+10FFFF)の文字は 2 コードユニットを取り、これを サロゲートペア と呼ぶ。U+D800〜U+DBFF の上位サロゲートの後に U+DC00〜U+DFFF の下位サロゲートが続く形だ。U+D800〜U+DFFF のブロックは Unicode で恒久的に予約されており、ここに実在の文字が住むことはない。UTF-16 は JavaScript、Java、C#(.NET)、Windows カーネル API、Objective-C NSString、Qt の内部文字列形式である。いずれも 65,536 文字で十分に見えた時代に設計された。
String.length の罠
"a".length // 1 — BMP, single code unit
"é".length // 1 — BMP (U+00E9), single code unit
"中".length // 1 — BMP (U+4E2D), single code unit
"😀".length // 2 — supplementary plane (U+1F600), surrogate pair!
"a😀".length // 3 — one BMP + two surrogate units
String.prototype.length は UTF-16 コードユニットの数を返すのであって、文字数ではない。追加面のものはすべて 2 と読まれる。同じ罠は Java の String.length() と C# の string.Length にも存在する。
JS でコードポイントを正しく数える
[..."😀"].length // 1 — spread iterator walks codepoints
Array.from("😀").length // 1 — Array.from also walks codepoints
"😀".match(/./gu).length // 1 — /u flag = unicode-aware regex
// "😀".charAt(0) returns the lone high surrogate (visually broken)
"😀".codePointAt(0) // 128512 — the full codepoint U+1F600
スプレッド演算子と Array.from はイテレーターのプロトコルを使い、言語仕様ではこれをコードポイント単位で歩くものと定義している。素のインデックスアクセス(str[0]、charAt)はコードユニットを返し続け、絵文字に対してはサロゲートペアの片割れを渡してくる。
Python——len() はすでに正しいことをしている(ほぼ)
len("😀") # 1 — Python 3 strings are codepoint-indexed
len("👨👩👧") # 5 — codepoints (3 humans + 2 ZWJ), not graphemes
# Python 2 was byte-indexed by default — len("😀") returned 4
Python 3 は文字列を 1/2/4 バイトの可変表現で保持し(PEP 393)、コードポイントでインデックスする。len("😀") は 1 だが、それでも書記素の数ではない。家族絵文字は依然として 5 と読まれる。ユーザーが知覚する文字を数えるには書記素ライブラリが要る。JavaScript なら Intl.Segmenter(Node 22 以降、現行のすべてのブラウザ)、Python なら grapheme や regex、あるいは Swift——主流言語の中で String.count がデフォルトで書記素を数える唯一の言語だ。
UTF-16 vs UCS-2——静かなる移行
1996 年以前、Unicode は 16 ビットに収まると約束しており、対応するエンコーディングは UCS-2、つまり固定 2 バイトのマッピングだった。Unicode 2.0 は追加面を加えてその約束を破った。UTF-16 はサロゲートペアで取り繕った版である。JavaScript の仕様は今でも各所で古い UCS-2 の語彙を引きずっており、これが言語が本来不正なはずの孤立サロゲートを許容する理由でもある。「WTF-16」というジョークは現実だ。Web プラットフォーム API(DOM、fetch、TextEncoder)は孤立サロゲートを拒否する。有効な UTF-8 にエンコードできないからである。
UTF-32、BOM、そしてバイト順序の問題
UTF-32——シンプルだが無駄も多い
UTF-32 はコードポイントあたり固定 4 バイトを使う。U+0041 は 0x00000041 として、U+1F600 は 0x0001F600 として格納される。利点は定数時間のランダムアクセスで、n 番目のコードポイントはバイトオフセット 4n にある。欠点はサイズで、純粋な ASCII テキストは UTF-8 のフットプリントの 4 倍に膨らみ、CJK テキストでも 2 倍になる。ディスクに UTF-32 を保存するシステムはほぼない。内部表現としては、Python 3 は文字列ごとに最大コードポイントに応じて 1/2/4 バイトを選ぶし、Linux の fontconfig スタックはメモリ内グリフテーブルに UTF-32 を使う。
バイト順序——UTF-16/UTF-32 でなぜエンディアンが効くのか
UTF-8 は単一バイトの流れなので、エンディアンは適用されない。UTF-16 と UTF-32 はマルチバイト単位で動作し、CPU ごとに数字のどちら端が先かで意見が割れる。
U+0041 ('A') in UTF-16 BE → 00 41
U+0041 ('A') in UTF-16 LE → 41 00
x86 と ARM CPU はリトルエンディアン、古い PowerPC と「ネットワークバイトオーダー」はビッグエンディアンだ。UTF-16 ファイルを書くときはどちらかを選び、読み手にそれを伝えなければならない。そのためのものが BOM である。
BOM——何で、いつ使うか
バイトオーダーマークはファイル先頭に置かれた U+FEFF のことだ。エンコードされると、エンコーディングと(UTF-16/UTF-32 については)バイト順序の両方を宣言する。
| エンコーディング | 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 |
utf-8 BOM も存在するが、UTF-8 にはバイト順序がないので、バイト順序情報は持たない。その唯一の役割は「このファイルは UTF-8 である」と宣言することだ。他に手がかりのないツールには有用だが、ファイルがマジックナンバーやディレクティブで始まることを期待するツールには有害である。
BOM 判断マトリクス——付けるべきか?
| 形式 | UTF-8 BOM | UTF-16 BOM | UTF-32 BOM |
|---|---|---|---|
| HTML | 不要(古いパーサーで <!doctype> 検出が壊れる) | — | — |
| JSON | 不要(RFC 8259 が禁止) | — | — |
| JavaScript/CSS ソース | 避ける(古い Node や IE が詰まる) | — | — |
| Excel で開く CSV | 必要(Excel は BOM なし UTF-8 を ANSI と解釈し CJK を壊す) | — | — |
| XML | 任意(XML 宣言が既にエンコーディングを示す) | 必須 | 必須 |
プレーンテキスト .txt | 任意(Windows メモ帳はデフォルトで付ける) | 必須 | 必須 |
短いルール:Web で配信するものからは UTF-8 BOM を外す。Excel で開いてほしい CSV には付ける。それ以外は読み手に任せよ。
9 言語の横並び比較——デフォルトのエンコーディング挙動
言語をまたぐ仕事こそ、この知識が元を取る場所だ。同じ文字列 "a😀é" が、Bash スクリプトから呼ぶランタイムごとに違う長さを返してくる。
言語横断挙動表
| 言語 | ソースファイルのエンコーディング | 文字列の格納 | length / len が数えるもの | デフォルト I/O エンコーディング | 4 バイト絵文字安全? |
|---|---|---|---|---|---|
| JavaScript(V8/SpiderMonkey) | UTF-8 | UTF-16 | UTF-16 コードユニット | UTF-8(Node、Web) | 安全だが .length === 2 |
| Python 3 | UTF-8(PEP 3120) | 動的 1/2/4 バイト(PEP 393) | コードポイント | UTF-8(3.7 以降 PEP 540) | 安全、len === 1 |
| Java | UTF-8(javac デフォルト) | UTF-16 | UTF-16 コードユニット | プラットフォーム文字セット → UTF-8(JEP 400、JDK 18 以降) | 安全だが .length() === 2 |
| Go | UTF-8 | UTF-8 バイト | バイト(コードポイントは utf8.RuneCountInString) | UTF-8 | 安全、len(s) はバイトを返す |
| Rust | UTF-8 | UTF-8 バイト(String 不変条件) | .len() バイト、.chars().count() コードポイント | UTF-8 | 安全、明示的 |
| C#(.NET) | UTF-8(.NET Core 3.0 以降デフォルト) | UTF-16 | UTF-16 コードユニット | UTF-8(.NET 5 以降 Encoding.Default) | 安全だが .Length === 2 |
| Ruby | UTF-8(2.0 以降) | 文字列ごとのエンコーディングタグ | コードポイント(.length) | UTF-8 | 安全、length === 1 |
| PHP | (ソースエンコーディングなし) | バイト文字列 | バイト(strlen);コードポイントは mb_strlen | default_charset 次第 | 安全、mb_* ファミリーを使うこと |
| MySQL | — | カラムの文字セット | バイト(LENGTH)、文字数(CHAR_LENGTH) | character_set_* システム変数 | utf8mb4 でのみ安全 |
この表が本当に語っていること
哲学が三つあれば、踏むバグも三種類になる。
- UTF-8 内部(Go、Rust、Ruby)。ネイティブ文字列はバイト列で、
lengthの定義は明確だが、数えるのはバイトだ。UI や検証の境界をまたぐときだけコードポイントや書記素に変換する。 - UTF-16 内部(JavaScript、Java、C#)。1990 年代の前提を引きずっており、
lengthはコードユニットで、サロゲートペアは 2 と数える。ユーザーに見せる数値では、コードポイント単位の反復を使うこと。 - コードポイントインデックス(Python 3)。
lenはコードポイントを返し、ZWJ 絵文字に出会うまでは正しく感じる。そこに来たら結局、書記素ライブラリが要る。
PHP は特殊例だ。組み込みの str* 関数はすべてバイト上で動作し、UTF-8 列を不透明な塊として扱う。ASCII でないプロジェクトはすべて mb_*(マルチバイト)ファミリーを使う必要があり、毎年のように出るバグ報告がそれを忘れる頻度を物語っている。
実務指針はこうだ。ファイル、HTTP ボディ、データベースのカラム、どこでもワイヤーフォーマットを UTF-8 に保ち、境界で初めてランタイムのネイティブ文字列型に変換する。これが第 8 章で再登場する「UTF-8 サンドイッチ」だ。
実務で踏む 8 つの落とし穴
以下のパターンは、グローバル対応のコードベースのコードレビューで毎回出てくる。
罠 1:MySQL の utf8 は 3 バイトの嘘——utf8mb4 に切り替えよ
症状。 INSERT INTO users (bio) VALUES ('Hello 😀'); が Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio' を返す。
根本原因。 MySQL の歴史的な utf8 は utf8mb3 のエイリアスで、1 文字あたり 3 バイトに制限された UTF-8 の派生形だ。U+FFFF を超える任意のコードポイント(すべての絵文字、数千の希少 CJK 文字、すべての歴史的文字)は 4 バイトの UTF-8 を要求し、拒否される。
修正。
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 でも utf8 は依然として utf8mb3 のエイリアスとして出荷されている。utf8mb3 は非推奨だがまだ削除されていない。新規のカラム、データベース、接続にはすべて utf8mb4 を使うこと。レガシー派生を選ぶ理由は何もない。
罠 2:Windows-1252 フォールバック——疑問符ミステリー
症状。 Windows の同僚のメモ帳から書き出された .txt は、その人のマシンでは "smart quotes" と em ダッシュを表示する。自分のサーバーでは ? か U+FFFD(置換文字)になる。
根本原因。 古いメモ帳はデフォルトで Windows-1252(CP-1252)を使う。これは曲がった引用符 " を 0x93 としてエンコードする。UTF-8 デコーダーは 0x93 を、先行する開始バイトのない孤立した後続バイト(上位ビットが 10)と見て、置換文字に置き換える。
修正。 ソースのエンコーディングを検出し(Unix では file、Python では chardet / charset-normalizer、Node では jschardet)、正しいコーデックでデコードし、保存前に UTF-8 に再エンコードする。受け入れ時点で UTF-8 に統一すれば再発はなくなる。
罠 3:URL パーセントエンコーディング ≠ UTF-8(だが UTF-8 の上に成り立つ)
症状。 fetch("/search?q=中文") が、あるバックエンドフレームワークでは 404 を返し、別のフレームワークでは動く。
根本原因。 パーセントエンコーディングはコードポイントではなくバイト上で動作する。中 は 1 コードポイントだが UTF-8 で 3 バイト(E4 B8 AD)、それぞれが個別に %E4%B8%AD としてパーセントエンコードされる。URL 上では ASCII 9 文字だ。URL を Latin-1 として復号するフレームワークは、3 つの文字化けしたバイトを 3 つのシングルバイト文字として解釈し、ハンドラに渡してしまう。
修正。 クライアントで encodeURIComponent("中文") を使い(ブラウザは UTF-8 化とパーセントエンコードを 1 ステップで行う)、サーバーフレームワークが URL を UTF-8 として復号することを確認する(現代のフレームワークはどれも既定でそうする)。視覚的に確認するなら、中文 を URLエンコード・デコード オンラインツール — URL解析・パーサー付き に貼り付けて %E4%B8%AD%E6%96%87 になる様子を眺めよう。連鎖の全体は URLエンコード・デコード実践ガイド:パーセントエンコーディングの仕組みと落とし穴 で扱っている。
罠 4:Base64 の入力はバイトだが、君が打ったのは文字列だ
症状。 btoa("你好") が InvalidCharacterError: The string contains characters outside the Latin1 range を投げる。
根本原因。 btoa は ASCII/Latin-1 時代に設計された。入力文字は 1 バイト(コードポイント 0〜255)に収まることを前提とする。你好 は JS エンジン内では UTF-16 で、コードポイントは U+4F60 U+597D。どちらも 255 を遥かに超える。
修正。 まず UTF-8 バイトにエンコードしてから、そのバイトを Base64 化する。
// Wrong:
btoa("你好"); // throws
// Correct:
const bytes = new TextEncoder().encode("你好");
// Uint8Array(6) [228, 189, 160, 229, 165, 189]
const b64 = btoa(String.fromCharCode(...bytes));
// "5L2g5aW9"
詳しい話は Base64エンコーディングとは?初心者向けわかりやすい解説 と Base64 完全ガイド:MIME・Data URL・パフォーマンス・セキュリティ にある。Base64デコード・エンコード 無料オンライン変換ツール は変換を 1 ステップで済ませ、途中のバイト列も見せてくれる。
罠 5:バリデーションに String.length を使う(Twitter/SMS 制限)
症状。 280 文字のコンポーザーがクライアント側で通過し、API が 422 を返す。あるいはその逆で、問題ない投稿がクライアントに拒否される。
根本原因。 JavaScript の .length は UTF-16 コードユニットを数える。絵文字 1 つで 2 になる。Twitter はコードポイントを数える(絵文字 = 1)。信じる API によって、文字数の誤差は逆方向に振れる。
修正。 コードポイント数なら [...text].length、真の書記素数なら Intl.Segmenter(Bluesky/iMessage のアプローチ)。プラットフォームごとの具体数と SMS の GSM-7 対 UCS-2 の境界は 文字数・単語数の制限 2026 — Twitter、SMS、SEO、Instagram にまとめてある。
罠 6:ZWJ 絵文字ファミリーは N コードポイント、書記素 1
症状。 "👨👩👧".length === 8。コードポイントを数えると 5。ユーザーには 1 つの画像だ。
根本原因。 Zero-Width Joiner(U+200D)が複数の絵文字コードポイントを 1 つの描画クラスターに接合する。人物絵文字 3 個+ZWJ 2 個でコードポイント 5、UTF-16 コードユニット 8、書記素 1 だ。
修正。
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment("👨👩👧")].length; // 1
Intl.Segmenter は Node 22 以降と現行のすべてのブラウザで使える。古いランタイム向けには、grapheme-splitter パッケージが UAX #29 を実装している。
罠 7:JSON の \uXXXX エスケープ——U+FFFF を超えるコードポイントはサロゲートペアが必要
症状。 JSON ペイロードに "😀" が含まれていて、受信側のデコーダーが正しく 😀 を描画するか、JSON 内のサロゲートペアを理解するかどうかで、2 つの豆腐文字が現れる。
根本原因。 JSON の \uXXXX エスケープは厳密に 4 桁の 16 進数しか受け付けない。つまり UTF-16 コードユニット 1 つだ。😀(U+1F600)のエンコードにはサロゲートペア 😀 が必要。JSON に \u{...} のブレース構文はない。
修正。 サロゲートペアを受け入れる(仕様準拠のパーサーはどれも処理できる)か、絵文字をそのまま書く。JSON はエスケープ構文の外で任意の UTF-8 文字を許容しており、現代のパーサーの多くはその形式を好む。
罠 8:HTTP の Content-Type: charset= のデフォルトは思っているものとは違う
症状。 UTF-8 の HTML ページが、あるブラウザでは文字化けし、別のブラウザでは正しく描画される。
根本原因。 RFC 2616 は当初、text/* レスポンスに明示的な charset がない場合のデフォルトとして ISO-8859-1 を必須としていた。RFC 7231(2014 年)はそのデフォルトを削除し、各ブラウザに推測を委ねた。コンテンツをスニッフィングするブラウザもあれば、UTF-8 にフォールバックするものも、システムロケールにフォールバックするものもある。
修正。 サーバーからは常に Content-Type: text/html; charset=utf-8 を送り、さらに ドキュメントの head に <meta charset="utf-8"> を入れること。どちらか単独でも機能するが、両方付ければヘッダーを剥がす旧式プロキシ対策のベルト&サスペンダーになる。
これらの罠をバイトレベルで生で観察する最速の顕微鏡は Base64デコード・エンコード 無料オンライン変換ツール だ。文字列を貼り付け、Base64 にエンコードすれば、デコードされたペイロードがその UTF-8 ストリームである。
適切なエンコーディングを選ぶ——判断マトリクス
utf-8 vs utf-16 の問いに対する答えは、ほぼ常に UTF-8 だ。下の表はエッジケースを扱う。
判断マトリクス
| シナリオ | 選択 | 理由 |
|---|---|---|
| Web ページ、API JSON、ソースファイル | UTF-8(BOM なし) | ASCII 互換、バイト順序なし、ラテン文字なら最小、JSON は RFC 8259 で UTF-8 必須 |
| CJK の重い格納(中国語 DB、日本語ゲームデータ) | UTF-8(utf8mb4) | UTF-8 は CJK 1 文字に 3 バイト使い、UTF-16 の 2 バイトより大きいが、マークアップや JSON キーの ASCII オーバーヘッドを含めると実務では UTF-8 が有利。周辺エコシステムも UTF-8 だ |
| Windows ネイティブ API、レガシー Java/C# コード | UTF-16 | プラットフォームのデフォルト;API 呼び出しごとに変換するとバグの温床になる |
| インデックス多用のメモリ内テキスト処理 | UTF-32 | 定数時間でコードポイントアクセス;パーサーのホットパスでだけ価値がある |
| Windows の Excel で開く CSV | UTF-8 + BOM | Excel は BOM なし UTF-8 を ANSI と解釈し CJK ヘッダーを壊す |
| 新規プロジェクト、制約なし | UTF-8(BOM なし) | エンコーディング戦争は決着済み |
二つの経験則
- プラットフォームが強制しない限り、どこでも UTF-8 をデフォルトに。 W3C、IETF、Unicode コンソーシアム全員が同意している。
- 境界で変換し、途中では変換しない。 受信時にバイトを言語のネイティブ文字列型へデコード。ビジネスロジックでは文字列で操作し、バイトは触らない。出力時に UTF-8 へ再エンコード。この「UTF-8 サンドイッチ」が、パイプライン中盤の文字化けバグを丸ごと消し去る。
よくある質問(FAQ)
UTF-8 は常に ASCII と後方互換なのか?
そうだ。有効な ASCII ファイルは UTF-8 表現とビット単位で同一である。最初の 128 コードポイント(U+0000〜U+007F)は上位ビットを 0 にした 1 バイトとしてエンコードされる。レガシーの ASCII 専用ツール(初期の grep、sed、古典的なシェルパイプ)は、純粋 ASCII の UTF-8 ファイルを変更なしで処理する。トラブルが起きるのは ASCII でないバイト(上位ビットが立つ)が流入したときだけだ。
ファイルに UTF-8 BOM を使うべきか?
デフォルトでは付けない。HTML、JSON、JavaScript、CSS のファイルは、BOM が先頭に現れると一部のパーサーで壊れたり警告が出たりする。標準的な例外は Windows の Excel で開く CSV だ。BOM がないと Excel は ANSI と推測して中国語、日本語、韓国語のヘッダーを壊す。第 5 章の BOM 判断マトリクスを参照のこと。
なぜ JavaScript で "😀".length === 2 になるのか?
JavaScript の文字列は UTF-16 で格納され、.length は文字数ではなくコードユニットの数を返す。😀(U+1F600)は追加面に住み、サロゲートペア(16 ビットのコードユニット 2 つ)を必要とするので .length は 2 になる。真の数値が欲しいなら [..."😀"].length、Array.from("😀").length、または Intl.Segmenter を使え。
Unicode と UTF-8 の違いは?
Unicode はすべての文字にコードポイント(U+1F600 のような番号)を割り当てる文字テーブルだ。UTF-8 はそれらのコードポイントをバイト列に変換するエンコーディングの一つで、コードポイントあたり 1〜4 バイトを使う。Unicode は「文字とは何か」を定義し、UTF-8 は「文字がファイルやネットワークをどう旅するか」を定義する。UTF-16 と UTF-32 は同じ Unicode テーブルの代替エンコーディングだ。
MySQL で utf8mb4 は常に utf8 より安全か?
新規プロジェクトには、そうだ。MySQL の utf8 は名前を誤った 3 バイト制限の派生 utf8mb3 で、U+FFFF を超える文字は一切格納できない。すべての絵文字、多くの希少 CJK 文字、すべての歴史的文字が該当する。utf8mb4 は完全な 4 バイト UTF-8 だ。一つ留意点があり、インデックス長だ。utf8mb4 の 1 文字は最大 4 バイトを取りうるので、InnoDB の旧来の 767 バイトインデックス制限が一意インデックスを 191 文字までに縛る(MySQL 5.7 以降の innodb_large_prefix で解消、8.0 でデフォルト)。
未知のファイルのエンコーディングはどう検出する?
Unix なら file、Python なら chardet または charset-normalizer、Node なら jschardet を使う。どれも完璧ではない。バイト分布から統計的に推測しているだけだ。UTF-8 の検出は後続バイトのパターンのおかげで高い信頼性を持つ。Windows-1252、ISO-8859-1、その他のシングルバイトレガシーエンコーディングは互いにほぼ区別がつかず、検出は結局言語ヒューリスティクスに頼ることになる。
UTF-16 はすべての Unicode 文字を表現できるか?
できる。UTF-16 は全 1,114,112 コードポイントをカバーする。BMP の文字(U+0000〜U+FFFF)は 1 つの 16 ビットコードユニット(2 バイト)を、追加面の文字(U+10000〜U+10FFFF)はサロゲートペア(4 バイト)を使う。カバー範囲は UTF-8 や UTF-32 と同じで、違うのはバイトレイアウトと処理セマンティクスだけだ。三者の選択はエコシステムへの適合性の問題であって、能力の問題ではない。