Você coleta a lista de produtos de uma loja em /api/products?page=1, um endereço que encontrou no painel Network do navegador. O script percorre as primeiras 300 páginas e então para em data = r.json() com requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0). Mesmo endereço, mesmo código. Você acrescenta uma linha, print(r.status_code, r.headers.get("Content-Type"), r.text[:200]), e o quadro muda: 403, text/html e uma página que começa com <!DOCTYPE html>. O servidor mandou uma página web em vez de JSON, e o parser desistiu no primeiro caractere, <.
Este guia explica o que significam a mensagem e a posição que ela aponta, qual exceção cada biblioteca Python lança e uma verificação de três linhas que encontra a causa. Depois passa por respostas vazias, páginas HTML, a resposta 407 de um proxy e corpos que só parecem JSON, e termina com uma função auxiliar parse_json() testada.
O que significa JSONDecodeError: Expecting value?
Significa que o parser esperava o início de um valor JSON e encontrou outra coisa. Um texto JSON é um único valor, como define a RFC 8259: um objeto, um array, uma string entre aspas duplas, um número, true, false ou null. Por isso, um texto válido só pode começar com {, [, ", um dígito, -, t, f ou n, depois de espaços em branco opcionais. Quando o parser encontra o < de uma página HTML, o T de Too Many Requests ou o fim de uma string vazia, ele para com "Expecting value".
Isso não é um erro de conexão. Uma requisição que nunca chegou ao servidor falha antes, dentro de requests.get(), com erros como ConnectionError ou ProxyError (Max Retries Exceeded With URL). Quando você vê JSONDecodeError, uma resposta chegou; só o conteúdo dela não pôde ser lido como JSON.
O que "line 1 column 1 (char 0)" indica?
Os números mostram onde a leitura falhou. json.JSONDecodeError os guarda como atributos: msg (o motivo), doc (o texto inteiro), pos (o índice do caractere que falhou), lineno e colno (documentação do json no Python). char 0 é o primeiro caractere do corpo, então nenhum JSON chegou a começar.
Outras posições dizem mais:
line 1 column 4 (char 3): o corpo tinha três espaços e mais nada. Os espaços em branco são ignorados, e então o texto acaba.line 2 column 1 (char 1): o corpo começa com uma quebra de linha, seguida de algo que não é JSON, muitas vezes uma página HTML.- Uma posição bem dentro do texto: o JSON começou, mas quebrou depois, por exemplo em um download cortado.
Dentro de um bloco except, e.doc[:200] mostra o início desse texto.
Qual exceção requests, json, httpx e aiohttp lançam?
Verificamos cada biblioteca com Python 3.13, Requests 2.34.2, HTTPX 0.28.1 e AIOHTTP 3.14.3:
- json:
json.loads()lançajson.JSONDecodeError, uma subclasse deValueError. - Requests: desde a versão 2.27.0 (janeiro de 2022),
r.json()lançarequests.exceptions.JSONDecodeError. Segundo o changelog do Requests, ela herda das exceções lançadas antes e também é umaRequestException. - Requests com simplejson instalado: a classe-mãe passa a ser
simplejson.errors.JSONDecodeError. No nosso teste,except json.JSONDecodeErrorentão não a capturou;except ValueErrorainda capturou. - HTTPX:
Response.json()lança ojson.decoder.JSONDecodeErrorpadrão. - AIOHTTP:
await resp.json()confere o Content-Type primeiro e lançaContentTypeError(Attempt to decode JSON with unexpected mimetype: text/html) sem ler o corpo. Comcontent_type=None, lançajson.JSONDecodeError.
Com o Requests, capture requests.exceptions.JSONDecodeError: funciona nos dois casos. Outras diferenças entre os clientes estão em Comparativo entre HTTPX, Requests e AIOHTTP.
Como encontrar a causa em três linhas?
Imprima o que chegou antes de ler como JSON:
print(r.status_code, r.history, r.url)
print(r.headers.get("Content-Type"), len(r.content))
print(r.text[:200])Depois leia a saída nesta ordem:
- Status e histórico. O status é
2xx? Houve um301ou302no caminho? Depois de um redirecionamento,r.status_codemostra o200final, e sór.historymostra[<Response [302]>]. - URL final.
r.urlé o endereço depois dos redirecionamentos. Se terminar em/loginou/consent, você não chegou à API. - Content-Type. Você quer
application/jsonou um tipo que termine em+json. Qualquer outro aponta para uma das causas abaixo. - Tamanho e primeiros caracteres.
0significa vazio,<significa HTML,{'um dict do Python impresso como texto,cb(JSONP. - Compare o resultado com a tabela abaixo.
Registre no log só os primeiros 200 caracteres: um corpo inteiro pode conter tokens ou dados pessoais.
O que os primeiros caracteres do corpo revelam?
Todas as mensagens abaixo vêm da nossa execução com Python 3.13.9 e Requests 2.34.2 contra um servidor de teste local; outras versões do Python podem redigi-las de outro jeito.
| Início do corpo | Status e Content-Type típicos | Mensagem de r.json() | Causa provável | O que fazer |
|---|---|---|---|---|
| (nada) | 204, 304, HEAD ou um 200 vazio | Expecting value: line 1 column 1 (char 0) | O endpoint não devolve conteúdo | Confira o status e len(r.content) antes de ler |
<!DOCTYPE html> | 403, 429, 503, ou 200 depois de um 302; text/html | Expecting value: line 1 column 1 (char 0) | Página de bloqueio, redirecionamento para o login, página de erro | Resolva o status primeiro |
<html>...407... ou nada | 407 e Proxy-Authenticate, destino http:// | Expecting value: line 1 column 1 (char 0) | Credenciais ou IP do proxy errados | Confira user:pass e a whitelist de IP |
Too Many Requests | 429, text/plain | Expecting value: line 1 column 1 (char 0) | Um limite de taxa em texto puro | Diminua o ritmo; leia Retry-After |
cb({"items": ...}); | 200, application/javascript | Expecting value: line 1 column 1 (char 0) | JSONP | Use o endpoint sem callback |
{"id":1}, depois uma nova linha e {"id":2} | 200, application/x-ndjson | Extra data: line 2 column 1 (char 9) | NDJSON | Leia linha por linha |
{'id': 1, ...} | 200, muitas vezes text/plain | Expecting property name enclosed in double quotes: line 1 column 2 (char 1) | Um dict escrito com str() | json.dumps() no código que gera os dados |
Um BOM invisível, depois { | 200, application/json | Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0) | Uma marca de ordem de bytes | Decodifique com utf-8-sig |
As cinco primeiras linhas da tabela têm a mesma mensagem, então a mensagem sozinha nunca diz a causa; o status e o Content-Type dizem.
Respostas vazias: 204, HEAD e 304
Algumas respostas não têm corpo por definição. A RFC 9110 estabelece que uma resposta 204 No Content não pode ter conteúdo, que um 304 Not Modified também não tem, e que a resposta a uma requisição HEAD traz só cabeçalhos. Muitas APIs respondem a DELETE e PUT com 204: a ação deu certo e não há nada para ler, mas r.json() lança Expecting value.
Trate essas respostas como "sem dados", não como erros, e confira r.status_code antes de ler o corpo. Um 200 vazio é diferente: em geral indica uma falha do servidor ou um endpoint errado, e merece uma linha no log.
HTML em vez de JSON: páginas de bloqueio, de login e de erro
Três tipos de página HTML chegam onde se esperava JSON, e o código de status os diferencia.
Uma página de bloqueio ou de verificação. Um 403, 429 ou 503 com text/html costuma ser a resposta de uma proteção contra bots. O <title> dela, como "Just a moment..." ou "Access denied", basta para reconhecê-la. Não tente ler essa página como JSON nem repita a requisição em loop. O que cada código significa está em Códigos de status HTTP no web scraping, as páginas da Cloudflare em Cloudflare scraper e a pontuação de bots em Como funciona a detecção de bots. Os caminhos legítimos são uma API oficial, um ritmo menor que respeite o robots.txt ou a permissão do dono do site.
Uma página de login depois de um redirecionamento. O status é 200, então esse caso passa despercebido com facilidade. r.history mostra [<Response [302]>] e r.url termina em /login: a sua sessão expirou. Na sua própria conta, a correção é cuidar da sessão (Sessões e cookies em Python).
Uma página de erro do servidor. Um 500, 502 ou 504 com HTML vem do servidor ou de um gateway na frente dele: o problema está na API, não no seu parser.
HTML também chega quando a URL aponta para a página, e não para a API. O JSON vem de uma requisição separada que a página faz, e você a encontra no painel Network (primeiro encontre a requisição da API).
Por um proxy: a resposta 407
Envie uma requisição http:// simples por um proxy com a senha errada, e o próprio proxy responde 407 Proxy Authentication Required. O Requests entrega essa resposta ao seu código como uma resposta normal, com o corpo que o proxy mandar: uma página HTML, um texto curto ou nada. Com dois proxies de teste locais, um que mandava HTML e outro que mandava um corpo vazio, r.json() deu Expecting value nas duas vezes.
Para um destino https://, o 407 chega enquanto o túnel é montado, então requests.get() lança ProxyError: Tunnel connection failed: 407 e o código nunca chega a r.json() (Max Retries Exceeded With URL).
Um 407 com um cabeçalho Proxy-Authenticate aponta para o proxy, não para o destino. Confira o usuário e a senha, codifique os caracteres especiais na URL do proxy (@ vira %40) ou confirme que o seu IP está na whitelist (Autenticação de proxy: user:pass ou whitelist de IP).
Corpos que parecem JSON, mas não são: JSONP, NDJSON e aspas simples
Alguns corpos contêm JSON, mas não como um único valor limpo.
JSONP
O JSONP envolve o JSON em uma chamada de função, cb({"items": [1]});, em geral enviada como application/javascript. O parser vê o c e informa Expecting value em char 0. Use o endpoint sem o parâmetro callback ou pegue o texto entre o primeiro ( e o último ).
NDJSON e JSON Lines
Endpoints de exportação e de streaming muitas vezes mandam um valor JSON por linha, um formato descrito em jsonlines.org. A primeira linha é lida, depois o parser encontra mais texto e para com Extra data: line 2 column 1. Leia esses corpos linha por linha com r.iter_lines().
Aspas simples e valores do Python
Um corpo como {'id': 1} foi escrito com o str() do Python em vez de json.dumps(), e o parser para em char 1 com Expecting property name enclosed in double quotes. Um corpo None ou True, na grafia do Python, falha com Expecting value, porque o JSON escreve null e true. Corrija o código que escreve os dados: text.replace("'", '"') quebra qualquer valor com apóstrofo, e ast.literal_eval() só é seguro com dados que você mesmo produziu.
Uma marca de ordem de bytes (BOM) no início dá Unexpected UTF-8 BOM; o lado da codificação está em Erros de codificação no Python.
Exemplo completo: parse_json() que confere o corpo antes de ler o JSON
A função auxiliar transforma a verificação de três linhas em código. Ela devolve os dados lidos, None para respostas que não têm corpo por definição, ou lança um único erro NotJSON que informa o status, os redirecionamentos, o Content-Type, a URL final e o início do corpo. Instale o Requests com pip install requests.
"""Lê uma resposta JSON ou diz em uma linha por que o corpo não é JSON."""
import json
import requests
PROXY = "http://user:pass@pr.proxynet.io:8000"
PROXIES = {"http": PROXY, "https": PROXY}
class NotJSON(ValueError):
"""O servidor respondeu, mas não com o JSON que pedimos."""
def describe(r):
"""Status, redirecionamentos, Content-Type, URL final e os primeiros 200 caracteres."""
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):
"""Devolve o corpo lido, None para "sem conteúdo", ou lança NotJSON com o motivo."""
if r.status_code in (204, 304) or r.request.method == "HEAD":
return None # essas respostas não têm corpo por definição
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))Um status de erro com corpo que não é JSON para logo no início, então uma página 403 ou o 407 de um proxy nunca passa pelo parser. Um status de erro com corpo JSON, como 400 com {"error": ...}, é devolvido, porque muitas APIs explicam os erros assim; quem chama a função confere r.ok. PROXIES é opcional, e timeout=(5, 30) dá 5 segundos para a conexão e 30 para a resposta.
A função auxiliar não faz novas tentativas, não rotaciona IPs e não roda em paralelo, e isso é de propósito. Quais status merecem uma nova tentativa está em Códigos de status HTTP no web scraping, a rotação em Como rotacionar proxies em Python e as requisições paralelas em Concorrência e paralelismo no web scraping.
Como fica a saída
Rodamos parse_json() contra um servidor de teste local que responde a cada caminho com um corpo da tabela; a última linha passou por um proxy de teste que respondeu 407 com HTML.
/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 respondeu 204 e /api/export mandou NDJSON, então nenhum dos dois é erro. A linha de /api/private mostra o redirecionamento que uma verificação de status não pega: 302 -> 200, terminando em /login.
Casos de uso: quais scripts que esperam JSON encontram esse erro?
- Chamar a API do próprio site: a requisição que você copiou do painel Network para de funcionar quando a sessão ou o token por trás dela expira (páginas estáticas e dinâmicas).
- Monitoramento de preços: uma tarefa diária que lê o JSON dos produtos recebe uma página de bloqueio no dia em que roda rápido demais (monitoramento de preços da concorrência).
- Paginação de API: a página depois da última pode devolver
204ou um corpo vazio em vez de uma lista vazia (paginação no web scraping). - Ferramentas de automação: um nó HTTP Request do n8n espera JSON e recebe uma página de erro em HTML (proxy no n8n).
- Pipelines de dados: uma resposta HTML entre milhares de respostas JSON deve parar um lote, não acabar no banco de dados (extração de dados).
- Crawlers: um crawler que lê endpoints JSON em muitos hosts precisa de uma mensagem clara para cada host que falhou (web crawler).
Erros comuns
- Engolir o erro.
except JSONDecodeError: passnão guarda nada e esconde a causa. - Confiar no status 200. Uma página de login depois de um
302também chega como200. Confirar.historyer.url. - Trocar aspas simples por aspas duplas com
replace(). Isso quebra todo valor que contém um apóstrofo. - Usar
eval()no corpo de uma resposta. Ele executa qualquer código que o servidor tenha mandado. - Repetir uma página de bloqueio no mesmo ritmo. As mesmas requisições no mesmo ritmo recebem o mesmo
429ou403; diminua o ritmo primeiro (429 Too Many Requests). - Registrar o corpo inteiro no log. Os primeiros 200 caracteres dizem a causa; o resto pode ter tokens e dados pessoais.
- Um único
except RequestExceptionem volta deget()ejson(). Desde a 2.27.0 ele captura os dois, então um erro de rede e um erro de leitura do JSON ficam iguais. - Procurar o bug no módulo json. O parser está certo; o corpo é que não é JSON.
Guia de decisão
| O que você vê | O que fazer |
|---|---|
Status 204, ou o corpo está vazio | Não chame r.json(); trate como "sem dados" |
403, 429 ou 503 com text/html | Não leia como JSON e resolva o status (Códigos de status HTTP no web scraping) |
200, mas r.history mostra um 302 para uma página de login | Renove a sua sessão (Sessões e cookies em Python) |
407 com corpo HTML ou vazio | Confira as credenciais do proxy e a whitelist de IP |
ProxyError: Tunnel connection failed: 407 | A mesma causa em destinos https:// (Max Retries Exceeded With URL) |
Extra data | Leia linha por linha com r.iter_lines() |
O corpo começa com callback( | Use o endpoint sem callback ou remova o invólucro |
Unexpected UTF-8 BOM | Decodifique com utf-8-sig (Erros de codificação no Python) |
Perguntas frequentes
Por que r.json() falha quando o código de status é 200?
Um 200 não diz nada sobre o formato do corpo. Uma página de login depois de um redirecionamento, uma resposta JSONP ou um corpo vazio podem vir com 200. Confira o Content-Type, r.history e o início de r.text.
Qual é a diferença entre requests.exceptions.JSONDecodeError e json.JSONDecodeError?
r.json() lança requests.exceptions.JSONDecodeError desde o Requests 2.27.0. Ela é subclasse do JSONDecodeError da biblioteca de JSON e da RequestException do próprio Requests, então as duas a capturam. A exceção a essa regra é o simplejson: quando ele está instalado, except json.JSONDecodeError não pega o erro, então capture a classe do Requests.
Por que recebo JSONDecodeError: Extra data?
O parser leu um valor JSON completo e depois encontrou mais texto: em geral NDJSON, ou dois objetos escritos um depois do outro. Leia linha por linha ou use json.JSONDecoder().raw_decode() para ler um valor de cada vez.
O que significa "Expecting property name enclosed in double quotes"?
Uma chave dentro de um objeto não está entre aspas duplas. A causa mais comum é um dict do Python escrito com str(), que usa aspas simples. No Python 3.12 e em versões anteriores, uma vírgula sobrando antes de } também dá essa mensagem; o Python 3.13 a informa como Illegal trailing comma before end of object.
Recebo esse erro no yfinance ou no spotdl. O que devo fazer?
A biblioteca pediu JSON a um serviço remoto e recebeu outra coisa. Atualize a biblioteca, chame-a com menos frequência e procure a mesma mensagem nas issues do projeto.
Como fica o mesmo erro em JavaScript?
No Node.js 24, JSON.parse() sobre uma página HTML lança SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON, e sobre uma string vazia, SyntaxError: Unexpected end of JSON input. Lá também, confira o status e o Content-Type antes de response.json() (cURL em JavaScript). O aviso do editor do WordPress "The response is not a valid JSON response" é outro problema.
Em resumo
JSONDecodeError: Expecting value é um sintoma, não a causa. A conexão funcionou e o servidor respondeu, mas o corpo estava vazio ou não era JSON. Três verificações encontram a causa: o código de status com r.history, o Content-Type e os primeiros 200 caracteres do corpo. Um 204 vazio é normal, uma página HTML indica bloqueio, login ou página de erro, um 407 aponta para o proxy, e Extra data ou aspas simples significam que o corpo só parece JSON. Os tipos de proxy que você pode colocar na frente de um trabalho assim estão na nossa página de serviços de proxy.




