JSON troubleshooting

Unexpected token '<' / DOCTYPE is not valid JSON

Fix JSON parsing failures caused by HTML responses. Inspect the response status, content type and body before parsing in JavaScript, Python or Go.

What the error means

The JSON parser received a body that starts with HTML, often a DOCTYPE declaration or an HTML tag. The parser stops at the first < because that character cannot begin a JSON value. The response may be a login page, a missing-route page or a proxy error. A successful HTTP status alone does not prove that the body is JSON.

Exact messages by runtime

Messages depend on the input and runtime version. Node and Python rows were reproduced locally. Other rows cite official documentation or source. Rows marked as fragments or templates preserve the source wording and are not complete exception transcripts. Browsers and libraries may append location details.

RuntimeMessageInput and evidence
JavaScript / V8SyntaxError: Unexpected token '<', "<!DOCTYPE html>" is not valid JSONJSON.parse("<!DOCTYPE html>")
Reproduced in Node.js 24.21.0
Older V8Unexpected token < in JSON at position 0HTML starting with <
V8 8.0 message template, with token < and position 0 substituted
Pythonjson.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)json.loads("<!DOCTYPE html>")
Reproduced in Python 3.14.4
Go / encoding/jsoninvalid character '<' looking for beginning of valuejson.Unmarshal([]byte("<!DOCTYPE html>"), &value)
Official Go 1.26 scanner source
FirefoxSyntaxError: JSON.parse: unexpected characterNon-JSON character at the start
MDN diagnostic wording, location suffix omitted
C# / System.Text.Json'{0}' is an invalid start of a value.Source template; {0} is the offending character
Official .NET 10 resource string, path and location suffix omitted
PHP / json_last_error_msg()Syntax errorjson_decode("<!DOCTYPE html>")
Official PHP 8.4 JSON_ERROR_SYNTAX message

Causes you can reproduce

The URL resolves to the application shell

A relative API URL can hit the frontend server. A catch-all route then returns index.html, sometimes with status 200. Verify the full request URL in the Network panel.

JAVASCRIPT
const body = '<!DOCTYPE html>'; // body returned by the wrong route
JSON.parse(body);

Authentication redirects to a login page

Fetch normally follows redirects. An expired session can turn an API request into a final HTML login response. Check response.url and response.redirected, then fix the session or API authentication behavior.

JAVASCRIPT
const body = '<html><form>Sign in</form></html>';
JSON.parse(body);

A server or proxy returns an HTML error

A missing endpoint, gateway failure or server exception can return an HTML error page. Fetch resolves for HTTP 404 and 500, so the caller must check response.ok.

PYTHON
import json
body = '<html><h1>502 Bad Gateway</h1></html>'
json.loads(body)

How to fix it

  1. Inspect the final request URL, redirects, HTTP status and Content-Type in the browser Network panel or client logs.
  2. Read the body as text once. Inspect a short preview in a local debugging session, with credentials and personal data removed.
  3. Correct the URL, authentication or server failure. Make the API return JSON for the response your client expects.
  4. Check status and media type before parsing. application/json and media types ending in +json are JSON types, but the body still has to contain valid JSON.

Inspect a fetch response before parsing

Fetch does not reject for HTTP error statuses. Read the body once as text, inspect it during local debugging, then check status and media type. The 204 branch below returns null as an application choice; adapt it to your API contract. Remove debugging output after diagnosis and redact sensitive values before sharing logs.

JAVASCRIPT
async function fetchJson(url) {
  const res = await fetch(url, {
    headers: { Accept: 'application/json' },
    signal: AbortSignal.timeout(15000),
  });
  const text = await res.text(); // read the body only once
  console.debug(res.status, res.url, res.headers.get('content-type'));
  console.debug(text.slice(0, 200)); // local debugging only
  if (!res.ok) throw new Error('HTTP ' + res.status);
  if (res.status === 204) return null; // only for a documented no-body result
  const type = (res.headers.get('content-type') || '')
    .split(';')[0].trim().toLowerCase();
  if (type !== 'application/json' && !/^application\/[^/]+\+json$/.test(type)) {
    throw new Error('Expected JSON, received ' + (type || 'no Content-Type'));
  }
  if (!text.trim()) throw new Error('Expected JSON, received an empty body');
  return JSON.parse(text);
}

