\ud83d\ude00を絵文字に戻す:unicode_escapeが化ける理由

JSON やログに \ud83d\ude00 と出ていたら、それは絵文字「😀」(U+1F600)1文字である。ところが、この12文字を Python の codecs.decode(s, 'unicode_escape') で戻すと、見た目は戻ったようで中身は2文字になり、ファイルに UTF-8 で書こうとした瞬間に UnicodeEncodeError になる。

ハシトシステムの Unicode エスケープ変換 は、文字列と \uXXXX 形式を相互に変換するツールで、絵文字を入れると \ud83d\ude00 のように2つのエスケープを出す。この記事では、なぜ2つになるのか、それを JavaScript と Python で戻すと何が起きるのかを実測で確かめる。

なぜ絵文字は \u が2つになるのか

\uXXXX は16進4桁なので、表せるのは U+0000〜U+FFFF(基本多言語面)までである。U+1F600 のようにそれより大きい文字は、UTF-16 の サロゲートペア(上位サロゲート U+D800〜U+DBFF と下位サロゲート U+DC00〜U+DFFF の組)に分けて、2つの \uXXXX で書く。

JSON の仕様である RFC 8259 の 7章も、基本多言語面の外の文字は「UTF-16 のサロゲートペアを表す12文字の並び」で書くと定めている。JavaScript の文字列も中身は UTF-16 なので、'😀'.length は 2 になる。

U+1F600 の場合は次のように分かれる。

値
コードポイント U+1F600
0x10000 を引く 0x0F600
上位10ビット + 0xD800 0xD83D
下位10ビット + 0xDC00 0xDE00

漢字でも、基本多言語面の外にある「𠮷」(U+20BB7)はツールで \ud842\udfb7 の2つになる。

JavaScript では JSON.parse で戻る

Node.js v24.11.1 で、公開中のツールの関数と JSON の関数に同じ文字列を渡した(コードは後ろの「実際に試す」にある)。

入力・操作 結果
ツールで A😀あ をエスケープ A\ud83d\ude00\u3042
ツールで \uD83D\uDE00 を戻す 😀
ツールで \u{1F600} を戻す 😀
ツールで \U0001F600 を戻す 変換されずそのまま
JSON.parse に "\uD83D\uDE00" 😀
JSON.parse に "\u{1F600}" SyntaxError
JSON.stringify('😀'.slice(0, 1)) "\ud83d"
  • \u{1F600} は JavaScript の文字列リテラルの書き方で、JSON にはない。JSON.parse は SyntaxError になる。ツールは貼り付けやすさのため、戻すときだけこの形も受け付ける。
  • \U0001F600(大文字の U と8桁)は Python などの書き方で、ツールは変換しない。
  • '😀'.slice(0, 1) のように絵文字の途中で切ると、上位サロゲートだけの「片割れ」(lone surrogate)が残る。JSON.stringify は片割れを "\ud83d" とエスケープして出す。MDN によると、これは「well-formed JSON.stringify」と呼ばれる変更で、以前のエンジンは片割れをそのまま出力していた。

Python では unicode_escape だと2文字になる

Python 3.11.9 で、\ud83d\ude00 の12文字を戻した結果が次のとおり。

方法 結果 文字数 UTF-8 に書き出す
codecs.decode(s, 'unicode_escape') '\ud83d\ude00' 2 UnicodeEncodeError
上の結果を .encode('utf-16', 'surrogatepass').decode('utf-16') '\U0001f600' 1 可
json.loads('"' + s + '"') '\U0001f600' 1 可

unicode_escape は Python のソースコードのエスケープを読む codec で、\uXXXX を1つずつ独立した文字に直す。そのため、上位と下位のサロゲートが別々の2文字として残る。Python の str は UTF-16 ではなくコードポイントの並びなので、この2文字が自動で1つの絵文字に組み合わさることはない。UTF-8 の codec はサロゲートの範囲のコードポイントを書けないので、.encode('utf-8') の時点でエラーになる。

一方、json.loads は JSON の規則どおりにサロゲートペアを1文字に組み合わせて返す。元が JSON の文字列なら、json.loads で戻すのが正しい。

すでに2文字に分かれてしまった文字列は、Python ドキュメントの codecs にある surrogatepass エラーハンドラで直せる。surrogatepass はサロゲートのコードポイントを普通のコードポイントとして encode・decode させるハンドラで、いったん UTF-16 のバイト列にしてから decode し直すと、隣り合ったペアが1文字にまとまる。

逆向きでは、json.dumps('😀') は既定(ensure_ascii=True)で "\\ud83d\\ude00" を出す。JSON を読む側が人間なら ensure_ascii=False を付けると絵文字のまま出力される。

片割れが混ざった文字列は外に出すときに壊れる

