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.
| Runtime | Message | Input and evidence |
|---|---|---|
| JavaScript / V8 | SyntaxError: Unexpected token '<', "<!DOCTYPE html>" is not valid JSON | JSON.parse("<!DOCTYPE html>")Reproduced in Node.js 24.21.0 |
| Older V8 | Unexpected token < in JSON at position 0 | HTML starting with <V8 8.0 message template, with token < and position 0 substituted |
| Python | json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) | json.loads("<!DOCTYPE html>")Reproduced in Python 3.14.4 |
| Go / encoding/json | invalid character '<' looking for beginning of value | json.Unmarshal([]byte("<!DOCTYPE html>"), &value)Official Go 1.26 scanner source |
| Firefox | SyntaxError: JSON.parse: unexpected character | Non-JSON character at the startMDN diagnostic wording, location suffix omitted |
| C# / System.Text.Json | '{0}' is an invalid start of a value. | Source template; {0} is the offending characterOfficial .NET 10 resource string, path and location suffix omitted |
| PHP / json_last_error_msg() | Syntax error | json_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.
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.
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.
import json
body = '<html><h1>502 Bad Gateway</h1></html>'
json.loads(body)How to fix it
- Inspect the final request URL, redirects, HTTP status and Content-Type in the browser Network panel or client logs.
- Read the body as text once. Inspect a short preview in a local debugging session, with credentials and personal data removed.
- Correct the URL, authentication or server failure. Make the API return JSON for the response your client expects.
- 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.
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.
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.
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
- Copy the exact input text, including its original whitespace. For HTTP failures, copy the raw response body.
- Open the Tool Kumar JSON validator and paste the text. The document stays in your browser.
- 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.
- 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.
Related errors and tools
- Unexpected end of JSON input: empty or truncated data
- Go JSON: invalid character looking for beginning of value
- All JSON error guides
- JSON syntax reference
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.