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.

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:
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:
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:
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.
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.
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:
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.
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.
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:
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
withblock 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".
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.
- Print
r.status_code,r.headers.get("content-type")andrepr(r.text[:300]). - Call
r.raise_for_status()beforer.json(). - Treat 204 and
not r.contentas "no data", not as an error. - Catch
requests.exceptions.JSONDecodeErrorforr.json()andjson.JSONDecodeErrorforjson.loads(). - Include the status, content type and a body snippet in whatever you log or raise.
- For
json.load()on a file, check the file size and look for blank lines or a prefix. - Use
raw_decode()when the JSON is followed by other text. - Send JSON with
json=payload, notdata=json.dumps(payload), unless you set the header yourself. - 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.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
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
Next note