絵文字の途中で切った文字列や、"\ud83d" だけの JSON を読んだ結果には、サロゲートの片割れが入る。片割れは、どの実装でも「読むところまでは通り、外に出すところで壊れる」。

操作 結果
JS: 'abc😀'.slice(0, 4).isWellFormed() false
JS: 同じ文字列を encodeURIComponent URIError: URI malformed
JS: 同じ文字列を UTF-8 のバイト列に(Buffer.from) 616263efbfbd(末尾が U+FFFD に置き換わる)
JS: 同じ文字列の toWellFormed() abc�
Python: json.loads('"\\ud83d"') '\ud83d'(エラーにならない)
Python: その結果を .encode('utf-8') UnicodeEncodeError

RFC 8259 の 8.2節は、"\uDEAD" のような片割れを含む JSON を受け取ったソフトウェアの振る舞いは予測できないと書いている。Python の json モジュールのドキュメントも、片割れを含む文字列を既定で受け付け、そのまま出力すると明記している。JavaScript でも、JSON.parse('"\\uD83D"') は長さ1の文字列を返してエラーにならない。

URL に入れる、UTF-8 でファイルや DB に書く、といった外に出す処理の直前で、JavaScript なら isWellFormed() で確かめるか toWellFormed() で U+FFFD に置き換える。MDN によると、この2つのメソッドは2023年10月から主要ブラウザで使える。そもそも片割れを作らないために、文字数で切るときは Intl.Segmenter などで書記素(見た目の1文字)単位に切る。

実際に試す

Node.js 20 以降(isWellFormed と toWellFormed を使うため。記事では v24.11.1)と Python 3(記事では 3.11.9)で試せる。

