ProxynetProxynet

Como corrigir JSONDecodeError: Expecting Value no Python

Publicado:

16 min de leitura

Acar Diveroli
Autor: Acar Diveroli
Portão r.json(): cartões vermelhos com corpo vazio, <!DOCTYPE html> e página 407 à esquerda; um cartão {"ok": true} passa

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ça json.JSONDecodeError, uma subclasse de ValueError.
  • Requests: desde a versão 2.27.0 (janeiro de 2022), r.json() lança requests.exceptions.JSONDecodeError. Segundo o changelog do Requests, ela herda das exceções lançadas antes e também é uma RequestException.
  • Requests com simplejson instalado: a classe-mãe passa a ser simplejson.errors.JSONDecodeError. No nosso teste, except json.JSONDecodeError então não a capturou; except ValueError ainda capturou.
  • HTTPX: Response.json() lança o json.decoder.JSONDecodeError padrão.
  • AIOHTTP: await resp.json() confere o Content-Type primeiro e lança ContentTypeError (Attempt to decode JSON with unexpected mimetype: text/html) sem ler o corpo. Com content_type=None, lança json.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:

python
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:

  1. Status e histórico. O status é 2xx? Houve um 301 ou 302 no caminho? Depois de um redirecionamento, r.status_code mostra o 200 final, e só r.history mostra [<Response [302]>].
  2. URL final. r.url é o endereço depois dos redirecionamentos. Se terminar em /login ou /consent, você não chegou à API.
  3. Content-Type. Você quer application/json ou um tipo que termine em +json. Qualquer outro aponta para uma das causas abaixo.
  4. Tamanho e primeiros caracteres. 0 significa vazio, < significa HTML, {' um dict do Python impresso como texto, cb( JSONP.
  5. 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 corpoStatus e Content-Type típicosMensagem de r.json()Causa provávelO que fazer
(nada)204, 304, HEAD ou um 200 vazioExpecting value: line 1 column 1 (char 0)O endpoint não devolve conteúdoConfira o status e len(r.content) antes de ler
<!DOCTYPE html>403, 429, 503, ou 200 depois de um 302; text/htmlExpecting value: line 1 column 1 (char 0)Página de bloqueio, redirecionamento para o login, página de erroResolva o status primeiro
<html>...407... ou nada407 e Proxy-Authenticate, destino http://Expecting value: line 1 column 1 (char 0)Credenciais ou IP do proxy erradosConfira user:pass e a whitelist de IP
Too Many Requests429, text/plainExpecting value: line 1 column 1 (char 0)Um limite de taxa em texto puroDiminua o ritmo; leia Retry-After
cb({"items": ...});200, application/javascriptExpecting value: line 1 column 1 (char 0)JSONPUse o endpoint sem callback
{"id":1}, depois uma nova linha e {"id":2}200, application/x-ndjsonExtra data: line 2 column 1 (char 9)NDJSONLeia linha por linha
{'id': 1, ...}200, muitas vezes text/plainExpecting 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/jsonUnexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)Uma marca de ordem de bytesDecodifique 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.

python
"""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.

text
/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 204 ou 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: pass não guarda nada e esconde a causa.
  • Confiar no status 200. Uma página de login depois de um 302 também chega como 200. Confira r.history e r.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 429 ou 403; 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 RequestException em volta de get() e json(). 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á vazioNão chame r.json(); trate como "sem dados"
403, 429 ou 503 com text/htmlNã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 loginRenove a sua sessão (Sessões e cookies em Python)
407 com corpo HTML ou vazioConfira as credenciais do proxy e a whitelist de IP
ProxyError: Tunnel connection failed: 407A mesma causa em destinos https:// (Max Retries Exceeded With URL)
Extra dataLeia 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 BOMDecodifique 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.