JSONDecodeError Expecting value: line 1 column 1 (char 0) — A Fix Guide
You called a JSON API, and the console prints this:
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)"I got JSON back — so why does it blow up at the first character (char 0)?" This error is a five-minute fix. Bottom line: that message almost always means the parser did not actually receive JSON. Empty string, HTML error page, or an object that was already parsed. This is part 4 of the Python Development Guide series: diagnosis table first, then copy-paste code, straight through to a working fix.
5-second self-diagnosis table
Pin down your situation in under a minute. The key is printing the actual value right before you parse.
| Symptom | Likely cause | One-line check | Fix |
|---|---|---|---|
response.text is '' | Empty body (204, timeout, rate limit) | print(repr(r.text)) → '' | ① Empty-string guard |
Body starts with <!DOCTYPE html> | HTML / error page (404/500/login) | r.text[:50] contains <html | ② raise_for_status + content-type |
Leading \ufeff | Encoding with BOM | r.text[:1] == '\ufeff' | ③ Strip BOM |
| Already a dict, parsing again | Double encoding / calling loads twice | Check type(data) | ④ Double-parse trap |
Content-Type: text/html | Wrong content-type | r.headers['Content-Type'] | ② Check content-type |
The #1 diagnostic line:
print(response.status_code, repr(response.text[:200]))That one line tells you which of the five cases you're in. Usereprso empty strings, whitespace, and BOM actually show up.
Repro code and copy-paste fixes by cause
① Empty response — empty-string guard
This is the most common one. The server returns 204 No Content, or the body is empty because of a 429 (rate limit) or a timeout.
# ❌ Bad
import requests
r = requests.get("https://api.example.com/data")
data = r.json() # blows up at char 0 if the body is ''# ✅ Fixed
r = requests.get("https://api.example.com/data", timeout=10)
print(r.status_code, repr(r.text[:200])) # inspect first
if not r.text.strip(): # empty-string / whitespace guard
data = {} # safe default
else:
data = r.json()② HTML / error page / wrong content-type
A 404 returns a pretty error HTML page, or an expired session sends you a login page. The body starts with <!DOCTYPE html>, so the JSON parser dies on the first character <.
# ✅ Validate status code and content-type first
r = requests.get(url, timeout=10)
r.raise_for_status() # raises on 4xx/5xx
ctype = r.headers.get("Content-Type", "")
if "application/json" not in ctype:
raise ValueError(f"Not JSON: {ctype} / {r.text[:200]!r}")
data = r.json()③ BOM in the payload
Some public-sector and finance OpenAPIs send a UTF-8 BOM (\ufeff). Decode with utf-8-sig instead of the default json path.
# ✅ requests usually handles this, but if you're dealing with raw bytes
import json
raw = r.content # bytes
data = json.loads(raw.decode("utf-8-sig"))If the encoding itself is the issue (UnicodeDecodeError, UTF-8 decoding), see part 3 of this series. Here we only cover the BOM one-liner.
④ The double-parse trap
Parsing an already-parsed object from r.json() or json.loads() will blow up. Passing a dict to loads raises TypeError, but passing an already-parsed string again — or None — produces the same JSONDecodeError.
# ❌ Parsing twice
data = r.json() # already a dict
data = json.loads(data) # parse again → error
# ✅ Once is enough
data = r.json() # done. get in the habit of checking type(data)json.loads vs json.load
Mix these two up and you get the same error. Memorize this:
| Function | Input | Use |
|---|---|---|
json.loads(s) | str / bytes | Parse API response text |
json.load(f) | file object | Parse from an open file |
# ❌ Common mistake: passing a file path string to loads
json.loads("data.json") # parses the path as JSON → char 0 error
# ✅ Files go through load
with open("data.json", encoding="utf-8") as f:
data = json.load(f)Practical requests debugging procedure
When a 404 returns HTML or an auth redirect dumps you on a login page, trace it in this order:
print(r.status_code)— if it's not 200, the body is almost certainly an error page.print(r.url)— check whether a redirect bounced you to a login URL.print(r.headers.get("Content-Type"))—text/htmlmeans it is definitely not JSON.print(repr(r.text[:300]))— look for clues like<title>Login</title>.- If the token expired, refresh the header; if you hit a rate limit (429), back off and retry.
A note from production: This question spiked once people started wiring up LLMs, OpenAI, and Korean AI APIs. The three greatest hits: a 429 arriving as HTML instead of JSON, calling json() on an entire streaming response, and an expired-token redirect that returns login HTML. I always log status_code + text[:200] on every external API call — that one habit cut my debugging time in half.
Wrap-up: a safe try/except pattern
Last step: stop it from coming back. As of 2026, requests.exceptions.JSONDecodeError is the standard in requests 2.x (it subclasses the stdlib json.JSONDecodeError). Catching both is the safe play.
import logging
import requests
from json import JSONDecodeError
def safe_get_json(url, **kwargs):
r = requests.get(url, timeout=10, **kwargs)
try:
r.raise_for_status()
if not r.text.strip():
logging.warning("Empty response: %s", url)
return None
return r.json()
except (JSONDecodeError, requests.exceptions.JSONDecodeError):
# keep a snippet of the raw body so you can diagnose later
logging.error(
"JSON parse failed | status=%s ctype=%s body=%r",
r.status_code,
r.headers.get("Content-Type"),
r.text[:200],
)
return NoneFinal checklist
- Right before parsing,
print(status_code, repr(text[:200]))and inspect the real value - Filter 4xx/5xx with
raise_for_status() - Empty-string guard:
if not text.strip() - Confirm content-type is
application/json - Don't
loadsan already-parsed object -
try/exceptwith a fallback and raw-body logging
FAQ
Q. status_code is 200 — why am I still getting JSONDecodeError?
A. A 200 can still have an empty body or HTML. 200 does not mean JSON. Always inspect the actual body with repr(r.text[:200]).
Q. Should I catch requests.exceptions.JSONDecodeError or json.JSONDecodeError?
A. In requests 2.x the former subclasses the latter, so catching both is safe. If you only deal with requests responses, requests.exceptions.JSONDecodeError alone is enough.
Q. I get the same error when reading a file.
A. You probably passed a file path string to json.loads(). Open the file and use json.load(f).
Next up: debugging KeyError and TypeError: 'NoneType' object is not subscriptable after a successful parse.
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
Comments
Be the first to comment.