Skip to content

Fix: Python json.decoder.JSONDecodeError: Expecting value

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

How to fix Python JSONDecodeError Expecting value caused by empty responses, HTML error pages, invalid JSON, BOM characters, and API errors.

The Error

You parse JSON in Python and get:

json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

Or variations:

json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
json.decoder.JSONDecodeError: Extra data: line 2 column 1 (char 100)
json.decoder.JSONDecodeError: Unterminated string starting at: line 5 column 12 (char 89)
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 3 column 15 (char 45)

Python’s json.loads() or json.load() cannot parse the input as valid JSON. The input is either empty, not JSON at all, or contains syntax errors.

Why This Happens

json.loads() expects a valid JSON string. When it encounters something that is not valid JSON, it raises JSONDecodeError with a message indicating where the parsing failed. The message format is consistent: a short reason, then line L column C (char N). The char N value is the byte offset from the start of the input, which is the most useful number for debugging because it lets you slice the input directly: print(repr(text[max(0,N-20):N+20])) shows exactly what tripped the parser.

Common causes:

  • Empty string or response. json.loads("") fails immediately with “Expecting value.”
  • HTML error page instead of JSON. The server returned an HTML 404 or 500 page.
  • Single quotes instead of double quotes. JSON requires double quotes: {"key": "value"}, not {'key': 'value'}.
  • Trailing commas. {"a": 1, "b": 2,} is invalid JSON.
  • Comments in JSON. JSON does not support comments (// or /* */).
  • BOM (Byte Order Mark). A UTF-8 BOM at the start of the file.
  • Multiple JSON objects. Multiple JSON objects concatenated without being in an array.
  • Unquoted keys. {key: "value"} is not valid JSON.

A useful mental model: the error at line 1 column 1 (char 0) almost always means “the input was not JSON at all” — empty body, HTML page, plain text, captcha. Errors at later positions almost always mean “the input was real JSON that hit a syntax mistake.” This split tells you whether to debug the source (Fix 1, 4, 5) or the content (Fix 2, 3). Knowing which side to look at first saves the most time.

In Production: Incident Lens

This error in production almost always means a contract drift with an upstream API. You parsed JSON because the upstream service has always returned JSON, but today it returned something else — an HTML login page (the auth token expired), a 503 error page from a CDN in front of the real service, a captcha page from a rate-limiter, or an HTML “maintenance mode” page injected by the platform team. The parser worked fine yesterday and works fine in staging because staging uses a mocked happy-path response.

Blast radius is per-endpoint of the consuming service. If only one downstream API drifted, only the features that depend on that API break. The user sees blank states, missing data, or “something went wrong” errors. The monitoring signal is the rate of JSONDecodeError exceptions per consumer, alongside the downstream service’s health metrics. A spike in JSONDecodeError on the client without a corresponding spike in HTTP errors is the smoking gun for “the upstream is returning 200 OK with the wrong content-type.” Always log the first 500 bytes of the offending response — that single log line resolves the incident in under a minute.

Recovery is either a fallback parser (catch the exception and serve cached or default data) or a schema validation gate (reject the response if it does not match the expected shape and route to a fallback). Postmortem preventives are contract tests against the upstream (a CI job that calls the real upstream daily and verifies the shape), explicit response schema validation in the client using pydantic or marshmallow, and content-type assertions before parsing. Checking Content-Type: application/json before calling .json() would have caught the HTML login page case immediately, so make that assertion a standard part of every HTTP client wrapper in your codebase.

Fix 1: Check the Response Before Parsing

The most common cause is parsing an empty or non-JSON response:

Broken:

import requests
import json

response = requests.get("https://api.example.com/data")
data = response.json()  # JSONDecodeError if response body is empty or not JSON!

Fixed — check status code and content first:

response = requests.get("https://api.example.com/data")

# Check if the request succeeded
if response.status_code != 200:
    print(f"Error: HTTP {response.status_code}")
    print(f"Response body: {response.text[:500]}")  # Print first 500 chars for debugging
    raise Exception(f"API returned {response.status_code}")

# Check content type
content_type = response.headers.get("Content-Type", "")
if "application/json" not in content_type:
    print(f"Expected JSON but got: {content_type}")
    print(f"Response: {response.text[:500]}")
    raise Exception(f"Non-JSON response: {content_type}")

# Check if body is not empty
if not response.text.strip():
    print("Empty response body")
    raise Exception("Empty response")

data = response.json()

Simplified with try-except:

try:
    data = response.json()
except json.JSONDecodeError as e:
    print(f"Failed to parse JSON: {e}")
    print(f"Response status: {response.status_code}")
    print(f"Response body: {response.text[:500]}")
    raise

Pro Tip: Always check response.status_code before calling .json(). A 500 error page is usually HTML, not JSON. Print response.text[:500] in your error handler to see what the server actually returned — it is almost always immediately obvious (HTML page, empty string, plain text error).

Fix 2: Fix the JSON Syntax

Common JSON syntax errors:

Single quotes (not valid JSON):

# Broken — single quotes
bad = "{'name': 'Alice', 'age': 30}"
json.loads(bad)  # JSONDecodeError!

# Fixed — double quotes
good = '{"name": "Alice", "age": 30}'
json.loads(good)

# If you have Python dict-like strings, use ast.literal_eval instead
import ast
data = ast.literal_eval("{'name': 'Alice', 'age': 30}")

Trailing commas:

# Broken — trailing comma after last element
bad = '{"a": 1, "b": 2,}'
json.loads(bad)  # JSONDecodeError!

# Fixed — remove trailing comma
good = '{"a": 1, "b": 2}'

Comments:

# Broken — JSON doesn't support comments
bad = '''
{
  // This is a comment
  "name": "Alice",
  /* This too */
  "age": 30
}
'''

# Fixed — remove comments before parsing
import re
cleaned = re.sub(r'//.*?$|/\*.*?\*/', '', bad, flags=re.MULTILINE | re.DOTALL)
json.loads(cleaned)

# Or use a library that supports JSON with comments
# pip install json5
import json5
data = json5.loads(bad)

Unquoted keys:

# Broken — keys must be quoted in JSON
bad = '{name: "Alice"}'

# Fixed
good = '{"name": "Alice"}'

Common Mistake: Assuming Python dict syntax and JSON are the same. They are not. JSON requires double quotes for strings, no trailing commas, no comments, no single quotes, and no Python literals like True/False/None (JSON uses true/false/null).

Fix 3: Fix BOM and Encoding Issues

A UTF-8 BOM (Byte Order Mark) at the start of a file causes “Expecting value”:

# The file starts with \xef\xbb\xbf (UTF-8 BOM)
with open("data.json", "r") as f:
    data = json.load(f)  # JSONDecodeError!

Fixed — use utf-8-sig encoding:

with open("data.json", "r", encoding="utf-8-sig") as f:
    data = json.load(f)  # utf-8-sig strips the BOM automatically

Fixed — strip BOM manually:

with open("data.json", "rb") as f:
    content = f.read()
    if content.startswith(b'\xef\xbb\xbf'):
        content = content[3:]  # Remove BOM
    data = json.loads(content.decode("utf-8"))

Check for encoding issues:

with open("data.json", "rb") as f:
    raw = f.read(10)
    print(raw)  # See the actual bytes
    # b'\xef\xbb\xbf{...' means BOM is present

Fix 4: Fix File Reading Issues

Reading a file incorrectly:

Broken — file path wrong or file is empty:

with open("config.json", "r") as f:
    data = json.load(f)  # JSONDecodeError if file is empty!

Fixed — check file contents:

import os
import json

filepath = "config.json"

if not os.path.exists(filepath):
    raise FileNotFoundError(f"{filepath} does not exist")

if os.path.getsize(filepath) == 0:
    raise ValueError(f"{filepath} is empty")

with open(filepath, "r", encoding="utf-8") as f:
    data = json.load(f)

Broken — reading the file twice (cursor at end):

with open("data.json", "r") as f:
    print(f.read())     # Read the entire file
    data = json.load(f)  # JSONDecodeError! File cursor is at the end

# Fixed — seek back to start
with open("data.json", "r") as f:
    print(f.read())
    f.seek(0)           # Reset cursor to beginning
    data = json.load(f)

Fix 5: Fix Multiple JSON Objects (JSONL / NDJSON)

A file with one JSON object per line is not valid JSON as a whole:

{"id": 1, "name": "Alice"}
{"id": 2, "name": "Bob"}
{"id": 3, "name": "Charlie"}

Broken:

with open("data.jsonl", "r") as f:
    data = json.load(f)  # JSONDecodeError: Extra data

Fixed — parse line by line:

records = []
with open("data.jsonl", "r") as f:
    for line in f:
        line = line.strip()
        if line:
            records.append(json.loads(line))

Fixed — use a list comprehension:

with open("data.jsonl", "r") as f:
    records = [json.loads(line) for line in f if line.strip()]

Fix 6: Fix API-Specific Issues

Empty responses on 204 No Content:

response = requests.delete(f"/api/items/{item_id}")

if response.status_code == 204:
    # 204 means success with no body — don't parse JSON
    return None

data = response.json()

Paginated APIs returning empty on last page:

def fetch_all_pages(base_url):
    all_items = []
    page = 1
    while True:
        response = requests.get(f"{base_url}?page={page}")
        if response.status_code != 200:
            break
        try:
            data = response.json()
        except json.JSONDecodeError:
            break
        if not data.get("items"):
            break
        all_items.extend(data["items"])
        page += 1
    return all_items

Rate-limited APIs returning HTML:

response = requests.get("https://api.example.com/data")
if response.status_code == 429:
    retry_after = int(response.headers.get("Retry-After", 60))
    time.sleep(retry_after)
    return fetch_data()  # Retry

Fix 7: Fix Python Literals vs JSON

Python literals and JSON look similar but are different:

# Python dict (not JSON!)
python_str = "{'key': True, 'value': None}"
json.loads(python_str)  # Fails! True/None are not valid JSON

# Convert Python literals to JSON
import ast
python_obj = ast.literal_eval(python_str)
json_str = json.dumps(python_obj)
# Now json_str is: '{"key": true, "value": null}'

Boolean and null differences:

PythonJSON
Truetrue
Falsefalse
Nonenull
'single'"double" only

Fix 8: Use a Robust JSON Parser

For JSON that is slightly malformed:

# pip install json5
import json5

# Handles comments, trailing commas, single quotes, unquoted keys
data = json5.loads("""
{
  // Configuration file
  name: 'My App',
  debug: true,
  ports: [8080, 8443,],  // trailing comma OK
}
""")

For very large JSON files, use streaming parsers:

# pip install ijson
import ijson

with open("huge.json", "rb") as f:
    for item in ijson.items(f, "items.item"):
        process(item)

Still Not Working?

Print the raw content to see what you are actually parsing:

print(repr(content))  # repr shows invisible characters like \r, \n, \xef
print(len(content))   # Check if it's empty
print(content[:100])  # Print first 100 characters

Check for compressed responses. Some APIs return gzip-compressed data:

response = requests.get(url, headers={"Accept-Encoding": "gzip"})
# requests automatically decompresses, but manual HTTP calls might not

Check for JSONP responses. Some older APIs wrap JSON in a callback function:

# JSONP: callback({"data": "value"})
text = response.text
if text.startswith("callback("):
    text = text[len("callback("):-1]  # Strip the wrapper
data = json.loads(text)

Check for double-encoded JSON. Some upstream services return a JSON string whose value is itself a JSON-encoded string — a known antipattern in poorly-built APIs. The outer parse succeeds and gives you a plain string, then you try to use it as a dict and it crashes. Inspect the parsed type:

data = response.json()
print(type(data))
# If it is <class 'str'>, the upstream returned a stringified JSON. Decode again:
if isinstance(data, str):
    data = json.loads(data)

This is common when an API stores JSON in a database column and forgets to deserialize it before returning. File a ticket against the upstream — the workaround is one line but the root cause is on their side.

Check for NaN, Infinity, and -Infinity. Standard JSON does not allow NaN, Infinity, or -Infinity, but Python’s json module accepts them by default when serializing and parsing. If you receive JSON from a Java, Go, or Rust service that strictly follows RFC 8259, those values are rejected. Conversely, a Python service may emit them and a non-Python consumer rejects the payload. Force strict mode to find the offender:

data = json.loads(response.text, parse_constant=lambda c: (_ for _ in ()).throw(ValueError(f"Invalid JSON literal: {c}")))

Check for a backwards-compatible response wrapper that changed. Some APIs wrap responses in {"data": ..., "meta": ...} and switch to a raw array under a version bump. Your code reaches into response.json()["data"] and gets a KeyError that surfaces as a different exception, but the upstream change is the real culprit. Pin to an explicit API version in the URL or Accept-Version header so an upstream rollout cannot silently rebreak your parser. For consumers that fail with KeyError on parsed data, see Fix: Python KeyError.

For Python type errors with None values, see Fix: Python TypeError: ‘NoneType’ object is not subscriptable. For file not found errors when loading JSON files, see Fix: Python FileNotFoundError. For requests connection errors that show up upstream of the parse step, see Fix: Python requests ConnectionError: Max retries exceeded.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles