| | |

JSON 04 ๐Ÿ“„ String Escaping and Character Encoding (UTF-8)

โฑ๏ธ Estimated read: 13 min | |

A JSON string is a sequence of Unicode characters, and the JSON text that carries it must be encoded in UTF-8 when it is exchanged between systems that are not part of a closed ecosystem. The RFC 8259 makes this requirement explicit: “JSON text exchanged between systems that are not part of a closed ecosystem MUST be encoded using UTF-8” . The encoding is the layer that turns the string into bytes for transport, and the escaping is the layer that lets a string contain characters that cannot appear literally. The two are separate concerns, and the interaction between them is where the interoperability problems live.

The escaping rules are small but precise. The quote and the backslash must be escaped. The control characters from U+0000 to U+001F must be escaped. The solidus may be escaped. The \uXXXX form is the escape for any character in the Basic Multilingual Plane, and the characters outside the BMPโ€”the emoji, the musical symbols, the rare CJK ideographsโ€”are represented as a UTF-16 surrogate pair, a twelve-character sequence of two \uXXXX escapes . The encoding is UTF-8, and the escape is the ASCII-safe representation. The two are not the same thing, and the distinction matters for the parsers and the databases.

Key point: JSON strings are Unicode, and the JSON text must be UTF-8 for the interoperable exchange. The escaping is the ASCII-safe representation, and the characters outside the BMP use the UTF-16 surrogate pair. The unpaired surrogates are allowed by the ABNF but unpredictable in the behavior, and the implementations must validate them or replace them.


Why the escaping and the encoding are separate

The transport problem. The JSON text is a sequence of bytes, and the bytes must be interpreted as characters. The UTF-8 is the encoding that the RFC requires, and it is the encoding that every parser accepts. The escaping is the mechanism that represents the characters that the JSON grammar cannot carry literallyโ€”the quote, the backslash, the control charactersโ€”and the characters that the transport cannot carry safely in some contexts. The escaped string is ASCII, and the ASCII is the subset of UTF-8. The escaping is the transport-safe representation, and the encoding is the interpretation of the bytes.

The surrogate pair problem. The characters outside the BMPโ€”the code points from U+10000 to U+10FFFFโ€”are not representable in a single UTF-16 code unit. The UTF-16 represents them as a pair of code units: a high surrogate from U+D800 to U+DBFF and a low surrogate from U+DC00 to U+DFFF. The JSON escape represents the pair as two \uXXXX sequences . The pair is the representation, and the character is the abstract concept. The parser must combine the pair into the single code point, and the serializer must split the code point into the pair. The Apache Thrift issue THRIFT-6122 is the concrete example of the failure: the Ruby JSON protocol converts the high surrogate independently, and Ruby rejects the standalone surrogate with a RangeError before the low surrogate can be combined .

The unpaired surrogate problem. The ABNF grammar allows a lone \uDEADโ€”a single unpaired surrogateโ€”because the grammar does not enforce the pair structure. The RFC 8259 warns that the behavior is unpredictable: the implementations “might return different values for the length of a string value, or even suffer fatal runtime exceptions” . The unpaired surrogate is not a Unicode character, and it has no UTF-8 encoding. The implementation that receives it may reject it, replace it with the U+FFFD replacement character, or store the raw code unit. The Apache Flink issue FLINK-41204 is the concrete example: the VARIANT parser replaces the unpaired surrogate with the ? character, which means the stored text is different from the input and nothing fails . The three behaviors are all valid, and the incompatibility is the problem.

The trade-off. The escaping is the mechanism that makes the string transport-safe, but it is not free. The escape sequences are longer than the literal characters, and the parsing is slower because the escapes must be decoded. The UTF-8 encoding is the efficient transport, and the escaping is the safety net for the characters that the grammar or the transport cannot carry. The two are the layers, and the design is the choice of which layer handles which character.


a. The escape sequences and the Unicode representation

The escape sequences are the backslash-prefixed forms. The \" is the double quote, the \\ is the reverse solidus, the \/ is the solidus, the \b is the backspace, the \f is the form feed, the \n is the line feed, the \r is the carriage return, and the \t is the tab. The \uXXXX is the escape for the four-hex-digit code unit . The escapes are the grammar’s mechanism for the characters that must be escaped, and the \uXXXX is the general form.

// The escape sequences
const escaped = "He said \"hello\" and \\ backslash";
// The result: He said "hello" and \ backslash

