U+2028 and U+2029 in JSON and JavaScript: what actually breaks
JSON.parse does not break. Measured on Node 22, current Chromium and Python 3.13: a raw U+2028 or U+2029 inside a JSON string parses cleanly in all three. What actually differs is the serializers — JavaScript writes these characters out raw, Python escapes them. And inside JavaScript source code, both characters still count as line terminators, which is where the real bugs come from.
Measured, one row at a time
Each row is a single expression evaluated on Node 22. U+2028 is the line separator,
U+2029 the paragraph separator; both are invisible on screen.
| Where the character sits | Result |
|---|---|
Inside a JSON string, parsed with JSON.parse | OK — parses fine |
Inside a JS string literal ("x
y") | OK |
| Inside a template literal | OK |
| Between two statements, acting as whitespace | OK |
Inside a // comment, with code after it | Ends the comment — the code after it runs |
| Inside a regex literal | SyntaxError: Invalid regular expression |
The last two rows are the ones that bite. A U+2028 inside a line comment terminates the comment, so whatever was meant to be commented out starts executing. A U+2028 inside a regex literal stops the literal from parsing at all. Neither of these looks like anything when you read the file, because the character has no width.
The serializers disagree
This is the part that catches people out across language boundaries. Same object, two languages:
// JavaScript, Node 22
JSON.stringify({a: "x
y"})
// -> {"a":"x
y"} raw character, NOT escaped
# Python 3.13
json.dumps({"a": "x
y"})
# -> {"a": "x\u2028y"} escaped
Python escapes them; JavaScript does not. So JSON produced by a JS service carries the raw character across the wire, while JSON produced by a Python service carries a six-character escape sequence. Both parse fine on the receiving end — until the payload is pasted into a JavaScript file instead of parsed, and the line-terminator behaviour above takes over.
Ten characters that claim to end a line
U+2028 and U+2029 are not a pair of oddities. They are two members of a set of ten, and the set
is the reason this is hard: no two tools agree on where it starts or stops. Sweeping every one of
the 1,112,064 non-surrogate code points through Python 3.13's splitlines() returns
exactly ten boundaries. Seven of them are control characters nobody thinks of as a newline.
| Code point | Name | Bytes in UTF-8 | Legal raw inside a JSON string |
|---|---|---|---|
U+000A | Line feed | 1 | no — must be written \n |
U+000B | Line tabulation | 1 | no — \u000b |
U+000C | Form feed | 1 | no — \f |
U+000D | Carriage return | 1 | no — \r |
U+001C | File separator | 1 | no |
U+001D | Group separator | 1 | no |
U+001E | Record separator | 1 | no |
U+0085 | Next line (NEL) | 2 | yes |
U+2028 | Line separator | 3 | yes |
U+2029 | Paragraph separator | 3 | yes |
That fourth column is the one that bites. JSON only forces an escape below U+0020, so seven of
the ten can never appear raw in a valid JSON string — every writer escapes them, and they
never cause trouble. The other three sit above the escape threshold and travel as themselves.
U+0085 is the one nobody talks about. JSON.stringify leaves it raw
just like U+2028. json.dumps escapes it by default — and writes it raw the
moment you pass ensure_ascii=False, which is exactly what everyone does to get
readable UTF-8 output. Turning that flag off re-opens the hole for all three at once, not for
two.
Who calls it a line break
Same ten characters, five different opinions. Every cell below was evaluated on this machine: JavaScript on Node 22.22, Python on 3.13, the shell column on a file holding three fields separated by the character.
| Code point | JS \s | JS . | Python \s | Python . | Python splitlines() | wc -l |
|---|---|---|---|---|---|---|
U+000A | yes | no | yes | no | splits | 2 |
U+000B | yes | yes | yes | yes | splits | 0 |
U+000C | yes | yes | yes | yes | splits | 0 |
U+000D | yes | no | yes | yes | splits | 0 |
U+001C | no | yes | yes | yes | splits | 0 |
U+001E | no | yes | yes | yes | splits | 0 |
U+001F | no | yes | yes | yes | does not split | 0 |
U+0085 | no | yes | yes | yes | splits | 0 |
U+2028 | yes | no | yes | yes | splits | 0 |
U+2029 | yes | no | yes | yes | splits | 0 |
Two of those columns are worth reading twice, because each one is the same engine contradicting
itself. JavaScript's \s calls U+2028 whitespace while JavaScript's .
refuses to match it — both are the language's own verdict on one character. And U+0085 is
the exact mirror image across languages: Python's \s matches it, JavaScript's does
not.
The full sweep makes the asymmetry concrete. Over all 1,112,064 non-surrogate code points,
JavaScript's . refuses exactly four — U+000A, U+000D, U+2028, U+2029 —
and \s matches exactly 25. Python's . refuses exactly one, U+000A, and its
\s matches 29 code points against JavaScript's 25 — the whole disagreement, in
one pair of numbers. So the same pattern on the same string:
# Python 3.13
re.fullmatch(r"a.b", "a
b") # matches
// JavaScript, Node 22
/^a.b$/.test("a
b") // false
It is not only regexes. A string made of nothing but one of these characters is empty to one
language and a character long to the other: JavaScript's trim() empties six of the
eleven — U+000A, U+000B, U+000C, U+000D, U+2028, U+2029 — while Python's
strip() empties all eleven, including U+001C, U+001D, U+001E, U+001F and U+0085.
A field containing only U+0085 passes a browser-side emptiness check and fails a
Python-side one. That is the same bug as the trimming above, wearing different
clothes.
The shell column is where a lot of lost afternoons come from. For three fields separated by
U+000A, wc -l reports 2 and awk 'END{print NR}' reports 3. Swap the
separator for any of the other nine and wc -l reports 0 while
awk and grep -c '' report 1. Same file, same bytes, and Python's
splitlines() insists there are three lines all along.
Comma-separated files have their own opinion. Python's csv writer quotes a field
for exactly two of the ten — U+000A and U+000D — and writes the other eight bare.
Read the result back and it is one field with two values in it, unquoted and unremarkable, right
up until something calls splitlines() on it.
The browser is the one that does not break
Here is the part that surprises people who have only met these characters through JavaScript.
A browser will not start a new line at U+2028 or U+2029. Not in any mode. Measured in Chromium
at 16 px monospace with white-space: pre, the string AAAA + U+2028 +
BBBB lays out as a single line box 26 px tall; the same string with U+000A is
50 px. I tried all five values — normal, pre,
pre-wrap, pre-line, nowrap — and got one line box
and 26 px every single time.
It is not free, though. The run measures 72 px against 64 px for the same eight letters with nothing between them: one character cell of advance, and zero ink. Drawn on a canvas at the same size, the string with U+2028 colours exactly as many pixels as the string without it, 224 each, where an empty draw colours none. It takes up room on the line and shows you nothing for it.
Form fields do not normalise it either. A <textarea> hands U+2028 and U+2029
back byte for byte. It does silently rewrite U+000D into U+000A, which is the normalisation
people are used to relying on — and that normalisation stops one character short of the
two characters on this page.
The two numbers in that report are the whole point of this section. Paste a block that was
separated with U+2028 and the tool will tell you it is one line by \n and several
lines by the ten-character rule — which is precisely the disagreement that makes a file
look right in an editor and wrong in a script.
The one-line fix when you generate JS from data
If you are writing JSON into a <script> tag or building JavaScript source from
data, escape these two characters explicitly. JSON.stringify will not do it for you:
const safe = JSON.stringify(data)
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
The output still parses to the same value — verified by round-tripping it back through
JSON.parse — but it now survives being pasted into source code. U+0085 does
not need the same treatment for JavaScript: inside a JavaScript string literal it is an ordinary
character, and always has been. It needs it for Python, where splitlines() will cut
your value in half. Escape for the language that will read the file, not for the one that wrote
it.
Scan a file for all ten
If you suspect a file, do not eyeball it — count. These two snippets report every one of the ten characters above, with counts, and print nothing for the ones that are absent. Both were run against the same test file holding one U+000A, one U+2028 and one U+2029, and both reported exactly that.
// Node — save as scan.js, run: node scan.js file.txt
const fs = require("fs");
const text = fs.readFileSync(process.argv[2], "utf8");
const set = ["\u000A","\u000B","\u000C","\u000D","\u001C","\u001D","\u001E","\u0085","\u2028","\u2029"];
for (const c of set) {
const n = [...text].filter(ch => ch === c).length;
if (n) console.log("U+" + c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0") + ": " + n);
}
# Python — run: python3 scan.py file.txt
import sys
text = open(sys.argv[1], encoding="utf-8").read()
chars = ["\u000A","\u000B","\u000C","\u000D","\u001C","\u001D","\u001E","\u0085","\u2028","\u2029"]
for c in chars:
if text.count(c):
print(f"U+{ord(c):04X}: {text.count(c)}")
Run it on the file as received, before any editor or pipeline has had a chance to normalise
it — an editor that rewrites U+2028 into a plain newline on save will hide the evidence.
If the scan finds U+0085, check the Python side first: ensure_ascii=False in
json.dumps is the usual way it gets in.
Checking whether you have them
All ten are invisible, so the only way to know is to look. The tool above counts them and rewrites them. The zero-width space remover shows you where invisible characters sit in a block of text, and the invisible characters list has the code points. For a payload that will not parse at all, start with the JSON cleaner. If the text came out of a Word document, the Word cleaner strips the same class of characters from pasted Word text; for text that looks empty but is not, see hidden text and invisible text.
What this page does not claim
These measurements come from Node 22.22, Chromium 153 and Python 3.13 — the runtimes available here. Older JavaScript engines treated U+2028 and U+2029 as line terminators everywhere, including inside string literals, which is where this character's reputation comes from; that was relaxed in ES2019, and I could not test pre-2019 engines on this machine, so this page makes no claim about them. The eleven candidates above are the ones worth arguing about, not a complete theory of line breaking: Unicode's own line-breaking algorithm has a far larger set of break opportunities, and none of the tools measured here implement it. Run the snippets above against the runtimes you actually ship to.
Measured 2026-09-21 and 2026-10-07 on Node 22.22, Chromium (Playwright) and Python 3.13. Scan snippets verified 2026-11-10. Tools: Claude watermark remover, zero-width space remover, JSON cleaner, invisible characters list, what they cost in tokens, the tag block that hides whole messages, hidden text, invisible text, and the Word cleaner.