CoreMesh

JSONDecodeError Expecting Value: Why It Happens and the Fix

Python's JSONDecodeError: Expecting value usually means an empty or HTML response. Diagnose it, check status codes, and parse JSON safely with a helper.

JSONDecodeError Expecting Value: Why It Happens and the Fix

Python raises JSONDecodeError: Expecting value when json.loads() or response.json() gets text that doesn't start with a JSON value. In practice that text is almost always one of two things: an empty body, or an HTML page (an error page, a login redirect, a captcha). The decoder looked at the first character, expected {, [, a quote, a digit, true, false or null, and found nothing it could use.

The fix is to inspect the response before parsing it. Check the status code, check whether the body is empty, and only decode when there's something to decode. Below is how to do that with requests and the standard library, plus a small helper you can paste into a project so you stop hitting this.

What the error says and why it happens#

The full message from the standard library looks like this:

text
Traceback (most recent call last):
  File "fetch.py", line 6, in <module>
    data = json.loads(r.text)
  ...
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

With requests, the same failure surfaces as the library's own wrapper:

text
requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

Read it in two halves. "Expecting value" means the parser needed the start of a JSON value. "line 1 column 1 (char 0)" is where it gave up. Character 0 is the first byte of the input, so the body was empty or began with something that isn't JSON, most often a < from an HTML document. If the reported position is later than 0, the body started out as JSON and broke partway through, which points at truncation or a stray character rather than a missing body.

The Python json module documentation states that load() and loads() raise JSONDecodeError when the data "is not a valid JSON document". An empty string isn't a document. Neither is <!DOCTYPE html>.

You can reproduce every variant without touching the network:

python
import json

samples = {
    "empty string": "",
    "whitespace only": "   \n",
    "html page": "<!DOCTYPE html><html><body>502 Bad Gateway</body></html>",
    "truncated json": '{"ok": true, "items": [1, 2',
    "valid json": '{"ok": true}',
}

for label, text in samples.items():
    try:
        print(f"{label:16} -> {json.loads(text)!r}")
    except json.JSONDecodeError as exc:
        print(f"{label:16} -> JSONDecodeError: {exc}")

Find out what the server actually sent#

Before changing any logic, print three things: the status code, the content type, and the start of the body. Use repr() on the body so an empty string and a string of spaces don't look identical.

python
import requests

r = requests.get("https://api.github.com/events", timeout=10)
print(r.status_code)
print(r.headers.get("content-type"))
print(repr(r.text[:300]))

What you see tells you which fix applies:

Status Body Likely cause Fix
200 '' Endpoint returns nothing for this method or query Fix 2
204 '' No Content, by design Fix 2
401 or 403 HTML Login page, expired token, blocked client Fix 1
404 HTML Wrong URL or path Fix 1
500 to 504 HTML Server or gateway error page Fix 1, then retry
200 HTML Redirected to a landing page, captcha or captive portal Fix 1, and check r.url
any JSON that stops early Truncated download or trailing junk See the files section

r.headers is case-insensitive, so content-type and Content-Type both work.

Fix 1: check the status code before you parse#

The Requests quickstart makes a point worth repeating: a successful r.json() doesn't mean the request succeeded, and a failed request usually doesn't come with JSON. Check the status first.

python
import requests

r = requests.get("https://api.github.com/events", timeout=10)
r.raise_for_status()
data = r.json()

raise_for_status() raises requests.exceptions.HTTPError for 4XX and 5XX responses and returns None otherwise. That swaps a vague decode error for a direct "404 Client Error" that names the URL.

If you'd rather keep running on a bad status, compare r.status_code to what you expect and log the body:

python
if r.status_code != requests.codes.ok:
    print("unexpected status", r.status_code, repr(r.text[:200]))
else:
    data = r.json()

How to confirm it worked: the JSONDecodeError disappears and is replaced by an HTTPError or your own log line. Now you're debugging the real issue, which is authentication, the URL or the server, instead of the symptom.

Fix 2: handle empty bodies such as 204 No Content#

