Skip to main content

Bad Control Character in String Literal: Raw Newlines and Tabs in JSON

The rule is narrower than the message suggests. Only U+0000 through U+001F are forbidden, only inside a string, and a pretty-printed document full of newlines is perfectly valid. What matters is which side of a quotation mark the character sits on.

The short answer

A JSON string may not contain a raw control character — anything from U+0000 to U+001F. A newline, a tab, and a carriage return are all in that range, so pressing Enter inside a quoted value produces a document no standard parser will read. Written as the two- character escapes \n, \t, and \r, the same values are fine.

The rule is narrower than it sounds, and the narrowing is where people get stuck. It applies only inside a string. A pretty-printed document is full of newlines and entirely valid, because those newlines sit between tokens rather than within them.

Inside a string, or outside it

One character, two verdicts. Nothing distinguishes these but which side of a quotation mark the newline falls on:

{"a": "line1⏎line2"}
     └─ newline inside the value        invalid

{⏎  "a": 1⏎}
 └─ newline between tokens              valid

So the question to ask is never “does my JSON contain newlines” — almost all JSON does. It is whether a newline landed inside a quoted value. Outside a string, newlines, tabs, carriage returns, and spaces are simply whitespace, and JSON allows as much of it as you like between tokens.

Which characters are actually forbidden

Exactly U+0000 through U+001F. That includes the null character, backspace, form feed, and everything else below the space character — not only the newline that brings most people here.

The boundary is worth knowing precisely, because the name misleads. U+007F, the delete character, is conventionally called a control character but sits abovethe range, and both parsers measured here accept it raw inside a string. So “is this a control character” is the wrong test; “is this below U+0020” is the right one. Any of them may also be written as a \u00XX escape, which is always valid.

DocumentWhat it isVerdict
{"a": "line1⏎line2"}Real newline inside a stringInvalid
{"a": "line1\nline2"}Escaped newlineValid
{"a": "col1→col2"}Real tab inside a stringInvalid
{"a": "col1\tcol2"}Escaped tabValid
{"a": "line1␍line2"}Real carriage return inside a stringInvalid
{"a": "line1\rline2"}Escaped carriage returnValid
{"a": "x␀z"}U+0000 null inside a stringInvalid
{"a": "x␈z"}U+0008 backspace inside a stringInvalid
{"a": "x␌z"}U+000C form feed inside a stringInvalid
{"a": "x␟z"}U+001F inside a string — the last forbidden oneInvalid
{"a": "x␡z"}U+007F delete inside a string — outside the forbidden rangeValid
{"a": "x\u0000z"}U+0000 written as an escapeValid
{⏎ "a": 1⏎}Newlines between keys, outside any stringValid
{→"a": 1}Tab used as indentation, outside any stringValid
{⏎ "a": {⏎ "b": [1, 2]⏎ }⏎}Ordinary pretty-printed documentValid

The , , and marks stand in for real characters that would otherwise be invisible here — which is the practical difficulty with this error in the first place.

Two ecosystems, two error strings

Searches for this problem split by language, because the wording does. JavaScript engines say Bad control character in string literal; Python says Invalid control character at. They describe the same defect, and neither phrase appears in the other ecosystem.

InputV8 13.6Python 3.9.6
{"a": "line1⏎line2"}position 12 (line 1 column 13)line 1 column 13 (char 12)
{"a": "col1→col2"}position 11 (line 1 column 12)line 1 column 12 (char 11)
{"a": "line1␍line2"}position 12 (line 1 column 13)line 1 column 13 (char 12)
{"a": "x␀z"}position 8 (line 1 column 9)line 1 column 9 (char 8)
{"a": "x␈z"}position 8 (line 1 column 9)line 1 column 9 (char 8)
{"a": "x␌z"}position 8 (line 1 column 9)line 1 column 9 (char 8)
{"a": "x␟z"}position 8 (line 1 column 9)line 1 column 9 (char 8)

Read the coordinates side by side: the two engines agreed on the offending character in every case measured. That agreement is what makes a second parser genuinely useful here — when both point at the same offset, the location is settled.

Where the character came from

Serializers do not produce this. json.dumps, JSON.stringify, and their equivalents escape control characters by definition. So a document with a raw newline in a string was almost certainly assembled as text:

# The usual origin: JSON assembled as text.
note = "first line\nsecond line"          # a real newline in the value
body = '{"note": "' + note + '"}'         # pasted straight into the document
json.loads(body)
# JSONDecodeError: Invalid control character at: line 1 column 21 (char 20)

# Serialising instead escapes it for you:
body = json.dumps({"note": note})
# {"note": "first line\nsecond line"}     <- two characters, backslash and n

