Skip to content
ブログに戻る
チュートリアル

UTF-8 vs UTF-16 vs Unicode エンコーディングガイド

UTF-8、UTF-16、UTF-32 を開発者向けに完全解説:コードポイント、サロゲートペア、BOM、MySQL utf8mb4 の落とし穴、JS string.length の罠まで学ぼう。

12 分で読める

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 つのシナリオを紹介する。

  1. MySQL が絵文字を拒否する。 ユーザーが Hello 😀 を送信すると、サーバーが Incorrect string value: '\xF0\x9F\x98\x80' を返す。テーブルは utf8 で、開発者は「UTF-8 のはずなのに何が悪い?」と頭を抱える。答えは MySQL の歴史の奥に埋もれている(第 7 章で扱う)。
  2. 文字数カウンターが壊れたまま出荷される。 280 文字制限のツイートバリデーターが text.length を使い、絵文字だらけのメッセージを通してしまい、API に弾かれる。逆に、有効な投稿がフロントエンドで拒否されることもある。この症状は第 4 章で解明する。
  3. ローカル 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+0041U+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+0000U+007F)は 1 バイトで、ASCII とバイト単位で同一だ。それより高いコードポイントは複数バイト列を使い、先頭バイトが全長を示し、後続バイトはすべて 10xxxxxx のビットパターンで始まる。この自己記述的なレイアウトが、UTF-8 がエンコーディング戦争で生き残った最大の理由だ。

バイトパターン表——UTF-8 を 1 枚の図で

コードポイント範囲UTF-8 バイト数バイトパターン
U+0000U+007F1 バイト0xxxxxxx
U+0080U+07FF2 バイト110xxxxx 10xxxxxx
U+0800U+FFFF3 バイト1110xxxx 10xxxxxx 10xxxxxx
U+10000U+10FFFF4 バイト11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

x はコードポイントの 2 進数表現から取られるデータビットだ。先頭の 0110111011110 はデコーダーに全何バイトかを伝え、先頭の 10 はそれが後続バイトであることを示す。この冗長性のおかげで UTF-8 は自己同期可能になる。1 バイト落としても次の開始バイトから再開でき、後続すべてが壊滅することはない。

ワークドエグザンプル——(U+4E2D)をエンコードする

コードポイント 0x4E2DU+0800U+FFFF の範囲に入るので、3 バイトテンプレートを使う。

  1. 2 進数:0x4E2D = 0100 1110 0010 1101(16 ビット)。
  2. x スロットに収まるよう 4-6-6 で分割:0100 / 111000 / 101101
  3. 1110xxxx 10xxxxxx 10xxxxxx に代入:11100100 10111000 10101101
  4. 16 進:0xE4 0xB8 0xAD

これがまさに が URL エンコード後に %E4%B8%AD になる理由だ。パーセントエンコーディングは各 UTF-8 バイトを %XX で包むのであって、コードポイントを直接エンコードするわけではない。第 7 章の罠 3 でこの連鎖を詳しく見る。

ワークドエグザンプル——😀(U+1F600)をエンコードする

コードポイント 0x1F600 は BMP を超えるので、4 バイトテンプレートを使う。

  1. 2 進数:0x1F600 = 0 0001 1111 0110 0000 0000(21 ビット、パディング込み)。
  2. 3-6-6-6 で分割:000 / 011111 / 011000 / 000000
  3. 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx に代入:11110000 10011111 10011000 10000000
  4. 16 進:0xF0 0x9F 0x98 0x80

この 4 バイトこそ MySQL の utf8 コレーションが詰まるものだ。1 文字あたり最大 3 バイトしか割り当てないからである。第 7 章の罠 1 に修正方法がある。

UTF-8 が生き残った 3 つの理由

  1. ASCII 互換性。 純粋な ASCII テキストのファイルは、その UTF-8 エンコーディングとバイト単位で同一だ。Unicode 以前の数十年にわたるツール群(grepawk、古典的なシェルパイプ)は、その部分集合に対して動き続ける。
  2. 自己同期。 後続バイトは常に 10 で始まり、これはどの開始バイトとも衝突しない。ネットワーク転送で 1 バイト失っても次の文字境界で再同期でき、ゴミが連鎖することはない。
  3. バイト順序なし。 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+D800U+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+0000U+FFFF)の文字はちょうど 1 コードユニットを取る。追加面(U+10000U+10FFFF)の文字は 2 コードユニットを取り、これを サロゲートペア と呼ぶ。U+D800U+DBFF の上位サロゲートの後に U+DC00U+DFFF の下位サロゲートが続く形だ。U+D800U+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 なら graphemeregex、あるいは 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、fetchTextEncoder)は孤立サロゲートを拒否する。有効な UTF-8 にエンコードできないからである。

UTF-32、BOM、そしてバイト順序の問題

UTF-32——シンプルだが無駄も多い

