文字数カウントツールを使うと、英字と日本語でバイト数が異なることに気づきます。英字「A」は1バイトなのに、ひらがな「あ」は3バイト——なぜ文字によってバイト数が違うのでしょうか。

この違いは文字コード(エンコーディング)の仕組みによるものです。そして実務では、この「文字数とバイト数の食い違い」が、文字列が途中で切れる・データベースに入らない・文字化けする、といった不具合の原因になります。仕組みと、そこから生まれる実務上の落とし穴をあわせて整理します。

文字コードとは

コンピュータは文字をそのまま扱えません。すべての文字に番号(コードポイント)を割り当て、その番号をバイト列として保存する仕組みが「文字コード」です。

最も古い文字コードのひとつが ASCII(アスキー)です。英字・数字・記号など128種類の文字を7ビット(1バイト)で表します。「A」は65番、「a」は97番、「0」は48番というように割り当てられています。英語圏の文書はこれで十分でしたが、日本語・中国語・アラビア語など世界の言語を扱うには全く足りませんでした。

UTF-8の仕組み

世界中のすべての文字を統一的に扱うために作られたのが Unicode という規格です。Unicodeが用意しているコードポイントの枠は約111万個で、そのうち実際に文字が割り当てられているのは約15万字です(Unicode 15.0時点)。残りは将来のための空き枠と、私用領域です。

UTF-8はそのUnicodeを実際にバイト列に変換する方式のひとつで、可変長エンコーディングという仕組みを採用しています。コードポイントの大きさに応じて1〜4バイトを使い分けます。

文字の種類 コードポイント UTF-8のバイト列 バイト数
ASCII文字 A U+0041 41 1
ラテン拡張・キリル文字 é U+00E9 C3 A9 2
ひらがな・漢字・ハングル U+3042 E3 81 82 3
絵文字・CJK拡張漢字 😀 U+1F600 F0 9F 98 80 4

ひらがな「あ」のコードポイントはU+3042です。UTF-8が2バイトで表せるのはU+07FFまでなので、それを超える「あ」は3バイトを使うことになります。ひらがな・カタカナ・漢字はまとめてこの範囲に入るため、日本語はおおむね1文字3バイトになります。

なぜUTF-8はこの設計なのか

「日本語が3バイトになるなら、全部2バイトの方式にすればいいのでは」と思うかもしれません。実際、そういう方式(UTF-16)もあります。それでもUTF-8が主流になったのは、ASCIIと完全な互換性があるからです。

UTF-8では、ASCIIの範囲(U+0000〜U+007F)はそのまま1バイトで表されます。つまり英数字だけで書かれたテキストは、ASCIIとUTF-8でバイト列がまったく同じになります。既存の英語圏のシステムやプロトコルをそのまま動かしながら、日本語や絵文字も扱えるようになる——この後方互換性が決定的でした。

その代償として、日本語は3バイトになっています。日本語話者から見ると不利な設計に見えますが、Web全体の互換性と引き換えに受け入れられているトレードオフです。

「文字数」には3つの意味がある

実務でいちばん混乱を生むのがここです。「1文字」の数え方は、立場によって3通りあります。

家族の絵文字 👨‍👩‍👧‍👦 を例にすると、こうなります。

数え方 結果 どこで使われるか
見た目の文字数(書記素) 1 人間の感覚。「何文字に見えるか」
コードポイント数 7 Unicodeとしての文字の数
UTF-16コードユニット数 11 JavaScriptの String.length、Javaの length()
UTF-8バイト数 25 ファイルサイズ、API制限、DBの容量

見た目は1文字なのに、JavaScriptで数えると11、バイト数では25。この絵文字は複数の人物絵文字を結合文字でつないで作られているため、こうした差が生まれます。

絵文字ほど極端でなくても、同じ問題は日常的に起きます。

  • 𠮷(つちよし)のような一部の漢字は、コードポイントとしては1文字ですが、JavaScriptの length では2と数えられます(サロゲートペア)
  • é は「é」1文字として持つ場合と、「e」+アクセント記号の2文字として持つ場合があり、見た目が同じでも文字数が変わります

「文字数制限」を実装するときは、どの数え方の話なのかを最初に決めておく必要があります。

バイト数で切ると文字が壊れる

文字列を一定の長さで切り詰める処理は、バイト数で切ると日本語が壊れます。UTF-8では1文字が複数バイトに分かれているため、文字の途中で切れてしまうからです。

「日本語のテキスト」= 8文字 / 24バイト