Some endpoints legitimately return nothing. DELETE calls often answer with 204 No Content, and the requests documentation says plainly that if the response gets a 204, r.json() raises requests.exceptions.JSONDecodeError. A status check won't help because 204 is a success.

python
import requests

r = requests.delete("https://httpbin.org/delete", timeout=10)
r.raise_for_status()
if r.status_code == 204 or not r.content:
    data = None
else:
    data = r.json()

r.content is the raw bytes of the body. An empty body is b'', which is falsy, so not r.content catches both the 204 case and the "200 with nothing in it" case. Checking content rather than text also skips the encoding guess that r.text performs.

How to confirm it worked: the DELETE no longer raises and data is None.

Fix 3: a safe parsing helper for responses#

After the second time you write the same three lines, wrap them. This helper enforces the status, tolerates empty bodies, and when decoding fails it tells you what the body actually was. That's the information you need and the information the original error hides.

python
import requests


class NotJSONError(Exception):
    """The server answered, but not with JSON."""


def parse_json_response(r, *, allow_empty=True):
    r.raise_for_status()
    if not r.content:
        if allow_empty:
            return None
        raise NotJSONError(f"{r.url} returned {r.status_code} with an empty body")
    try:
        return r.json()
    except requests.exceptions.JSONDecodeError as exc:
        ctype = r.headers.get("content-type", "unknown")
        raise NotJSONError(
            f"{r.url} returned {r.status_code} ({ctype}) that is not JSON: {exc}; "
            f"body starts {r.text[:200]!r}"
        ) from exc


r = requests.get("https://api.github.com/events", timeout=10)
events = parse_json_response(r)

A few deliberate choices. raise_for_status() runs first so a 500 with an HTML page is reported as an HTTP error, not a parse error. r.url is the URL after redirects, so a silent bounce to a login page shows up in the message. The body snippet is capped so a large HTML page doesn't flood your logs. And the original exception is chained with from exc, so the full traceback is still there if you need it.

For text that arrives from somewhere other than requests (a queue message, a subprocess, a cache), a dependency-free version that returns a default instead of raising is often more convenient:

python
import json


def loads_or_default(text, default=None):
    if text is None:
        return default
    if isinstance(text, (bytes, bytearray)):
        text = text.decode("utf-8", errors="replace")
    if not text.strip():
        return default
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return default


print(loads_or_default(""))
print(loads_or_default(None, default={}))
print(loads_or_default("<html>502 Bad Gateway</html>", default={}))
print(loads_or_default(b'{"from": "bytes"}'))
print(loads_or_default("  [1, 2, 3]  "))

Pick the raising version for code paths where "not JSON" is a bug you want to hear about. Pick the default-returning version for optional data where a missing value is normal. Don't use the second one to hide errors from the first kind of code path.

Files, strings and JSON Lines#

The same error comes up with json.load() on files, and the causes are the file equivalents of an empty response:

  • The file is zero bytes. Something opened it with mode "w", which truncates, and never wrote. Check the size before blaming the parser.
  • You read a file you just wrote without closing it, so nothing was flushed yet. Use a with block for the write and open the file again for the read.
  • A JSON Lines file has a blank line. Each line is its own document, and an empty line is "Expecting value" at char 0.
  • There's text before or after the document, such as a log prefix. A leading prefix gives "Expecting value"; trailing text gives "Extra data" with a non-zero position instead.

For the last case, JSONDecoder.raw_decode() parses one document from the start of a string and returns where it ended. The json docs describe it as the tool for a string "that may have extraneous data at the end".

python
import json

jsonl = '{"id": 1}\n\n{"id": 2}\n'
records = []
for lineno, line in enumerate(jsonl.splitlines(), start=1):
    if not line.strip():
        print(f"line {lineno}: blank, skipped")
        continue
    records.append(json.loads(line))
print("records:", records)

padded = 'LOG 12:00:01 {"status": "ok", "count": 3} -- end of entry'
start = padded.index("{")
value, end = json.JSONDecoder().raw_decode(padded[start:])
print("parsed:", value)
print("trailing text:", repr(padded[start + end:]))

Common mistakes and gotchas#