1. ツールの関数と JSON の関数を比べる(Node.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// try.mjs: 公開中のツールの関数と、JSON.parse / JSON.stringify / encodeURIComponent を比べる
const html = await (await fetch('https://hashitosystem.com/tools/unicodeescape/')).text();
const s = html.indexOf('function hex4(');
const e = html.indexOf('(function(){', s);
const { escapeUnicode, unescapeUnicode } = new Function(html.slice(s, e) + '; return { escapeUnicode, unescapeUnicode };')();

const B = '\\'; // バックスラッシュ1文字
const show = (label, f) => {
let r;
try { r = f(); } catch (err) { r = `${err.name}: ${err.message}`; }
console.log(label.padEnd(28), typeof r === 'string' ? JSON.stringify(r) : r);
};

show('ツール escape', () => escapeUnicode('A😀あ', false));
show('ツール unescape uXXXX x2', () => unescapeUnicode(`${B}uD83D${B}uDE00`));
show('ツール unescape u{1F600}', () => unescapeUnicode(`${B}u{1F600}`));
show('ツール unescape U0001F600', () => unescapeUnicode(`${B}U0001F600`));
show('JSON.parse ペア', () => JSON.parse(`"${B}uD83D${B}uDE00"`));
show('JSON.parse u{1F600}', () => JSON.parse(`"${B}u{1F600}"`));
show('JSON.stringify 片割れ', () => JSON.stringify('😀'.slice(0, 1)));

const lone = 'abc😀'.slice(0, 4); // 4文字目で切ると上位サロゲートだけ残る
show('lone.isWellFormed()', () => lone.isWellFormed());
show('encodeURIComponent(lone)', () => encodeURIComponent(lone));
show('UTF-8 にしたバイト列', () => Buffer.from(lone).toString('hex'));
show('lone.toWellFormed()', () => lone.toWellFormed());
1
2
3
4
5
6
7
8
9
10
11
12
$ node try.mjs
ツール escape "A\\ud83d\\ude00\\u3042"
ツール unescape uXXXX x2 "😀"
ツール unescape u{1F600} "😀"
ツール unescape U0001F600 "\\U0001F600"
JSON.parse ペア "😀"
JSON.parse u{1F600} "SyntaxError: Bad Unicode escape in JSON at position 3 (line 1 column 4)"
JSON.stringify 片割れ "\"\\ud83d\""
lone.isWellFormed() false
encodeURIComponent(lone) "URIError: URI malformed"
UTF-8 にしたバイト列 "616263efbfbd"
lone.toWellFormed() "abc�"

出力は JSON.stringify を通しているので、バックスラッシュが2つに見える。ページの中身が変われば結果も変わるので、2026-11-04 時点の結果として読んでほしい。

2. Python で12文字を絵文字に戻す

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# try.py: JSON やログにあった \u エスケープの12文字を、Python で絵文字に戻す
import codecs
import json

B = '\\' # バックスラッシュ1文字
src = B + 'ud83d' + B + 'ude00'


def show(label, f):
try:
r = f()
except Exception as err:
r = f'{type(err).__name__}: {err}'
print(f'{label:34}', ascii(r) if isinstance(r, str) else r)


bad = codecs.decode(src, 'unicode_escape')
show('unicode_escape の結果', lambda: bad)
show(' len', lambda: len(bad))
show(' UTF-8 に書き出す', lambda: bad.encode('utf-8'))
show(' surrogatepass で組み直す', lambda: bad.encode('utf-16', 'surrogatepass').decode('utf-16'))

good = json.loads('"' + src + '"')
show('json.loads の結果', lambda: good)
show(' len', lambda: len(good))

show('json.dumps 既定', lambda: json.dumps('\N{GRINNING FACE}'))
show('json.dumps ensure_ascii=False', lambda: json.dumps('\N{GRINNING FACE}', ensure_ascii=False))

lone = json.loads('"' + B + 'ud83d"')
show('片割れだけを json.loads', lambda: lone)
show(' UTF-8 に書き出す', lambda: lone.encode('utf-8'))
1
2
3
4
5
6
7
8
9
10
11
$ python try.py
unicode_escape の結果 '\ud83d\ude00'
len 2
UTF-8 に書き出す "UnicodeEncodeError: 'utf-8' codec can't encode characters in position 0-1: surrogates not allowed"
surrogatepass で組み直す '\U0001f600'
json.loads の結果 '\U0001f600'
len 1
json.dumps 既定 '"\\ud83d\\ude00"'
json.dumps ensure_ascii=False '"\U0001f600"'
片割れだけを json.loads '\ud83d'
UTF-8 に書き出す "UnicodeEncodeError: 'utf-8' codec can't encode character '\\ud83d' in position 0: surrogates not allowed"

コンソールの文字コードに左右されないよう、ascii() で表示している。\U0001f600 が絵文字1文字、\ud83d\ude00 が片割れ2文字である。

ハシトシステムの Unicode エスケープ変換では

Unicode エスケープ変換 は、ブラウザの中だけで文字列と \uXXXX を相互に変換する。2026-11-04 時点のコードでは、次のように動く。

  • エスケープは UTF-16 の1単位ごとに \uXXXX(16進は小文字)を出す。絵文字や「𠮷」のような基本多言語面の外の文字は2つになる。この出力は JSON の文字列と JavaScript の文字列リテラルにそのまま使える。
  • 既定では ASCII 以外だけをエスケープする。「すべてエスケープ」にすると A も \u0041 になる。
  • 戻すときは \uXXXX と \u{1F600} の両方を受け付ける。Python の \U0001F600 は変換しない。
  • 戻すときに片割れのエスケープ(\ud83d だけなど)があっても、エラーにせずそのまま片割れの文字にする。ペアがそろっているかは、戻した結果に絵文字が正しく出ているかで確かめるとよい。

UTF-8 のバイト列としての見え方は JWT の日本語が化ける理由の記事、URL に入れるときの encodeURIComponent の挙動は encodeURIComponent と URLSearchParams の違いの記事、JSON の整形で値が変わる話は JSON 整形の記事 にも書いた。文字のコードポイントは コードポイント確認 で1文字ずつ見られる。

まとめ

  • \ud83d\ude00 は絵文字1文字。\uXXXX は4桁なので、U+10000 以上の文字は UTF-16 のサロゲートペアとして2つで書く。
  • Python で JSON 由来の \u エスケープを戻すなら json.loads。unicode_escape は片割れ2文字になり、UTF-8 に書けない。分かれてしまったら encode('utf-16', 'surrogatepass').decode('utf-16') で組み直す。
  • \u{1F600} は JavaScript の書き方で JSON では使えない。\U0001F600 は Python の書き方。
  • サロゲートの片割れは、JSON として読むところまでは通り、URL・UTF-8・DB に出すところで壊れる。JavaScript では isWellFormed() / toWellFormed() で出す前に確かめる。
  • 文字列を文字数で切るときは、絵文字の途中で切らないよう書記素単位で切る。

参考

  • RFC 8259 The JavaScript Object Notation (JSON) Data Interchange Format — 7. Strings、8.2. Unicode Characters
  • Python 3 ドキュメント codecs — Text Encodings(unicode_escape)、Error Handlers(surrogatepass)
  • Python 3 ドキュメント json — ensure_ascii、Character Encodings
  • MDN Web Docs: String.prototype.isWellFormed()、JSON.stringify()(Well-formed JSON.stringify)

\ud83d\ude00を絵文字に戻す:unicode_escapeが化ける理由
https://blog.hashito.biz/2026/11/04/unicode-escape-surrogate-pair-ud83d-ude00-python-json-lone-surrogate/
著者
hashito
作成日
2026年11月4日
著作権