How to Fix JSONDecodeError: Expecting Value in Python

Published:

15 minute read

Acar Diveroli
Written by: Acar Diveroli
A slim r.json() gate: red cards with an empty body, <!DOCTYPE html> and a 407 page on the left; a {"ok": true} card passes

You collect a shop's product list from /api/products?page=1, an address you found in the browser's Network panel. The script runs through the first 300 pages, then stops at data = r.json() with requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0). Same address, same code. You add one line, print(r.status_code, r.headers.get("Content-Type"), r.text[:200]), and the picture changes: 403, text/html and a page that starts with <!DOCTYPE html>. The server sent a web page instead of JSON, and the parser gave up at the first character, <.

This guide covers what the message and its position mean, which exception each Python library raises, and a three-line check that finds the cause. It then goes through empty responses, HTML pages, a proxy's 407 answer and bodies that only look like JSON, and ends with a tested parse_json() helper.

What does JSONDecodeError: Expecting value mean?

It means the parser expected the start of a JSON value and found something else. A JSON text is a single value, as RFC 8259 defines it: an object, an array, a string in double quotes, a number, true, false or null. A valid text can therefore only start with {, [, ", a digit, -, t, f or n, after optional whitespace. When the parser meets < from an HTML page, the T of Too Many Requests or the end of an empty string, it stops with "Expecting value".

This is not a connection error. A request that never reached the server fails earlier, inside requests.get(), with errors such as ConnectionError or ProxyError (Max Retries Exceeded With URL). When you see JSONDecodeError, a response has arrived; only its content could not be read as JSON.

What does "line 1 column 1 (char 0)" tell you?

The numbers point to where parsing failed. json.JSONDecodeError carries them as attributes: msg (the reason), doc (the whole text), pos (the index of the failing character), lineno and colno (Python json documentation). char 0 is the first character of the body, so no JSON started at all.

Other positions tell you more:

  • line 1 column 4 (char 3): the body was three spaces and nothing else. Whitespace is skipped, then the text ends.
  • line 2 column 1 (char 1): the body starts with a line break, followed by something that is not JSON, often an HTML page.
  • A position deep inside the text: JSON did start, but broke later, for example in a cut-off download.

Inside an except block, e.doc[:200] shows the start of that text.

Which exception do requests, json, httpx and aiohttp raise?

We checked each library with Python 3.13, Requests 2.34.2, HTTPX 0.28.1 and AIOHTTP 3.14.3:

  • json: json.loads() raises json.JSONDecodeError, a subclass of ValueError.
  • Requests: since version 2.27.0 (January 2022), r.json() raises requests.exceptions.JSONDecodeError. Per the Requests changelog, it inherits from the exceptions raised before and is also a RequestException.
  • Requests with simplejson installed: the parent becomes simplejson.errors.JSONDecodeError. In our test, except json.JSONDecodeError then missed it; except ValueError still caught it.
  • HTTPX: Response.json() raises the standard json.decoder.JSONDecodeError.
  • AIOHTTP: await resp.json() checks the Content-Type first and raises ContentTypeError (Attempt to decode JSON with unexpected mimetype: text/html) without parsing. With content_type=None it raises json.JSONDecodeError.

With Requests, catch requests.exceptions.JSONDecodeError: it works in both cases. Other client differences are in HTTPX vs Requests vs AIOHTTP.

How do you find the cause in three lines?

Print what arrived before you parse it:

python
print(r.status_code, r.history, r.url)
print(r.headers.get("Content-Type"), len(r.content))
print(r.text[:200])

Then read the output in this order:

  1. Status and history. Is the status 2xx? Did a 301 or 302 happen on the way? After a redirect, r.status_code shows the final 200, and only r.history shows [<Response [302]>].
  2. Final URL. r.url is the address after redirects. If it ends in /login or /consent, you did not reach the API.
  3. Content-Type. You want application/json or a type ending in +json. Anything else points to a cause below.
  4. Length and first characters. 0 means empty, < means HTML, {' a Python dict printed as text, cb( JSONP.
  5. Match the result to the table below.

Log only the first 200 characters: a full body can contain tokens or personal data.

What do the first characters of the body tell you?

Every message below comes from our run with Python 3.13.9 and Requests 2.34.2 against a local test server; other Python versions can word them differently.

Start of the bodyTypical status and Content-TypeMessage from r.json()Likely causeWhat to do
(nothing)204, 304, HEAD, or an empty 200Expecting value: line 1 column 1 (char 0)The endpoint returns no contentCheck status and len(r.content) before parsing
<!DOCTYPE html>403, 429, 503, or 200 after a 302; text/htmlExpecting value: line 1 column 1 (char 0)Block page, login redirect, error pageFix the status first
<html>...407... or nothing407 and Proxy-Authenticate, http:// targetExpecting value: line 1 column 1 (char 0)Wrong proxy credentials or IPCheck user:pass, IP whitelist
Too Many Requests429, text/plainExpecting value: line 1 column 1 (char 0)A plain-text rate limitSlow down; read Retry-After
cb({"items": ...});200, application/javascriptExpecting value: line 1 column 1 (char 0)JSONPUse the endpoint without a callback
{"id":1} then a new line and {"id":2}200, application/x-ndjsonExtra data: line 2 column 1 (char 9)NDJSONParse line by line
{'id': 1, ...}200, often text/plainExpecting property name enclosed in double quotes: line 1 column 2 (char 1)A dict written with str()json.dumps() in the producer
An invisible BOM, then {200, application/jsonUnexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)A byte order markDecode with utf-8-sig

The first five rows share one message, so the message alone never names the cause; the status and Content-Type do.

Empty responses: 204, HEAD and 304

Some answers have no body by definition. RFC 9110 states that a 204 No Content response cannot contain content, a 304 Not Modified has none either, and the response to a HEAD request carries headers only. Many APIs answer DELETE and PUT with 204: the action succeeded and there is nothing to parse, yet r.json() raises Expecting value.

Treat these answers as "no data", not as errors, and check r.status_code before parsing. An empty 200 is different: it usually means a server fault or a wrong endpoint, and deserves a log line.

HTML instead of JSON: block, login and error pages

Three kinds of HTML page arrive where JSON was expected, and the status code tells them apart.

A block or verification page. A 403, 429 or 503 with text/html is often a bot protection answer. Its <title>, such as "Just a moment..." or "Access denied", is enough to recognise it. Do not parse it or retry it in a loop. What each code means is in HTTP Status Codes in Web Scraping, Cloudflare pages in Cloudflare Scraper, and bot scoring in How Bot Detection Works. The legitimate routes are an official API, a lower rate that follows robots.txt, or the owner's permission.

A login page after a redirect. The status is 200, so this case is easy to miss. r.history shows [<Response [302]>] and r.url ends in /login: your session expired. For your own account, the fix is session handling (Sessions and Cookies in Python).

A server error page. A 500, 502 or 504 with HTML comes from the server or a gateway in front of it: the API is in trouble, not your parser.

HTML also arrives when the URL points to the page, not the API. The JSON comes from a separate request the page makes, which you find in the Network panel (finding the API request first).

Through a proxy: the 407 answer

Send a plain http:// request through a proxy with a wrong password, and the proxy answers 407 Proxy Authentication Required itself. Requests hands that answer to your code as a normal response, with whatever body the proxy sends: an HTML page, a short text or nothing. With two local test proxies, one sending HTML and one an empty body, r.json() gave Expecting value both times.

For an https:// target, the 407 arrives while the tunnel is set up, so requests.get() raises ProxyError: Tunnel connection failed: 407 and r.json() is never reached (Max Retries Exceeded With URL).

A 407 with a Proxy-Authenticate header points to the proxy, not the target. Check the username and password, encode special characters in the proxy URL (@ becomes %40), or confirm your IP is on the whitelist (Proxy Authentication: User:Pass vs IP Whitelist).

Bodies that look like JSON but are not: JSONP, NDJSON and single quotes

Some bodies contain JSON, but not as one clean value.

JSONP

JSONP wraps the JSON in a function call, cb({"items": [1]});, usually sent as application/javascript. The parser sees c and reports Expecting value at char 0. Use the endpoint without its callback parameter, or take the text between the first ( and the last ).

NDJSON and JSON Lines

Export and streaming endpoints often send one JSON value per line, a format described at jsonlines.org. The first line parses, then the parser finds more text and stops with Extra data: line 2 column 1. Read such bodies line by line with r.iter_lines().

Single quotes and Python values

A body like {'id': 1} was written with Python's str() instead of json.dumps(), and the parser stops at char 1 with Expecting property name enclosed in double quotes. A body of None or True, Python's spelling, fails with Expecting value, because JSON writes null and true. Fix the code that writes the data: text.replace("'", '"') breaks any value with an apostrophe, and ast.literal_eval() is only safe for data you produced yourself.

A byte order mark at the start gives Unexpected UTF-8 BOM; the encoding side is in Python Unicode encoding errors.

Full example: parse_json() that checks the body before reading JSON

The helper puts the three-line check into code. It returns the parsed data, None for answers that have no body by definition, or raises one NotJSON error naming the status, redirects, Content-Type, final URL and start of the body. Install Requests with pip install requests.

python
"""Read a JSON response, or say in one line why the body is not JSON."""
import json

import requests

PROXY = "http://user:pass@pr.proxynet.io:8000"
PROXIES = {"http": PROXY, "https": PROXY}


class NotJSON(ValueError):
    """The server answered, but not with the JSON we asked for."""


def describe(r):
    """Status, redirects, Content-Type, final URL and the first 200 characters."""
    hops = "".join(f"{h.status_code} -> " for h in r.history)
    ctype = r.headers.get("Content-Type", "none")
    start = r.text[:200].replace("\n", " ")
    return f"HTTP {hops}{r.status_code}, {ctype}, {r.url}, body {start!r}"


def media_type(r):
    return r.headers.get("Content-Type", "").split(";")[0].strip().lower()


def is_json_type(mtype):
    return mtype == "application/json" or mtype.endswith("+json")


def parse_json(r):
    """Return the parsed body, None for "no content", or raise NotJSON with the reason."""
    if r.status_code in (204, 304) or r.request.method == "HEAD":
        return None  # these answers carry no body by definition
    mtype = media_type(r)
    if not r.ok and not is_json_type(mtype):
        raise NotJSON(f"error response, not JSON: {describe(r)}")
    if not r.content:
        raise NotJSON(f"empty body: {describe(r)}")
    if mtype == "application/x-ndjson":
        return [json.loads(line) for line in r.iter_lines() if line.strip()]
    if r.text.lstrip().startswith("<"):
        raise NotJSON(f"HTML instead of JSON: {describe(r)}")
    try:
        return r.json()
    except requests.exceptions.JSONDecodeError as e:
        raise NotJSON(f"{e.msg} at char {e.pos}: {describe(r)}") from e


if __name__ == "__main__":
    url = "https://example.com/api/products?page=1"
    r = requests.get(url, proxies=PROXIES, timeout=(5, 30))
    try:
        data = parse_json(r)
    except NotJSON as e:
        print("stop:", e)
    else:
        if not r.ok:
            print("API error:", r.status_code, data)
        elif data is None:
            print("no content")
        else:
            print("ok:", type(data).__name__, len(data))

An error status with a non-JSON body stops first, so a 403 page or a proxy's 407 is never parsed. An error status with a JSON body, such as 400 with {"error": ...}, is returned, because many APIs explain errors that way; the caller checks r.ok. PROXIES is optional, and timeout=(5, 30) gives the connection 5 seconds and the answer 30.

The helper does not retry, rotate IPs or run in parallel on purpose. Which statuses deserve a retry is in HTTP Status Codes in Web Scraping, rotation in How to Rotate Proxies in Python, and parallel requests in Concurrency vs Parallelism.

What the output looks like

We ran parse_json() against a local test server that answers each path with one body from the table; the last line went through a test proxy that answered 407 with HTML.

text
/api/products -> {'items': [1, 2, 3]}
/api/items/7 -> None
/api/empty -> NotJSON: empty body: HTTP 200, application/json, http://127.0.0.1:8111/api/empty, body ''
/api/blocked -> NotJSON: error response, not JSON: HTTP 403, text/html; charset=utf-8, http://127.0.0.1:8111/api/blocked, body '<!DOCTYPE html><html><head><title>Just a moment...</title></head></html>'
/api/private -> NotJSON: HTML instead of JSON: HTTP 302 -> 200, text/html; charset=utf-8, http://127.0.0.1:8111/login, body '<!DOCTYPE html> <html><head><title>Sign in</title></head></html>'
/api/slow -> NotJSON: error response, not JSON: HTTP 429, text/plain, http://127.0.0.1:8111/api/slow, body 'Too Many Requests'
/api/jsonp -> NotJSON: Expecting value at char 0: HTTP 200, application/javascript, http://127.0.0.1:8111/api/jsonp, body 'cb({"items": [1]});'
/api/export -> [{'id': 1}, {'id': 2}]
/api/dict -> NotJSON: Expecting property name enclosed in double quotes at char 1: HTTP 200, text/plain, http://127.0.0.1:8111/api/dict, body "{'id': 1, 'name': 'Lamp'}"
/api/bad-request -> {'error': 'page must be a number'} (status 400)
proxy, wrong password -> NotJSON: error response, not JSON: HTTP 407, text/html, http://example.com/api/products, body '<html><head><title>407 Proxy Authentication Required</title></head><body><h1>407</h1></body></html>'

/api/items/7 answered 204 and /api/export sent NDJSON, so neither is an error. The /api/private line shows the redirect a status check misses: 302 -> 200, ending at /login.

Use cases: which scripts that expect JSON hit this error?

  • Calling a site's own API: the request you copied from the Network panel stops working when the session or token behind it expires (static vs dynamic pages).
  • Price tracking: a daily job that reads product JSON gets a block page on the day it runs too fast (competitor price tracking).
  • API pagination: the page after the last one can return 204 or an empty body instead of an empty list (pagination in web scraping).
  • Automation tools: an n8n HTTP Request node expects JSON and receives an HTML error page (n8n proxy setup).
  • Data pipelines: one HTML answer among thousands of JSON answers should stop a batch, not end up in the database (data scraping).
  • Crawlers: a crawler that reads JSON endpoints across many hosts needs one clear message per failed host (web crawler).

Common mistakes

  • Swallowing the error. except JSONDecodeError: pass stores nothing and hides the cause.
  • Trusting status 200. A login page after a 302 also arrives as 200. Check r.history and r.url.
  • Turning single quotes into double quotes with replace(). It breaks every value that contains an apostrophe.
  • Using eval() on a response body. It runs whatever code the server sent.
  • Retrying a block page at the same speed. The same requests at the same rate get the same 429 or 403; lower the rate first (429 Too Many Requests).
  • Logging the whole body. The first 200 characters name the cause; the rest may hold tokens and personal data.
  • One except RequestException around both get() and json(). Since 2.27.0 it catches both, so a network error and a parse error look the same.
  • Looking for the bug in the json module. The parser is right; the body is not JSON.

Decision guide

What you seeWhat to do
Status 204, or the body is emptyDo not call r.json(); treat it as "no data"
403, 429 or 503 with text/htmlStop parsing and fix the status (HTTP Status Codes in Web Scraping)
200, but r.history shows a 302 to a login pageRenew your session (Sessions and Cookies in Python)
407 with an HTML or empty bodyCheck proxy credentials and the IP whitelist
ProxyError: Tunnel connection failed: 407Same cause for https:// targets (Max Retries Exceeded With URL)
Extra dataParse line by line with r.iter_lines()
The body starts with callback(Use the endpoint without a callback, or strip the wrapper
Unexpected UTF-8 BOMDecode with utf-8-sig (Python Unicode encoding errors)

Frequently asked questions

Why does r.json() fail when the status code is 200?

A 200 says nothing about the format of the body. A login page after a redirect, a JSONP answer or an empty body can all come with 200. Check the Content-Type, r.history and the start of r.text.

What is the difference between requests.exceptions.JSONDecodeError and json.JSONDecodeError?

r.json() has raised requests.exceptions.JSONDecodeError since Requests 2.27.0. It is a subclass of the JSON library's JSONDecodeError and of Requests' own RequestException, so both catch it. The exception is simplejson: when it is installed, except json.JSONDecodeError misses the error, so catch the Requests class.

Why do I get JSONDecodeError: Extra data?

The parser read one complete JSON value, then found more text: usually NDJSON, or two objects written one after the other. Parse line by line, or use json.JSONDecoder().raw_decode() to read one value at a time.

What does "Expecting property name enclosed in double quotes" mean?

A key inside an object is not in double quotes. The usual cause is a Python dict written with str(), which uses single quotes. In Python 3.12 and older, a trailing comma before } also gives this message; Python 3.13 reports it as Illegal trailing comma before end of object.

I get this error in yfinance or spotdl. What should I do?

The library asked a remote service for JSON and received something else. Update the library, call it less often, and check the project's issue tracker for the same message.

What does the same error look like in JavaScript?

In Node.js 24, JSON.parse() on an HTML page throws SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON, and on an empty string SyntaxError: Unexpected end of JSON input. Check the status and Content-Type before response.json() there too (cURL in JavaScript). The WordPress editor notice "The response is not a valid JSON response" is a different problem.

Summary

JSONDecodeError: Expecting value is a symptom, not the cause. The connection worked and the server answered, but the body was empty or was not JSON. Three checks find the cause: the status code with r.history, the Content-Type, and the first 200 characters of the body. An empty 204 is normal, an HTML page means a block, login or error page, a 407 points to the proxy, and Extra data or single quotes mean the body only looks like JSON. The proxy types you can put in front of such a job are listed on our proxy services page.

Ask ChatGPTAsk Claude