シナリオ
深夜2時。cronジョブが突然停止しました。ログにはこう表示されています:
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
char 0は、Pythonが最初の文字すら読み取れなかったことを意味します。解析しようとした文字列は、完全に空だったか、まったく予期しないもの——HTMLエラーページ、改行のみ、BOM文字、信頼していたAPIからの500レスポンスなど——だった可能性があります。
何が起きているのか
json.loads()とjson.load()はどちらも、有効なJSONではないものを見た瞬間にJSONDecodeErrorをスローします。line 1 column 1 (char 0)というパターンは、入力が以下のいずれかであることを特定的に意味します:
- 空文字列(
""またはb"") - 空白のみ(
" ") - HTMLページ——APIからの404または503レスポンス
- サーバーからのプレーンテキストエラーメッセージ
- 書き込み途中で切り捨てられたファイル、またはまったく書き込まれなかったファイル
- 先頭にUTF-8 BOM(
\xef\xbb\xbf)が付いたレスポンス
修正方法は、どれに該当するかによって異なります。まず、解析しようとしている内容を正確に出力して確認しましょう。
修正前に原因を診断する
json.loads()呼び出しの直前にこの一行を追加してください:
print(repr(response_text)) # または repr(raw_string)
json.loads(response_text)
repr()はあらゆる誤魔化しを取り除きます。文字列が空かどうか、<!DOCTYPEで始まっているかどうか、位置ゼロにBOMが隠れているかどうかがすぐにわかります。
原因別の対処法
1. APIレスポンスからの空文字列
これは本番環境で最もよく見られるトリガーです。APIが204 No Contentを返した、タイムアウトした、またはJSONの代わりに5xxエラーページを返した場合に発生します。
import requests
import json
response = requests.get("https://api.example.com/data")
# 悪い例 — ボディが空のときにクラッシュする
data = json.loads(response.text)
# 良い例 — 解析前に確認する
if response.status_code == 200 and response.text.strip():
data = response.json() # requestsがデコードを処理する
else:
print(f"Unexpected response: {response.status_code} — {repr(response.text[:200])}")
可能であればrequestsライブラリのresponse.json()を使うことをお勧めします。標準ライブラリの素のバージョンよりも多くのコンテキストとともにrequests.exceptions.JSONDecodeErrorをスローします。
2. 空またはサイズが切り捨てられたファイルの読み取り
import json
import os
path = "data.json"
if not os.path.exists(path) or os.path.getsize(path) == 0:
raise FileNotFoundError(f"JSON file missing or empty: {path}")
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
別のプロセスからファイルへの書き込みと読み取りを行っていますか?書き込み側がまだフラッシュしていない可能性があります。データが確実にディスクに書き込まれるよう、書き込み後にf.flush()に続けてos.fsync(f.fileno())を呼び出してください。
3. ファイル先頭のBOM
WindowsツールやExcelで保存されたファイルには、UTF-8 BOM(\xef\xbb\xbf)がこっそり含まれていることがよくあります。PythonのjsonモジュールはこれをJSON以外の文字として扱い、即座に処理を中断します。
# 修正: utf-8-sig はBOMを解析前に自動で除去する
with open("data.json", "r", encoding="utf-8-sig") as f:
data = json.load(f)
4. APIがJSONの代わりにHTMLを返す
レート制限、期限切れの認証トークン、誤って設定されたプロキシなどは、HTMLエラーページを返すことがよくあります。Content-Typeヘッダーを確認しましょう——これは嘘をつきません:
import requests
response = requests.get(url, headers={"Accept": "application/json"})
content_type = response.headers.get("Content-Type", "")
if "application/json" not in content_type:
raise ValueError(f"Expected JSON, got {content_type}: {response.text[:300]}")
data = response.json()
5. 空の環境変数
環境変数は常に文字列です。未設定または空白の場合、空文字列がそのままjson.loads()に渡されます:
import json
import os
raw = os.environ.get("MY_CONFIG", "")
if not raw:
raise EnvironmentError("MY_CONFIG environment variable is not set")
config = json.loads(raw)
使い回せる便利なヘルパー関数
根本原因を特定したら、JSON解析をヘルパー関数でラップしましょう。深夜2時の不可解なトレースバックは辛いものです——この関数があれば、対処可能なエラーメッセージが得られます:
import json
from typing import Any
def safe_parse_json(raw: str, source: str = "unknown") -> Any:
"""Parse JSON with a useful error message on failure."""
if not isinstance(raw, str):
raw = raw.decode("utf-8", errors="replace") # handle bytes
stripped = raw.strip()
if not stripped:
raise ValueError(f"Empty JSON input from {source}")
try:
return json.loads(stripped)
except json.JSONDecodeError as e:
preview = repr(stripped[:200])
raise ValueError(f"Invalid JSON from {source}: {e} — got: {preview}") from e
次のように呼び出します:
data = safe_parse_json(response.text, source=f"GET {url}")
次回深夜2時にこれが発生しても、ログには不正な入力がどこから来たのか、最初の200文字がどのようなものだったかが正確に表示されます。
修正の確認
デプロイ前にこのクイックチェックを実行してください:
import json
test_cases = [
("", "empty string"),
(" ", "whitespace only"),
('{"key": "value"}', "valid JSON"),
("null", "JSON null"),
]
for raw, label in test_cases:
try:
result = json.loads(raw) if raw.strip() else None
print(f"{label}: OK → {result}")
except json.JSONDecodeError as e:
print(f"{label}: FAIL → {e}")
期待される出力:
empty string: OK → None
whitespace only: OK → None
valid JSON: OK → {'key': 'value'}
JSON null: OK → None
予防のためのヒント
3,000文字のレスポンスを眺めてどこで構文が壊れているかわからない場合は、ToolCraftのJSON Formatter & Validatorに貼り付けてみてください。どのトークンが無効かを正確にハイライトしてくれます——データはアップロードされず、すべてブラウザ上で動作します。
長期的な対策として:
- ボディを触る前に、必ずAPIレスポンスのステータスコードを確認する。
- JSON解析が失敗したときは、生のレスポンスをログに記録する——後のデバッグで必ず役に立ちます。
- APIを自分で管理している場合は、エラーレスポンスも含めて一貫して
Content-Type: application/jsonを返す。 - ファイルベースのパイプラインでは、アトミックに書き込む:まず
.tmpファイルに出力し、その後os.rename()で所定の場所に移動する。これにより部分的な読み取りを完全に防止できます。