The value itself is perfectly ordinary — a multi-line note, an address, a stack trace, a commit message. It only becomes a problem when it is dropped into JSON text without being escaped on the way in.

Model-generated JSON and tool-call arguments

A newer and now common source: JSON produced by a language model. When a model emits structured output or tool-call arguments containing multi-line text — a commit message, a code block, a formatted description — it sometimes writes a real newline where the escape sequence belongs. The receiving parser then fails on the whole call.

The diagnosis and the fix are the ones above; nothing about the cause is special. What differs is where to apply them. The producer is the model's output path, so the durable fix belongs in the layer that receives and validates that output — escaping control characters before parsing, or re-requesting properly escaped output — rather than in the consumer downstream.

Python's strict=False, and its cost

Python has an opt-out. json.loads accepts a strict keyword, and setting it to False allows raw control characters inside strings:

# Python only. Accepts raw control characters inside strings:
json.loads(text, strict=False)

# There is no equivalent in JSON.parse, jq, Go, or most other consumers,
# so a document that needs this flag is not portable JSON.

It is a legitimate tool for reading a file you cannot change. It is not a fix, and it is worth being clear why: the document remains invalid JSON, and the flag exists only in Python. The same text handed to JSON.parse in a browser, to a Go service, or to jq will fail exactly as before. Reaching for it means accepting that the data is no longer interchangeable — which is the one thing JSON is for.

Finding a character you cannot see

Both parsers report a position, a line, and a column, and both point at the offending character rather than somewhere near it. Since the character does not render, those coordinates are doing work that reading the document cannot.

One caveat worth knowing if your numbers do not line up. CPython ships a C-accelerated decoder, and a pure-Python fallback used by PyPy and some restricted builds. The two do not agree: the fallback names the character but reports it one position further along, on the following line.

# Default build, C accelerator:
Invalid control character at: line 1 column 13 (char 12)

# Same input, pure-Python decoder (PyPy and some restricted builds):
Invalid control character '\n' at: line 2 column 1 (char 13)
#                          ^ names the character  ^ one further along

There is an open CPython issue about that discrepancy. In practice the default build agrees with V8, so a cross-check between Python and a JavaScript engine is reliable — but if a position looks off by one, which decoder produced it is the thing to check.

Fixing it

  1. 1. Read the reported position before anything elseBoth parsers point at the character itself, and the character is invisible, so the coordinates are doing work no amount of staring at the document will do. Print the surrounding text with repr() or a hex view rather than looking at it in an editor.
  2. 2. Check which side of a quotation mark it is onA newline between two keys is formatting and entirely legal. The same newline one character later, inside the string, is an error. This is the distinction the message does not make, and it decides whether there is a problem at all.
  3. 3. Find out who built the documentAlmost every occurrence traces back to JSON assembled by string concatenation or a template, rather than produced by a serializer. Fixing the producer removes the whole class of problem; patching the text removes one instance of it.
  4. 4. Use the serializer your language already hasjson.dumps, JSON.stringify, encoding/json and their equivalents escape control characters correctly by definition. If you genuinely must emit JSON text by hand, escape newline as \n, tab as \t, carriage return as \r, and anything else below U+0020 as \u00XX.
  5. 5. Re-check the corrected documentParsing again is a faster confirmation than reading it, particularly because the characters you are looking for do not render. If a second parser agrees, the fix held.

Checking a document

To get V8's reading of a document Python only described — or simply to locate the character — paste it into the JSON Formatter. It reports V8's message with a position, line, and column measured against the text you pasted, including any leading blank lines, so the coordinates match what is in front of you:

Invalid JSON: Bad control character in string literal in JSON at position 11 (line 4 column 9)

It does not repair anything. It will not escape a newline, strip a control character, or rewrite the string for you — invalid input produces the error and no output. Once the document is valid it formats it, which is a quick way to confirm the fix held. Parsing runs in your browser, so a payload containing real data is not uploaded anywhere.

If the message is different

Two neighbours are easy to confuse with this one. If the parser complains about a property name, a quote, or a comma, the document's structure is wrong rather than a string's contents — Expecting property name enclosed in double quotes covers that family. If the error mentions < or <!DOCTYPE, no JSON arrived at all and the response was an HTML page.

Versions measured

Error wording is an implementation detail and changes between releases. The strings on this page were produced by:

  • V8 / Node.js13.6 / 24.16.0
  • Python (stdlib json)3.9.6
  • DataToolsHQ JSON Formatternative JSON.parse (V8)

The rule underneath does not change: U+0000 to U+001F must be escaped inside a string, in every version and every language.