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 sitsResult
Inside a JSON string, parsed with JSON.parseOK — parses fine
Inside a JS string literal ("x
y")OK
Inside a template literalOK
Between two statements, acting as whitespaceOK
Inside a // comment, with code after itEnds the comment — the code after it runs
Inside a regex literalSyntaxError: 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 pointNameBytes in UTF-8Legal raw inside a JSON string
U+000ALine feed1no — must be written \n
U+000BLine tabulation1no — \u000b
U+000CForm feed1no — \f
U+000DCarriage return1no — \r
U+001CFile separator1no
U+001DGroup separator1no
U+001ERecord separator1no
U+0085Next line (NEL)2yes
U+2028Line separator3yes
U+2029Paragraph separator3yes

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 pointJS \sJS .Python \sPython .Python splitlines()wc -l
U+000Ayesnoyesnosplits2
U+000Byesyesyesyessplits0
U+000Cyesyesyesyessplits0
U+000Dyesnoyesyessplits0
U+001Cnoyesyesyessplits0
U+001Enoyesyesyessplits0
U+001Fnoyesyesyesdoes not split0
U+0085noyesyesyessplits0
U+2028yesnoyesyessplits0
U+2029yesnoyesyessplits0

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.

0 characters

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.