// The control character escape
const control = "Line 1\nLine 2";
// The result: the newline between the lines

// The Unicode escape
const unicode = "\u0041"; // The 'A'

The characters outside the BMP are escaped as the surrogate pair. The G clef (U+1D11E) is \uD834\uDD1E, and the grinning face (U+1F600) is \uD83D\uDE00 . The pair is the UTF-16 representation, and the JSON escape is the transport-safe form. The parser combines the pair into the single code point, and the string comparison is the code point comparison, not the code unit comparison .

// The surrogate pair
const clef = "\uD834\uDD1E"; // The G clef (U+1D11E)
const grin = "\uD83D\uDE00"; // The grinning face (U+1F600)

// The parser combines the pair into the single character.
console.log(clef.length); // 2 (the UTF-16 code units)
console.log([...clef].length); // 1 (the code point)

The escape is not the only representation. The JSON text can contain the literal UTF-8 characters, and the parser accepts them if the text is valid UTF-8. The literal emoji and the escaped pair are the same character, and the parser produces the same code point. The escaping is the choice of the transport, not the choice of the data model.


b. The UTF-8 encoding and the byte order

UTF-8 is a variable-width encoding. The ASCII characters (U+0000 to U+007F) are a single byte, and the bytes are identical to the ASCII encoding. The code points from U+0080 to U+07FF are two bytes, the code points from U+0800 to U+FFFF are three bytes, and the code points above U+FFFF are four bytes. The first byte indicates the number of the bytes that follow, which makes the forward parsing efficient and the self-synchronization possible.

The UTF-8 has no byte order problem. The code unit is a single byte, and the byte order is not applicable. The UTF-16 and the UTF-32 have the byte order problem, and the byte order mark (BOM) is the mechanism that distinguishes the big-endian and the little-endian. The RFC 8259 states that the implementations must not add a BOM to the beginning of the network-transmitted JSON text, and the parsers may ignore the BOM if it is present . The distinction between the network-transmitted text and the file content is the nuance: the RFC’s requirement applies to the text exchanged between the systems, not necessarily to the local files. The C++ SG16 discussion notes that the RFC permits the non-UTF-8 encodings in a closed system and permits the BOM in the file contents, while the network transmission requires the UTF-8 without the BOM .

// The UTF-8 encoding of the 'A'
const a = "A";
const bytes = new TextEncoder().encode(a);
// Uint8Array [ 65 ] (the single byte)

// The UTF-8 encoding of the 'รฉ' (U+00E9)
const e = "รฉ";
const eBytes = new TextEncoder().encode(e);
// Uint8Array [ 195, 169 ] (the two bytes)

// The UTF-8 encoding of the '๐Ÿ˜€' (U+1F600)
const emoji = "๐Ÿ˜€";
const emojiBytes = new TextEncoder().encode(emoji);
// Uint8Array [ 240, 159, 152, 128 ] (the four bytes)

The encoding is the layer beneath the string. The JSON parser decodes the bytes as UTF-8, and the string it produces is a sequence of the code points. The escaping is the layer above the string, and the escape sequence is decoded into the code point. The two layers are the transport and the representation, and the parser is the mechanism that connects them.


Complete Example Session

// ============================================
// PART 1: THE ESCAPE SEQUENCES
// ============================================

const escaped = "The quote is \" and the backslash is \\";
// The \" is the double quote.
// The \\ is the reverse solidus.

// ============================================
// PART 2: THE CONTROL CHARACTERS
// ============================================

const control = "Line 1\nLine 2\tTabbed";
// The \n is the line feed.
// The \t is the tab.
// The unescaped control characters are not allowed.

// ============================================
// PART 3: THE UNICODE ESCAPE
// ============================================

const a = "\u0041"; // The 'A'
const e = "\u00E9"; // The 'รฉ'

// The \uXXXX is the four-hex-digit code unit.

// ============================================
// PART 4: THE SURROGATE PAIR
// ============================================

const clef = "\uD834\uDD1E"; // The G clef (U+1D11E)
const grin = "\uD83D\uDE00"; // The grinning face (U+1F600)

// The pair is the UTF-16 representation.
// The parser combines the pair into the single code point.

// ============================================
// PART 5: THE UNPAIRED SURROGATE
// ============================================

const lone = "\uDEAD"; // The unpaired low surrogate

// The ABNF allows it.
// The behavior is unpredictable.
// The parser may reject it, replace it, or store it.