先頭10バイトで切る → 日本語?    ← 4文字目の途中で切れて壊れる
先頭 9バイトで切る → 日本語     ← たまたま3の倍数だったので壊れない

たまたま3の倍数のところで切れれば無事ですが、そうでなければ末尾に不正なバイトが残り、表示すると「�」になります。ログの末尾だけ文字化けする、といった現象はこれが原因であることがよくあります。

対策は、切り詰めをバイト単位ではなく文字単位で行うことです。どうしてもバイト数の上限を守る必要がある場合は、文字境界を意識して切る関数(PHPなら mb_strimwidth() など)を使います。

データベースのカラム長は文字数かバイト数か

「VARCHAR(255) に日本語は何文字入るのか」はよくある疑問ですが、答えはデータベース製品によって違います

製品 VARCHAR(N) の N が意味するもの
MySQL / MariaDB 文字数
PostgreSQL 文字数
Oracle 既定はバイト数VARCHAR2(N CHAR) と書けば文字数)
SQL Server VARCHARバイト数NVARCHAR は文字数

MySQLの VARCHAR(255) は文字数指定なので、日本語も255文字入ります。ただし別のところにバイト数の制約があります。

  • 行全体のサイズ上限(65,535バイト)。カラムが多いテーブルでは、文字数に余裕があっても行のサイズで引っかかることがあります。
  • インデックスのキー長。InnoDBのインデックス接頭辞の上限は、行フォーマットがCOMPACT/REDUNDANTのとき767バイトです。utf8mb4は1文字最大4バイトなので、767 ÷ 4 = 191文字VARCHAR(255) にインデックスを張ろうとすると上限を超えます。

古いMySQLの設定でカラム長を VARCHAR(191) にする慣習があるのは、この計算から来ています。行フォーマットがDYNAMICまたはCOMPRESSEDであれば上限は3,072バイト(768文字)に広がるため、現在の環境では VARCHAR(255) でも問題ないことがほとんどです。

文字化けはなぜ起きるのか

文字化けは「あるバイト列を、別の文字コードとして解釈した」ときに起きます。化け方には特徴があり、見た目から原因を推測できます

「あいう」を例にすると、次のようになります。

UTF-8      : E3 81 82 E3 81 84 E3 81 86  (9バイト)
Shift_JIS  : 82 A0 82 A2 82 A4          (6バイト)
何を何として読んだか 結果の見え方
UTF-8のバイト列を Shift_JIS として読んだ 縺ゅ>縺
UTF-8のバイト列を Latin-1 として読んだ ãã
Shift_JISのバイト列を UTF-8 として読んだ ������

が並んでいたら「UTF-8をShift_JISとして読んでいる」、 ばかりなら「Shift_JISなどをUTF-8として読もうとして失敗している」といった見当がつきます。化けた文字そのものが、原因を教えてくれる手がかりになります。

化けていないのに一致しない — 「〜」の落とし穴

文字化けは、目で見れば気づけます。やっかいなのは化けていないのに一致しないケースです。

CSVを取り込む処理を作っていたときの話です。ある項目が であればその行を範囲指定として扱う、というロジックを入れていました。しばらくして「範囲として認識されない」という問い合わせが届きます。データを開いても、見た目におかしなところはありません。 はきちんと に見えています。

原因が分かったのは偶然でした。条件式に書いてある を、別の場所からコピーした に置き換えてみたところ、見た目は何ひとつ変わっていないのに差分として表示されたのです。そこで初めて、この2つが違う文字だと気づきました。

テキスト差分比較ツールに、見た目の同じ2つの波線をそれぞれ貼り付けて比較した画面
左右どちらも同じ「〜」に見えるが、追加1行・削除1行・変更なし0行と判定される。共通する部分が1つもない、つまり完全に別の文字として扱われている

「〜」は2種類ある

よく似た波線には、別々のコードポイントが割り当てられています。

文字 コードポイント 名称 UTF-8のバイト列
U+301C WAVE DASH(波ダッシュ) E3 80 9C
U+FF5E FULLWIDTH TILDE(全角チルダ) EF BD 9E

フォントによっては形が違って見えますが、見分けがつかないこともあります。そしてどちらもUTF-8で3バイトです。文字数を数えてもバイト数を数えても、違いは表に出てきません。

なぜ混ざるのか

Shift_JISとの変換に原因があります。Shift_JISの 0x8160 というバイト列を何の文字とみなすかが、変換表によって違うのです。

変換表 0x8160 の解釈
標準のShift_JIS U+301C 波ダッシュ
Windows系のCP932 U+FF5E 全角チルダ