UTF-32 はコードポイントあたり固定 4 バイトを使う。U+00410x00000041 として、U+1F6000x0001F600 として格納される。利点は定数時間のランダムアクセスで、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-8EF BB BF
UTF-16 BEFE FF
UTF-16 LEFF FE
UTF-32 BE00 00 FE FF
UTF-32 LEFF FE 00 00

utf-8 BOM も存在するが、UTF-8 にはバイト順序がないので、バイト順序情報は持たない。その唯一の役割は「このファイルは UTF-8 である」と宣言することだ。他に手がかりのないツールには有用だが、ファイルがマジックナンバーやディレクティブで始まることを期待するツールには有害である。

BOM 判断マトリクス——付けるべきか?

形式UTF-8 BOMUTF-16 BOMUTF-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 スクリプトから呼ぶランタイムごとに違う長さを返してくる。

言語横断挙動表

言語ソースファイルのエンコーディング文字列の格納lengthlen が数えるものデフォルト I/O エンコーディング4 バイト絵文字安全?
JavaScript(V8/SpiderMonkey)UTF-8UTF-16UTF-16 コードユニットUTF-8(Node、Web)安全だが .length === 2
Python 3UTF-8(PEP 3120)動的 1/2/4 バイト(PEP 393)コードポイントUTF-8(3.7 以降 PEP 540)安全、len === 1
JavaUTF-8(javac デフォルト)UTF-16UTF-16 コードユニットプラットフォーム文字セット → UTF-8(JEP 400、JDK 18 以降)安全だが .length() === 2
GoUTF-8UTF-8 バイトバイト(コードポイントは utf8.RuneCountInStringUTF-8安全、len(s) はバイトを返す
RustUTF-8UTF-8 バイト(String 不変条件).len() バイト、.chars().count() コードポイントUTF-8安全、明示的
C#(.NET)UTF-8(.NET Core 3.0 以降デフォルト)UTF-16UTF-16 コードユニットUTF-8(.NET 5 以降 Encoding.Default安全だが .Length === 2
RubyUTF-8(2.0 以降)文字列ごとのエンコーディングタグコードポイント(.lengthUTF-8安全、length === 1
PHP(ソースエンコーディングなし)バイト文字列バイト(strlen);コードポイントは mb_strlendefault_charset 次第安全、mb_* ファミリーを使うこと
MySQLカラムの文字セットバイト(LENGTH)、文字数(CHAR_LENGTHcharacter_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 の歴史的な utf8utf8mb3 のエイリアスで、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 では chardetcharset-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(utf8mb4UTF-8 は CJK 1 文字に 3 バイト使い、UTF-16 の 2 バイトより大きいが、マークアップや JSON キーの ASCII オーバーヘッドを含めると実務では UTF-8 が有利。周辺エコシステムも UTF-8 だ
Windows ネイティブ API、レガシー Java/C# コードUTF-16プラットフォームのデフォルト;API 呼び出しごとに変換するとバグの温床になる
インデックス多用のメモリ内テキスト処理UTF-32定数時間でコードポイントアクセス;パーサーのホットパスでだけ価値がある
Windows の Excel で開く CSVUTF-8 + BOMExcel は BOM なし UTF-8 を ANSI と解釈し CJK ヘッダーを壊す
新規プロジェクト、制約なしUTF-8(BOM なし)エンコーディング戦争は決着済み

二つの経験則

  1. プラットフォームが強制しない限り、どこでも UTF-8 をデフォルトに。 W3C、IETF、Unicode コンソーシアム全員が同意している。
  2. 境界で変換し、途中では変換しない。 受信時にバイトを言語のネイティブ文字列型へデコード。ビジネスロジックでは文字列で操作し、バイトは触らない。出力時に UTF-8 へ再エンコード。この「UTF-8 サンドイッチ」が、パイプライン中盤の文字化けバグを丸ごと消し去る。

よくある質問(FAQ)

UTF-8 は常に ASCII と後方互換なのか?

そうだ。有効な ASCII ファイルは UTF-8 表現とビット単位で同一である。最初の 128 コードポイント(U+0000U+007F)は上位ビットを 0 にした 1 バイトとしてエンコードされる。レガシーの ASCII 専用ツール(初期の grepsed、古典的なシェルパイプ)は、純粋 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 になる。真の数値が欲しいなら [..."😀"].lengthArray.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+0000U+FFFF)は 1 つの 16 ビットコードユニット(2 バイト)を、追加面の文字(U+10000U+10FFFF)はサロゲートペア(4 バイト)を使う。カバー範囲は UTF-8 や UTF-32 と同じで、違うのはバイトレイアウトと処理セマンティクスだけだ。三者の選択はエコシステムへの適合性の問題であって、能力の問題ではない。

タグ: unicode utf-8 utf-16 character-encoding surrogate-pair encoding