toolhq.io

All posts
6 min readby Jameel Haider

JSON Unicode escapes: \uXXXX, surrogate pairs, and emoji

JSON lets you write any character two ways: as its literal UTF-8 byte sequence, or as a \uXXXX escape. Both are valid, both parse to the same string, and most of the time the raw form is better. The place people get stuck is emoji and other characters above U+FFFF, where a single \uXXXX is not enough and you need two.

The \uXXXX escape

Inside a JSON string, \u followed by exactly four hexadecimal digits denotes one Unicode code unit. The classic example is é for é and € for the euro sign €. The digits are case insensitive, so € and € are identical. You must supply all four digits, zero padded. \uE9 is a syntax error.

{ "price": "5€", "name": "Café" }

That parses to 5€ and Café.

The BMP limit

Here is the constraint that surprises people. Each \uXXXX escape holds four hex digits, so its maximum value is ￿, which is 65535. That range, U+0000 to U+FFFF, is the Basic Multilingual Plane. It covers almost every modern writing system, but not everything. Emoji, many historic scripts, mathematical alphanumeric symbols, and a growing list of newer characters live above U+FFFF, in what are called the astral or supplementary planes. A single \uXXXX cannot reach them.

Surrogate pairs

To escape a character above U+FFFF, JSON borrows UTF-16's surrogate mechanism. The code point is split into two 16 bit halves that each fall into a reserved range, and you write both as consecutive \uXXXX escapes:

  • The high (lead) surrogate is in \uD800 to \uDBFF.
  • The low (trail) surrogate is in \uDC00 to \uDFFF.

The grinning face emoji is U+1F600. Its surrogate pair is 😀.

{ "reaction": "😀" }

The conversion from code point to pair is:

cp  = 0x1F600
v   = cp - 0x10000          // 0xF600
high = 0xD800 + (v >> 10)   // 0xD83D
low  = 0xDC00 + (v & 0x3FF) // 0xDE00

A common bug is emitting a lone high surrogate with no low surrogate following it. That is an unpaired surrogate and strict validators reject it, because it maps to no real character.

When you need escapes and when you do not

SituationUse
ASCII textRaw characters
Accented Latin, CJK, most living scriptsRaw UTF-8, it is valid and readable
Emoji and astral charactersRaw UTF-8 if the transport is UTF-8 clean; otherwise a surrogate pair
Control characters (U+0000 to U+001F)Must be escaped; \n, \t, or \uXXXX
Output that must survive non UTF-8 channelsEscape everything non ASCII to be safe

The important fact: a JSON document is required to be valid UTF-8 (or UTF-16/UTF-32 with a byte order mark, though UTF-8 is the universal default). That means you almost never need \uXXXX for printable characters. You can paste é and 😀 directly and it is fully valid. Escapes exist for two reasons: representing control characters that cannot appear literally, and producing pure ASCII output for systems that mangle raw bytes. Python's json.dumps escapes non ASCII by default (ensure_ascii=True), which is why you see \u soup in its output. Pass ensure_ascii=False to get readable UTF-8.

The practical takeaway

Prefer raw UTF-8 for anything a human reads. Reach for \uXXXX only for control characters or when a channel is not UTF-8 safe, and remember that one escape covers the BMP while anything above U+FFFF needs a matched surrogate pair. To check that an escape sequence resolves to the character you expect, paste it into the JSON Formatter and read the parsed output.