Inspect a Python requests response

Print r.status_code, r.headers and a preview of r.text before decoding. The Requests documentation explains why an empty response or invalid JSON can fail decoding even when the HTTP request itself succeeds.

PYTHON
import json
import requests

def fetch_json(url):
    r = requests.get(url, headers={"Accept": "application/json"}, timeout=15)
    print(r.status_code, r.url, r.headers.get("Content-Type"))
    print(repr(r.text[:200]))  # local debugging only
    r.raise_for_status()
    if r.status_code == 204:
        return None  # only for a documented no-body result
    media_type = r.headers.get("Content-Type", "").split(";")[0].strip().lower()
    is_json = media_type == "application/json" or (
        media_type.startswith("application/") and media_type.endswith("+json")
    )
    if not is_json:
        raise ValueError(f"Expected JSON, received {media_type or 'no Content-Type'}")
    if not r.text.strip():
        raise ValueError("Expected JSON, received an empty body")
    return json.loads(r.text)

Check the Go response before unmarshalling

Read and close the body, check status and media type, then use json.Unmarshal for one complete document. A SyntaxError.Offset counts bytes read. For a stream of records, use the multiple-values workflow instead.

GO
package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "mime"
    "net/http"
    "strings"
    "time"
)

func fetchJSON(url string) (any, error) {
    client := &http.Client{Timeout: 15 * time.Second}
    resp, err := client.Get(url)
    if err != nil { return nil, err }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil { return nil, err }
    preview := body
    if len(preview) > 200 { preview = preview[:200] }
    fmt.Printf("status=%d type=%q body=%q\n", resp.StatusCode,
        resp.Header.Get("Content-Type"), preview) // local debugging only
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
    }
    if resp.StatusCode == http.StatusNoContent { return nil, nil }
    mediaType, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
    if err != nil { return nil, fmt.Errorf("invalid Content-Type: %w", err) }
    if mediaType != "application/json" &&
        !(strings.HasPrefix(mediaType, "application/") && strings.HasSuffix(mediaType, "+json")) {
        return nil, fmt.Errorf("expected JSON, received %s", mediaType)
    }
    var value any
    if err := json.Unmarshal(body, &value); err != nil {
        var syntax *json.SyntaxError
        if errors.As(err, &syntax) {
            return nil, fmt.Errorf("JSON error after byte %d: %w", syntax.Offset, err)
        }
        return nil, err
    }
    return value, nil
}

func main() {
    value, err := fetchJSON("https://example.com/api/data")
    if err != nil { fmt.Println(err); return }
    fmt.Println(value)
}

Find the exact position with Tool Kumar

  1. Copy the exact input text, including its original whitespace. For HTTP failures, copy the raw response body.
  2. Open the Tool Kumar JSON validator and paste the text. The document stays in your browser.
  3. Read the line and column in the error or fixable-issue banner. For an invalid document, select Go to error to move the caret to that location.
  4. Inspect the preceding token as well as the marked character. A parser marks where it discovered the problem; the missing quote, separator or value can start earlier.

Python character offsets are zero-based, while its displayed lines and columns are one-based. V8 string positions count UTF-16 code units; Go offsets count bytes read. Unicode text can make these offsets differ. Use the original text and its line/column context rather than copying an offset between runtimes.

Tool Kumar can classify some input as fixable loose JSON. Review a proposed change before using the repair action, then validate the result against your API contract. For empty input, inspect the producer or response because there is no character to select.

Frequently asked questions

Why does DOCTYPE appear in a JSON error?

DOCTYPE starts an HTML document. Its appearance in a JSON error means the body passed to the parser contains HTML. Inspect the endpoint and response body before changing the JSON parser.

Can this happen with HTTP 200?

Yes. A frontend fallback or login page can return HTML with status 200. Check both the status and Content-Type, then inspect the actual body.

Should I remove the < character?

No. Removing the first character cannot turn an HTML page into the intended JSON response. Fix the request or server response.

Does Accept: application/json guarantee JSON?

No. Accept expresses a client preference. The server can still return another media type, so check the response Content-Type and body.

Standards and diagnostic sources

RFC 8259 and ECMA-404 define JSON syntax. Diagnostic wording belongs to the parser. The table links each cited message to its source.