同じバイト列でも、読み込むときの指定ひとつで別の文字になります。CSVがShift_JISで書き出されていれば、どの変換表で読むかによって、システムに入ってくる の中身が変わることになります。文字を入力した環境によって、どちらが混ざるかが変わることもあります。

逆方向の変換はさらに厄介です。

変換 結果
U+301C → CP932 → 戻す U+FF5E に変わる(エラーは出ない)
U+FF5E → 標準のShift_JIS 変換できず失敗する

波ダッシュはCP932を一度通すだけで、警告もなく全角チルダに置き換わります。逆に全角チルダは標準のShift_JISやEUC-JPに対応する文字がないため、変換しようとすると失敗します。黙って変わるほうが、エラーで止まるより見つけにくいというのが、この問題のたちの悪いところです。

対処

このときは、比較する前にどちらか一方へ寄せる方法をとりました。取り込みの段階で を片方に置換してから判定するようにしたので、どちらが入ってきても同じ扱いになります。

文字を比較する処理では、比較の前に表記をそろえる。 これは に限った話ではありません。

見た目が近い文字 コードポイント
- U+002D / U+2010 / U+2013 / U+2014 / U+2212 / U+30FC
半角スペース / 全角スペース U+0020 / U+3000

ハイフンに見える記号は6種類が別々の文字として存在します。等号で比較するロジックを書くときは、比較対象の文字が1種類とは限らないことを疑ってください。

なお、この種の「見た目は同じなのに一致しない」問題は、テキスト差分比較に両方を貼り付ければ差分として現れます。目視で追うより確実です。差分ツールをこうした確認に使う方法は「テキスト差分比較の活用テクニック」にまとめました。

よくある質問

Q. 半角カナは1バイトではないのですか

A. UTF-8では半角カナ( など)も3バイトです。1バイトだったのはShift_JISでの話で、UTF-8には引き継がれていません。「半角=1バイト」という前提でバイト数を見積もると、UTF-8環境では合わなくなります。

Q. 全角文字は必ず3バイトですか

A. ほとんどはそうですが、例外があります。𠮷𩸽 のようなCJK拡張漢字は4バイトです。人名や地名で使われることがあるため、「日本語=3バイト」で固定して計算すると足りなくなる場合があります。

Q. JavaScriptで日本語の文字数を正しく数えるには

A. String.length はUTF-16のコードユニット数を返すため、絵文字や一部の漢字が2と数えられます。コードポイント単位で数えたい場合は [...str].length のようにスプレッド構文で分解します。見た目どおりの文字数が必要なら Intl.Segmenter を使います。

Q. 同じ文字に見えるのに検索でヒットしません

A. 見た目の近い別の文字が混ざっている可能性があります。(U+301C)と (U+FF5E)、ハイフンに見える6種類の記号、半角と全角のスペースなどが代表例です。どれもUTF-8のバイト数が同じことがあるため、文字数やバイト数を数えても違いは出ません。疑わしいときは、期待する文字列と実際のデータを差分ツールに並べて貼り付けると、違いのある位置が分かります。

Q. UTF-8とUTF-8(BOM付き)は何が違いますか

A. ファイルの先頭に EF BB BF という3バイトの印が付いているかどうかの違いです。UTF-8ではBOMは不要ですが、一部のソフトが付けることがあります。この3バイトを気づかずに読み込むと、先頭の項目名が一致しない、数値として解釈できない、といったトラブルになります。CSVの1列目だけおかしい、という症状の定番の原因です。

まとめ

日本語がUTF-8で3バイトになるのは、Unicodeのコードポイントが2バイトで表せる範囲を超えているためです。ASCIIとの互換性を優先した設計の結果であり、避けようがありません。

実務で押さえておきたいのは次の点です。

  • 「文字数」には見た目・コードポイント・UTF-16・バイト数の数え方があり、値が食い違う
  • 文字列をバイト単位で切ると日本語が壊れる
  • カラム長が文字数かバイト数かは製品によって違う。MySQLは文字数だが、インデックスにはバイト数の上限がある
  • 文字化けの見た目から原因を推測できる
  • 化けていなくても一致しないことがある のように見た目の近い別の文字が混ざるため、比較の前に表記をそろえる

以下の文字数カウントツールでは、文字数・バイト数・全角/半角の内訳を同時に表示します。API制限やカラム長に収まるかを確認したいとき、文字数だけでなくバイト数も一緒に見られるので、両者の食い違いに気づきやすくなります。

処理はすべてブラウザ内で行われ、入力したテキストが外部に送信されることはありません。