Quick Reference
Syntax errors (fences, commas, quoting, truncation) -- safe to auto-fix, the intended text is recoverable from context
Missing or fabricated fields -- not safe to auto-fix with a guess; the correct move is to flag it, not invent it
You ask an LLM to return JSON. It works. You ship it. Then, at 2am, three weeks later, JSON.parse() throws on a response that looks completely normal in the logs. You stare at it for a full minute before you spot the problem: a trailing comma, hiding in plain sight after the last field.
If you've built anything on top of an LLM API and asked it for structured output, you've hit this. Even with a system prompt that says "output only valid JSON," even with JSON mode enabled, models still produce invalid JSON often enough that you need a repair strategy, not just a validator that tells you something's wrong.
This guide walks through every error pattern LLMs actually produce, in the order a repair pipeline should realistically try to fix them, with working code for each one. At the end, we'll cover a problem most "JSON repair" tools don't talk about: some of them don't just fix syntax -- they fill in missing data with a guess, and that's a very different kind of "fix."
1. The Problem: Why "Just Ask for JSON" Isn't Enough
An LLM is a text generator. Valid JSON is a side effect of that text happening to be well-formed -- it's not a guarantee enforced by the model itself, unless you're specifically using a constrained/structured-output mode that enforces a grammar at generation time. Even then, plenty of production pipelines call chat completion endpoints directly, without structured output enforcement, and get back something like this:
Sure! Here's the JSON you asked for:
```json
{
"name": "Alice",
"age": 30,
"tags": ["admin", "billing"],
}
```
Let me know if you need anything else!Three separate problems in one response: conversational prose wrapping the answer, a markdown code fence, and a trailing comma after the last array element. None of these are edge cases -- they're the default behavior of a model that was trained to sound helpful and write code the way humans write code, trailing commas included.
2. The Cause: Eleven Failure Patterns, One Root Cause
Every LLM JSON failure traces back to the same root cause -- the model is pattern-matching "what JSON-like text looks like" from its training data, which includes JavaScript object literals (which permit trailing commas and unquoted keys), Markdown-formatted answers (which wrap code in fences), and conversational writing (which adds a sentence before and after the "answer"). None of that is JSON-illegal from the model's perspective, because most of it isn't illegal in the contexts it learned from.
Here is the realistic order to detect and fix these, from "doesn't even require touching the string content" to "requires structural repair":
2.1 Non-JSON Wrapping
- Markdown code fences:
```json ... ``` - Conversational prose before or after:
"Sure! Here's your JSON: {...}" - Byte-order marks and stray control characters from copy-paste or streaming artifacts
2.2 Encoding Artifacts
- Smart/curly quotes (
โโโโ) instead of straight ones -- common when a model has been "writing prose" in the same turn - En-dashes and em-dashes where a hyphen was intended
- Non-breaking spaces that look like regular spaces but aren't
- Orphaned Unicode surrogate pairs from truncated multi-byte sequences
2.3 Illegal Literals
- Illegal escape sequences inside string values
NaN,Infinity,-Infinity-- valid in JavaScript, illegal in JSON, and models mix the two constantly
2.4 Structural Syntax Errors
- JavaScript-style
//and/* */comments - Trailing commas before
}or] - Unquoted or single-quoted object keys and string values
2.5 Truncation
- The response gets cut off mid-object or mid-array because of a token limit, leaving unclosed brackets
Notice what's not on this list: a field that's missing entirely, or a value that's just wrong. Those aren't syntax problems -- they're semantic problems, and fixing them requires guessing, which is a fundamentally different kind of "fix" (more on that in section 5).
3. The Solution: A Repair Pipeline That Tries Cheap Fixes First
The efficient approach isn't one giant regex -- it's a staged pipeline that attempts progressively more invasive fixes, re-testing with a real parse after each stage, and stopping the moment a valid parse succeeds.
3.1 JavaScript / Node.js -- A Minimal Staged Pipeline
function repairJson(raw) {
let text = raw;
// Stage 1: strip BOM and non-printable control characters
text = text.replace(/^\uFEFF/, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
// Stage 2: pull JSON out of a markdown fence or surrounding prose
const fence = text.trim().match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
if (fence) {
text = fence[1].trim();
} else {
const start = text.search(/[{\[]/);
if (start > 0) text = text.slice(start);
}
// Stage 3: normalize smart quotes and dashes
text = text
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/\u2013/g, '-')
.replace(/\u2014/g, '--');
// Stage 4: JS-only literals JSON doesn't allow
text = text.replace(/-?Infinity\b|NaN\b/g, 'null');
// Try a cheap parse before doing anything more invasive
try { return JSON.parse(text); } catch {}
// Stage 5: strip comments, fix trailing commas, quote bare keys
text = text.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, '');
text = text.replace(/,\s*([}\]])/g, '$1');
text = text.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)(\s*:)/g, '$1"$2"$3');
try { return JSON.parse(text); } catch {}
// Stage 6: close unclosed brackets (truncated response)
const stack = [];
let inString = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (ch === '\\') { i++; continue; }
if (ch === '"') inString = false;
} else {
if (ch === '"') inString = true;
else if (ch === '{') stack.push('}');
else if (ch === '[') stack.push(']');
else if (ch === '}' || ch === ']') stack.pop();
}
}
text += (inString ? '"' : '') + stack.reverse().join('');
return JSON.parse(text); // let this throw if truly unrecoverable
}3.2 Python -- Same Strategy
import json
import re
def repair_json(raw: str):
text = raw.strip()
# Strip markdown fences
fence = re.match(r'^```(?:json)?\s*\n?(.*?)\n?```$', text, re.DOTALL | re.IGNORECASE)
if fence:
text = fence.group(1).strip()
else:
match = re.search(r'[{\[]', text)
if match and match.start() > 0:
text = text[match.start():]
# Normalize smart quotes
text = (text.replace('\u2018', "'").replace('\u2019', "'")
.replace('\u201c', '"').replace('\u201d', '"'))
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Remove trailing commas
text = re.sub(r',\s*([}\]])', r'\1', text)
# Quote bare keys
text = re.sub(r'([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)(\s*:)', r'\1"\2"\3', text)
return json.loads(text) # raise if still unrecoverable3.3 Prompting to Reduce How Often You Need Any of This
The cheapest fix is preventing the error from being generated in the first place. This won't get you to zero, but it measurably reduces the failure rate:
SYSTEM_PROMPT = """
Output ONLY raw JSON. No markdown code fences. No explanation before
or after. No trailing commas. All keys and string values must use
double quotes.
"""
// Combine with your provider's structured-output / JSON mode when
// available -- prompting reduces the failure rate, structured output
// modes enforce a grammar at generation time and get you much closer
// to zero, though neither one eliminates the class of errors above
// entirely for every provider and model version.4. Common Pitfalls
Pitfall #1: Fixing in the Wrong Order
Stripping comments before extracting the JSON block can accidentally eat part of a URL inside a string value ("https://example.com" contains //). Always extract the JSON block and quote-aware-strip comments before running comma/quoting fixes, and make sure your comment-stripping regex skips over string literals rather than blindly matching // anywhere in the text.
Pitfall #2: Retrying the Same Prompt Instead of Repairing
A common but expensive pattern: catch the parse error, then re-call the LLM with the same prompt and hope for a better roll. This doubles your latency and cost for a class of errors that's almost always mechanically fixable in milliseconds. Repair first; only retry the generation if repair genuinely fails.
Pitfall #3: Treating a Successful Repair as a Correctness Guarantee
A repaired JSON string that parses without error tells you the syntax is now valid -- it says nothing about whether the values are the ones you actually wanted. Syntax repair and semantic validation (does this data make sense? are required fields present?) are two separate steps, and skipping the second one is how a bad number sails straight into your database looking perfectly innocent.
Pitfall #4: Assuming Truncation Always Means "Just Close the Brackets"
Stack-based bracket completion works when the model got cut off between elements. It does not recover a truncated string value mid-word, or a field that was cut off before its value was written at all -- those cases need to be detected and either flagged or dropped, not silently "completed" with a guess at what the rest of the string might have been.
5. The Fix Most Tools Don't Mention: What Happens to Missing Fields
Here's a detail worth knowing before you pick a repair tool: several popular JSON repair libraries, when they encounter a field that's referenced but missing a value, will automatically fill it in with a "reasonable default" -- an empty string, a zero, a placeholder -- so the output parses cleanly. That's a completely reasonable design choice for some use cases (you genuinely just need something parseable). But it means the tool isn't just fixing syntax anymore -- it's making a decision about what your data should contain, silently, and that decision looks identical in the output whether it's a smart guess or a wrong one.
If a required field is genuinely absent from a model's raw output, a repair layer has exactly two honest options: leave it absent and let your own validation catch it, or refuse the repair and tell you why. Inventing a plausible-looking value is the one option that turns a visible problem (a missing field, easy to catch) into an invisible one (a fabricated field, indistinguishable from real data until something downstream breaks).
6. Practical Checklist
Step 1: Classify the Failure
- โ Does it fail to parse at all? โ syntax repair
- โ Does it parse but a required field is missing? โ semantic problem, do not auto-fill
- โ Does it parse but a value looks wrong? โ semantic problem, needs validation against a schema, not a repair tool
Step 2: Try the Cheap Fixes First
- โ Strip markdown fences and leading/trailing prose
- โ Normalize smart quotes, dashes, non-breaking spaces
- โ Re-parse after each stage -- stop as soon as it succeeds
Step 3: Handle Structural Damage
- โ Trailing commas, unquoted keys, single-quoted strings
- โ JavaScript-only literals (
NaN,Infinity, comments) - โ Truncated structures -- close open brackets, but check whether a string value itself got cut off first
Step 4: Validate After Repairing
- โ Run the repaired JSON through a schema check, not just a parser
- โ Confirm every required field is present and typed correctly
- โ Reject rather than silently accept if a required field is still missing after repair
Step 5: Reduce Future Failures
- โ Tighten the system prompt (no fences, no prose, no trailing commas)
- โ Use your provider's structured-output mode where available
- โ Log which failure patterns are actually occurring in production -- the mix varies by model and provider
7. Diagnostic Tool
function diagnoseJsonFailure(raw) {
const checks = [
{ name: 'Markdown fence', test: /```/.test(raw) },
{ name: 'Leading prose before { or [', test: /^[^{\[]*\S[^{\[]*[{\[]/.test(raw.trim()) },
{ name: 'Trailing comma', test: /,\s*[}\]]/.test(raw) },
{ name: 'Single-quoted string', test: /(? console.log(c.test ? 'โ ๏ธ' : 'โ
', c.name));
try {
JSON.parse(raw);
console.log('โ
Parses as-is -- no repair needed');
} catch (e) {
console.log('โ Fails to parse:', e.message);
}
}
diagnoseJsonFailure('```json\n{name: \'Alice\', tags: ["a",],}\n```');Final Thoughts
Almost every JSON error an LLM produces falls into one of eleven predictable patterns, and almost all of them are mechanically, safely fixable -- they're recovering intent the model clearly had, just expressed in a form the JSON spec doesn't allow. The one thing that isn't safe to auto-fix is a field that's genuinely missing. Filling that in with a guess isn't a repair -- it's the tool quietly making up part of your data and handing it back to you looking exactly as trustworthy as the rest.
The rule of thumb worth keeping: fix the syntax freely. Never let a repair step invent a value for something the model never actually produced.
๐ A JSON repair pipeline that draws this line explicitly
JSON Clean runs an 11-stage rule-based repair covering every syntax pattern in this article -- markdown fences, trailing commas, smart quotes, truncated structures, and more -- for free, entirely in your browser. When rule-based repair genuinely isn't enough (severely malformed structure, or values that need semantic remapping against a schema), AI JSON Repair steps in with a language model -- but with a hard guardrail: if a schema-required field is present in the output but absent from your original input, the repair is rejected outright rather than shipped with an invented value. Need clean-then-validate in one call? the AI-repair-plus-validate workflow runs both steps as a single request.