Catching the exception and moving on. A bare except Exception: pass around r.json() turns a loud failure into a None that crashes three functions later. If you must catch, log the status and a slice of the body.

Catching the wrong exception class. When you call r.json(), catch requests.exceptions.JSONDecodeError. When you call json.loads() yourself, catch json.JSONDecodeError. The requests docs describe their class as a wrapper that exists for interoperability across Python versions and JSON libraries, which is also why your traceback may show the standard library error first and the requests error after it.

Searching for "from None". Tracebacks that show one exception followed by another are exception chaining, and the "from" wording belongs to that. It isn't a hint that you passed None. If you actually pass None to json.loads(), you get a TypeError about the argument type rather than a JSONDecodeError, because loads() accepts only str, bytes or bytearray.

Sending JSON as a string without a content type. Posting with data=json.dumps(payload) does not set Content-Type: application/json, and some servers answer such a request with an HTML error page. Use json=payload and requests sets the header for you. Note that the json argument is ignored if data or files is also passed.

Treating the HTML page as noise. Read it. A 403 page from a gateway often names the reason: rate limit, missing header, blocked user agent. Setting a user-agent header through headers= is a common unblock for public APIs.

Disabling certificate verification to get past an error. A TLS failure raises an SSL error, not a decode error, so turning verification off can't fix this problem, and it does make your client trust anyone on the path. If the error you're actually seeing mentions certificates, the cause is different and covered in the CoreMesh post on SSL certificate verify failed in Python.

No timeout. Not a cause of this error, but every example above passes timeout= because the requests docs say nearly all production code should, and a hung request is harder to debug than a bad one.

Guessing the encoding. r.text is decoded with an encoding guessed from the headers. If the guess is wrong you'll see garbled characters or a UnicodeDecodeError. Pass r.content to json.loads() instead; it accepts UTF-8, UTF-16 and UTF-32 bytes directly.

If pip install requests itself refuses with an externally-managed-environment message on Debian or Ubuntu, use a virtual environment as described in the CoreMesh guide to the pip externally-managed-environment error.

Quick reference#

Work down this list in order. Most cases are resolved by the second item.

  1. Print r.status_code, r.headers.get("content-type") and repr(r.text[:300]).
  2. Call r.raise_for_status() before r.json().
  3. Treat 204 and not r.content as "no data", not as an error.
  4. Catch requests.exceptions.JSONDecodeError for r.json() and json.JSONDecodeError for json.loads().
  5. Include the status, content type and a body snippet in whatever you log or raise.
  6. For json.load() on a file, check the file size and look for blank lines or a prefix.
  7. Use raw_decode() when the JSON is followed by other text.
  8. Send JSON with json=payload, not data=json.dumps(payload), unless you set the header yourself.
  9. Always pass timeout=.

Quick answers

What does "Expecting value: line 1 column 1 (char 0)" mean?

The JSON parser needed the start of a value at the very first character and didn't find one. The input was empty, whitespace only, or started with non-JSON text such as an HTML page.

Why do I get JSONDecodeError from requests when the status code is 200?

A 200 can still carry an empty body or an HTML page, for example after a redirect to a login or landing page. Print repr(r.text[:300]) and r.headers.get("content-type") to see what came back, and check r.url for an unexpected redirect.

Does json.loads(None) raise JSONDecodeError?

No. Passing None raises a TypeError because loads() only accepts str, bytes or bytearray. The "from None" people see in tracebacks is exception chaining, not a hint about a None argument.

How do I catch JSONDecodeError in Python?

Catch requests.exceptions.JSONDecodeError around r.json() and json.JSONDecodeError around json.loads(). In the handler, log the status code and a slice of the body instead of silently continuing.

Why does r.json() fail on a DELETE request?

Many DELETE endpoints return 204 No Content, and the requests docs state that r.json() raises JSONDecodeError on a 204. Check r.status_code == 204 or not r.content before parsing and treat it as no data.

Did this solve it?

One tap. No account.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

On a computer? Scan the code with your phone wallet, or copy the address.

Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.

Discussion

Start the discussion

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS