Escaping a String for JSON: Three Characters Are Mandatory, and One Is a Trap
Published 7/31/2026 · 13 min read · Developer tools
Daniel Okonkwo — Front-end developer and tech writer at Allin
Web performance · File formats
Checked against 4 sources
RFC 8259 section 7 names three things that MUST be escaped inside a JSON string and nothing else: the quotation mark, the reverse solidus (backslash), and every control character from U+0000 to U+001F. Everything else may stay as literal UTF-8 — accented letters, ideographs, emoji, the DEL character at U+007F, the line separator at U+2028. Any character MAY be escaped, which is where the habit of writing \/ for a forward slash comes from: JSON never requires it, and the only reason to do it is embedding JSON inside an HTML script tag, where the sequence </ has to be broken up. Eight two-character escapes exist — \" \\ \/ \b \f \n \r \t — and anything else uses \uXXXX, four hex digits, one UTF-16 code unit. A character outside the Basic Multilingual Plane needs two of them: an emoji is written as a twelve-character surrogate pair, never a six-digit escape. That is where the trap sits. JSON strings are sequences of UTF-16 code units, and the grammar allows a code unit that is half a pair with no partner — the specification says so in section 8.2, giving � as its example. Such a JSON text parses without complaint. But section 8.1 requires JSON exchanged between systems to be UTF-8, and RFC 3629 section 3 prohibits UTF-8 from encoding anything between U+D800 and U+DFFF. Measured on this tool: the text {"k":"id-�-end"} parses, and encoding the result to UTF-8 yields the bytes 69 64 2d ef bf bd 2d 65 6e 64 — the lone half has become U+FFFD, the replacement character, and the value no longer round-trips. Nothing threw. That is the failure to look for.
RFC 8259 requires exactly three things to be escaped inside a JSON string. Everything else is optional. The one that actually breaks pipelines is a lone surrogate — legal in JSON text, impossible in UTF-8, and silently replaced the moment your data is written out.
Three mandatory, everything else optional
The rule is shorter than most people assume. All Unicode characters may be placed within the quotation marks except the ones that must be escaped: the quotation mark itself, the reverse solidus, and the control characters U+0000 through U+001F. Three items. A Windows path with backslashes and quotes in it, C:\Users\Léa\"report".txt, needs every backslash doubled and every quote escaped, and the é stays exactly as it is. There is no rule about accents, no rule about non-Latin scripts, no rule about emoji.
Two boundaries are worth checking rather than guessing. The DEL character at U+007F is not a control character for this purpose — the range stops at U+001F — so it stays literal, and running it through the tool confirms it: the output is the character itself, one byte in UTF-8, no escape. The line separator U+2028 and the paragraph separator U+2029 are likewise left alone, because they are not in the U+0000 to U+001F range either. That was once a live hazard when JSON was pasted into an inline script, since a JavaScript string literal could not contain them; the language was changed in 2019 to allow it. If your output still has to survive an old parser, the tool's ASCII-only mode escapes them to \u2028 and \u2029 along with everything else above the ASCII range.
The escaped forward slash deserves a paragraph because it is the most common thing people do without knowing why. JSON permits \/ and never requires it. The habit comes from putting JSON inside an HTML script element, where a literal </script> inside a string would close the element early and hand the rest of your data to the HTML parser; escaping the slash breaks the sequence up and the browser never sees it. The tool has a switch for it, off by default, which is the right default: turn it on when you are embedding, leave it off everywhere else. Escaping slashes in an API response makes the payload bigger and the values no different.
UTF-16 code units, not characters
The \uXXXX escape carries exactly four hex digits, which is one UTF-16 code unit and covers U+0000 to U+FFFF. Anything above that has to be written as two of them, and the specification is explicit: to escape an extended character that is not in the Basic Multilingual Plane, the character is represented as a twelve-character sequence encoding the UTF-16 surrogate pair. There is no six-digit form. Put a grinning face through the tool in ASCII mode and it emits 😀, which is right; if you ever see \u1f600 in someone's output, it is not JSON, it is a different escape convention that leaked in from Python or from a shell.
This is why the tool shows four different counts for the same string and why they disagree. A grinning face is one code point, two UTF-16 units and four UTF-8 bytes. A family emoji built from four people joined by zero-width joiners is seven code points, eleven UTF-16 units and twenty-five UTF-8 bytes. If your database column is declared as twenty characters, which of those three numbers it means is a property of the database and not of your data, and the gap between them is where truncation errors live.
The lone surrogate, and how one gets into your data
A surrogate pair is two code units that only mean something together: a high half from U+D800 to U+DBFF followed by a low half from U+DC00 to U+DFFF. The JSON grammar does not enforce the pairing. Section 8.2 of RFC 8259 says so in as many words, noting that the specification allows string values to contain bit sequences that cannot encode Unicode characters, and offering � as its example. So {"k":"id-�-end"} is a syntactically valid JSON text. Every parser you are likely to meet accepts it.
The contradiction arrives one section earlier. Section 8.1 requires JSON text exchanged between systems that are not part of a closed ecosystem to be encoded in UTF-8, and RFC 3629, which defines UTF-8, prohibits encoding character numbers between U+D800 and U+DFFF, precisely because they are reserved for UTF-16. So a value that JSON permits cannot be expressed in the encoding JSON mandates. What implementations do instead of failing is substitute: run that JSON text through a parser and re-encode the result, and the bytes come back 69 64 2d ef bf bd 2d 65 6e 64. Those three middle bytes, EF BF BD, are U+FFFD, the replacement character. The value that went in is not the value that came out, and nothing anywhere raised an error.
Nobody types a lone surrogate. They arrive from truncation. Take the string Rapport 📊 final, sixteen UTF-16 units for fifteen code points, and cut it to nine units to fit a label — the result is Rapport followed by half an emoji, and the tool escapes it as Rapport �. That is the everyday origin: a database column with a character limit, a UI that trims a title, a log line clipped at a fixed width, an import that copies fixed-length fields. Anywhere a string is cut by counting units rather than code points, the cut can land in the middle of a pair.
The tool gives you two ways to spot one before it travels. The escaper always writes a lone surrogate as \uXXXX even in its default, non-ASCII mode — exactly what a modern JSON.stringify does — so an unexpected � or � in the output is the signal. And the byte counter, which reports the same numbers as a real UTF-8 encoder, will already have counted that half as three bytes: three bytes is what U+FFFD costs, so a string whose escaped form contains a lone surrogate is being billed for a replacement character before it has left your screen.
Where this tool is deliberately more permissive than a parser
In the escaping direction, the tool's default output matches what a modern JSON.stringify produces, character for character, over every case tried — control characters, emoji, combining accents, Windows paths, lone surrogates. If you only ever use the default settings, the result is the body of a JSON string with the outer quotes removed, and nothing more surprising than that.
In the unescaping direction it is deliberately looser, and knowing where matters. A real JSON parser rejects a raw control character inside a string: paste a literal bell character into a JSON document and you get a syntax error saying so. This tool accepts it and hands the character back, on the reasoning that you are inspecting a fragment rather than validating a document. So passing a string through the unescape mode without complaint does not prove the surrounding JSON is valid. It does still reject the three things that make a fragment meaningless: an unknown escape such as \x, an incomplete or non-hexadecimal \u, and a backslash dangling at the very end, and it reports the position and the offending sequence for each.
The ASCII-only switch is the other setting worth understanding, because it changes the size of your payload rather than its meaning. Escaping everything above the ASCII range makes the output safe to carry through a system with an uncertain encoding, at a real cost: Café goes from four characters to nine, and a single emoji from two to twelve. Use it when the transport is questionable — an old log aggregator, a URL, a header, a database whose collation you do not trust — and not otherwise, because UTF-8 is what section 8.1 asks for and it is smaller.
| Input | Required by RFC 8259? | Tool output (default mode) |
|---|---|---|
| A double quote | Yes — must | Two-character escape |
| A backslash | Yes — must | Doubled |
| A tab, U+0009 | Yes — must (a control character) | The short form for a tab, not a six-character escape |
| The bell character, U+0007 | Yes — must (a control character with no short form) | A six-character escape ending in 0007 |
| DEL, U+007F | No — the control range stops at U+001F | Passed through literally, one UTF-8 byte |
| A forward slash | No — may be escaped, never must | Left alone unless the slash option is on |
| An emoji outside the BMP | No — literal UTF-8 is fine | Kept as-is; in ASCII mode, a twelve-character surrogate pair |
| A lone surrogate half | Representable, but not encodable in UTF-8 | Always escaped as \uXXXX, even in default mode — the warning sign |
Frequently asked questions
- Which characters must I escape in a JSON string?
- Exactly three kinds, per RFC 8259 section 7: the double quote, the backslash, and every control character from U+0000 to U+001F. Nothing else is mandatory. Accented letters, ideographs, emoji, DEL at U+007F and the separators at U+2028 and U+2029 may all sit in the string as literal UTF-8. Of the control characters, five have short forms — backspace, form feed, line feed, carriage return and tab — and the rest need the six-character \u form, which is how a bell character or a null byte is written. Anything may be escaped if you want to, which is why perfectly valid JSON sometimes arrives with every non-ASCII character spelled out; it is larger, not more correct.
- How do I write an emoji in a JSON string?
- Two ways, and both are correct. Leave it as literal UTF-8 — nothing in the specification requires escaping it, and this is what the tool does by default. Or, if the payload has to be ASCII, write the surrogate pair: two \uXXXX escapes, twelve characters in total, one for the high half and one for the low half. There is no six-digit form: \u1f600 is not JSON, and a parser that accepts it is not doing what the standard says. The cost of the ASCII form is real — a single emoji goes from two characters to twelve — so use it only when the transport cannot be trusted with UTF-8.
- What is a lone surrogate and why does it break things?
- It is half of a two-unit encoding with no partner: a code unit between U+D800 and U+DFFF standing on its own. JSON permits it — RFC 8259 section 8.2 explicitly acknowledges that string values may contain bit sequences that cannot encode Unicode characters, and gives an unpaired surrogate as the example — but UTF-8 cannot express it, because RFC 3629 prohibits encoding anything in that range. Since section 8.1 requires UTF-8 for interchange, the two rules collide, and what happens in practice is substitution: the half becomes U+FFFD, the replacement character, three bytes, and the original value is gone. No exception is thrown anywhere along the way, which is why this surfaces days later as a search that no longer matches or an identifier that no longer joins.
- Should I escape the forward slash?
- Only when you are embedding the JSON inside an HTML script element. JSON allows \/ and never requires it; the escape exists because a literal </script> inside a string value would close the element early and hand the remainder of your data to the HTML parser as markup. Breaking the sequence with a backslash stops that. Everywhere else — an API response, a file on disk, a message on a queue — escaping slashes only inflates the payload. The tool ships with the option off, which is the right default, and turning it on changes nothing about what the value means once parsed.
- The tool accepted my string but my parser rejects it. Why?
- Because the unescape direction is deliberately more tolerant than a document parser, and the difference is control characters. A JSON parser rejects a raw control character sitting literally inside a string — the error message usually says bad control character in string literal — while this tool accepts it and hands it back, on the assumption that you are inspecting a fragment rather than validating a document. If your parser complains and the tool did not, look for a raw tab or newline that should have been written as an escape. The tool does still reject the three defects that make a fragment unreadable: an unknown escape sequence, an incomplete or non-hexadecimal \u, and a backslash left dangling at the end, and it tells you the position of each.
Articles you may find interesting
All guides →Related tools
This describes how a file format and a renderer behave, verified against the specification cited and against the tool's own code as it stands today. Renderers disagree: GitHub, GitLab, a static-site generator and your editor's preview are four different implementations, and a construct that works in one may not work in another. Nothing here is a guarantee about your pipeline — test the output where it will actually be published, and treat any tool, this one included, as something to check rather than something to trust.
Sources
- RFC Editor — RFC 8259, The JavaScript Object Notation (JSON) Data Interchange Format, December 2017, Internet Standard STD 90 — section 7 lists the characters that MUST be escaped (quotation mark, reverse solidus, U+0000 to U+001F), states that any character MAY be escaped, and defines the \uXXXX form and the twelve-character surrogate pair; section 8.1 requires UTF-8 for interchange; section 8.2 acknowledges that the grammar permits an unpaired surrogate such as �
- RFC Editor — RFC 3629, UTF-8, a transformation format of ISO 10646, November 2003 — section 3 prohibits UTF-8 from encoding character numbers between U+D800 and U+DFFF because they are reserved for use with the UTF-16 encoding form
- Ecma International — ECMA-404, The JSON Data Interchange Syntax, 2nd edition, December 2017 — the syntax half of the same standard, published in parallel with RFC 8259 and freely downloadable
- Ecma International — ECMAScript Language Specification, JSON.stringify — since the well-formed JSON.stringify change of ES2019, a lone surrogate in a JavaScript string is emitted as a \uXXXX escape rather than as an unpaired code unit, which is the behaviour this tool reproduces
Spotted a mistake in this article?