// ============================================
// PART 6: THE UTF-8 ENCODING
// ============================================

const encoder = new TextEncoder();
const bytes = encoder.encode("A"); // Uint8Array [ 65 ]
const emojiBytes = encoder.encode("๐Ÿ˜€"); // Uint8Array [ 240, 159, 152, 128 ]

// The UTF-8 is the variable-width encoding.
// The ASCII is the single byte.
// The emoji is the four bytes.

// ============================================
// PART 7: THE BOM
// ============================================

// The RFC 8259 requires no BOM for the network-transmitted text.
// The implementations may ignore the BOM if present.
// The BOM is the U+FEFF.

// ============================================
// PART 8: THE STRING COMPARISON
// ============================================

// The escaped and the literal are the same string.
const escapedString = "a\\b"; // The backslash
const unicodeString = "a\u005Cb"; // The same backslash
console.log(escapedString === unicodeString); // true (after the parse)

// The comparison is the code point comparison.
// The unescaped comparison is the bug.

// ============================================
// PART 9: THE VALIDATION
// ============================================

// The unpaired surrogate should be rejected.
const invalid = '{"name": "\\uDEAD"}';
try {
  const parsed = JSON.parse(invalid);
  // The behavior is implementation-dependent.
} catch (e) {
  // Some parsers throw.
}

// ============================================
// PART 10: THE CHECKLIST
// ============================================

// [ ] The JSON text is UTF-8 encoded.
// [ ] The control characters are escaped.
// [ ] The quote and the backslash are escaped.
// [ ] The characters outside the BMP use the surrogate pair.
// [ ] The unpaired surrogates are rejected or replaced.
// [ ] The string comparison is the code point comparison.
// [ ] The BOM is not added to the network-transmitted text.

The ten parts covered the escape sequences, the control characters, the Unicode escape, the surrogate pair, the unpaired surrogate, the UTF-8 encoding, the BOM, the string comparison, the validation, and the checklist. The through-line is that the escaping is the transport-safe representation, and the encoding is the interpretation of the bytes.


Quick Reference

The escape sequences

EscapeCharacterCode point
\"The double quoteU+0022
\\The reverse solidusU+005C
\/The solidusU+002F
\bThe backspaceU+0008
\fThe form feedU+000C
\nThe line feedU+000A
\rThe carriage returnU+000D
\tThe tabU+0009
\uXXXXThe code unitU+XXXX

The UTF-8 byte ranges

Code point rangeBytesExample
U+0000โ€“U+007F1A โ†’ 65
U+0080โ€“U+07FF2รฉ โ†’ 195, 169
U+0800โ€“U+FFFF3โ‚ฌ โ†’ 226, 130, 172
U+10000โ€“U+10FFFF4๐Ÿ˜€ โ†’ 240, 159, 152, 128

The surrogate pair

Code pointSurrogate pair
U+1D11E (G clef)\uD834\uDD1E
U+1F600 (grinning face)\uD83D\uDE00

Best Practices

โœ… Do This:

// Encode the JSON text as UTF-8
const json = JSON.stringify(data);
const bytes = new TextEncoder().encode(json);                     // โœ…

// Escape the control characters
const escaped = "Line 1\nLine 2";                                // โœ…

// Use the surrogate pair for the characters outside the BMP
const emoji = "\uD83D\uDE00";                                    // โœ…

โŒ Don’t Do This:

// Add a BOM to the network-transmitted JSON text
const bom = "\uFEFF" + JSON.stringify(data);                     // โŒ

// Use the unpaired surrogate
const lone = "\uDEAD"; // the unpredictable behavior              // โŒ

// Compare the escaped and the unescaped strings without parsing
"a\\b" === "a\u005Cb"; // false before the parse                 // โŒ

Common Pitfalls

PitfallWhy It HappensFix
The string length is wrongThe unpaired surrogateReject or replace the surrogate
The comparison failsThe escaped and the unescaped formsParse both before the comparison
The emoji is corruptedThe wrong encoding or the split surrogateUse the UTF-8 and combine the pair
The BOM breaks the parserThe BOM is addedRemove the BOM for the network transmission

Real-World Examples

1. The basic escape

const s = "He said \"hello\"";

2. The control character

const s = "Line 1\nLine 2";

3. The Unicode escape

const s = "\u0041"; // 'A'

4. The surrogate pair

const s = "\uD834\uDD1E"; // The G clef

5. The unpaired surrogate

const s = "\uDEAD"; // The unpredictable behavior

6. The UTF-8 encoding

const bytes = new TextEncoder().encode("๐Ÿ˜€");

7. The BOM

const bom = "\uFEFF"; // Not for the network transmission

8. The string comparison

JSON.parse('"a\\\\b"') === JSON.parse('"a\\u005Cb"'); // true

9. The validation

try { JSON.parse('{"name":"\\uDEAD"}'); } catch (e) { /* rejected */ }

10. The Flink case

// The unpaired surrogate is replaced with '?'

Visual

The Escape Sequences

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  THE ESCAPE SEQUENCES                                        โ”‚
โ”‚                                                              โ”‚
โ”‚  \"  โ†’  the double quote                                    โ”‚
โ”‚  \\  โ†’  the reverse solidus                                 โ”‚
โ”‚  \n  โ†’  the line feed                                       โ”‚
โ”‚  \t  โ†’  the tab                                             โ”‚
โ”‚  \u0041  โ†’  the 'A' (the Unicode code point)                โ”‚
โ”‚                                                              โ”‚
โ”‚  The unescaped control characters are not allowed.          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Surrogate Pair

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  THE CHARACTERS OUTSIDE THE BMP                              โ”‚
โ”‚                                                              โ”‚
โ”‚  The G clef (U+1D11E)                                       โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ \uD834  \uDD1E      โ”‚  The surrogate pair                โ”‚
โ”‚  โ”‚ (high)  (low)       โ”‚                                    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ”‚             โ”‚                                                โ”‚
โ”‚             โ–ผ                                                    โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ U+1D11E             โ”‚  The single code point             โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ”‚                                                              โ”‚
โ”‚  The parser combines the pair.                              โ”‚
โ”‚  The serializer splits the code point.                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The UTF-8 Byte Ranges

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  THE VARIABLE-WIDTH ENCODING                                 โ”‚
โ”‚                                                              โ”‚
โ”‚  'A'  (U+0041)  โ†’  1 byte   โ†’  65                           โ”‚
โ”‚  'รฉ'  (U+00E9)  โ†’  2 bytes  โ†’  195, 169                     โ”‚
โ”‚  'โ‚ฌ'  (U+20AC)  โ†’  3 bytes  โ†’  226, 130, 172                โ”‚
โ”‚  '๐Ÿ˜€' (U+1F600) โ†’  4 bytes  โ†’  240, 159, 152, 128           โ”‚
โ”‚                                                              โ”‚
โ”‚  The first byte indicates the number of bytes that follow.  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
JSON encodingUTF-8 for the interoperable exchange
Escape sequences\", \\, \/, \b, \f, \n, \r, \t, \uXXXX
Surrogate pairThe characters outside the BMP use the two \uXXXX escapes
Unpaired surrogateAllowed by the ABNF, unpredictable in the behavior
UTF-8 byte ranges1 byte (ASCII), 2, 3, 4 bytes
BOMNot added to the network-transmitted text, may be ignored by the parser

Key takeaways:

  • The JSON text must be UTF-8 for the interoperable exchange. The RFC 8259 requires the UTF-8, and the previous specifications allowed the UTF-16 and the UTF-32 . The UTF-8 is the only encoding that every parser accepts.
  • The escape sequences are the transport-safe representation. The \", \\, and the control character escapes are the grammar’s mechanism for the characters that cannot appear literally. The \uXXXX is the general form .
  • The characters outside the BMP use the surrogate pair. The G clef is \uD834\uDD1E, and the emoji is the pair of the high and the low surrogates . The parser combines the pair into the single code point.
  • The unpaired surrogate is the interoperability hazard. The ABNF allows it, but the behavior is unpredictable: the implementations may reject it, replace it with the U+FFFD, or store the raw code unit . The Flink issue shows the replacement with ? , and the Thrift issue shows the RangeError .
  • The string comparison is the code point comparison. The escaped and the unescaped forms are the same string after the parse, and the comparison must be on the parsed values, not the raw text .

Remember: JSON strings are Unicode, and the JSON text is UTF-8. The escaping is the transport-safe representation, and the surrogate pair is the representation of the characters outside the BMP. The unpaired surrogate is the grammar’s edge and the implementation’s hazard. The encoding is the interpretation of the bytes, and the escaping is the interpretation of the characters. The two are the layers, and the parser is the connection.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!