Skip to content
Python

5 Python JSON Decoding Policy Tests Before Release

·10 min read
5 Python JSON Decoding Policy Tests Before Release - Python guide from JSON Prettifier

Quick answer: Test five independent decoding policies: string control characters, duplicate object names, non-finite numbers, complete object documents, and bounded UTF-8 input. Python's strict flag covers only part of this boundary. A successful parse establishes a Python value; your endpoint still needs explicit rules for what that value may contain.

A request can parse successfully and still violate the contract your backend assumes. The Python json module guide covers everyday loading and dumping; this article adds executable acceptance tests for an object-only input boundary. It does not replace model or business-rule validation.

Run the Python blocks together, in order, as one file. They were executed with Python 3.12.14 using only the standard library and synthetic inputs. No framework, remote endpoint, or production workload was tested. Wikipedia's JSON overview supplies format background; the linked Python documentation defines the decoder behavior used below.

1. Does strict mode reject literal control characters inside strings?

Test a literal control character separately from its valid escaped representation. Python's strict decoder rejects the former inside a string while accepting the latter. Keep strict enabled unless the input contract deliberately permits that extension, and avoid interpreting its name as a promise that every other acceptance policy is enforced.

The Python JSONDecoder documentation defines this particular option. The fixture uses a real newline in one input and a backslash escape in the other. Those strings can look nearly identical in an editor, so the assertions make the distinction observable.

import json
import math

def expect_error(error_type, function, *args):
    try:
        function(*args)
    except error_type:
        return
    raise AssertionError(f"Expected {error_type.__name__}")

literal_newline = '{"note":"first\nsecond"}'
escaped_newline = r'{"note":"first\nsecond"}'
expect_error(json.JSONDecodeError, json.loads, literal_newline)
assert json.loads(escaped_newline) == {"note": "first\nsecond"}
assert json.loads(literal_newline, strict=False)["note"] == "first\nsecond"

The last assertion documents an intentional difference, not a recommended fix. If a partner sends unescaped line breaks, changing strict to false broadens your accepted language. Decide whether the partner should correct its serializer before modifying the receiver. A workaround hidden in one service can leave another service rejecting the same message.

Keep raw input and decoded values distinct during diagnosis. A decoded string containing a newline is perfectly compatible with valid JSON when the source encoded that newline correctly. Logging only the decoded value cannot establish whether the original document contained an escape or an illegal literal character.

Use the error helper for focused negative fixtures, and let unexpected exception types fail the test. It deliberately does not compare an interpreter's exact diagnostic sentence. If your public endpoint promises a stable error code, test your own error mapping separately. The parser's character position is useful for debugging, but it is not a substitute for deciding which inputs the application accepts.

Finally, run these fixtures through your actual request adapter. An adapter that repairs or rewrites text before decoding can change the result. Such rewriting should be an explicit product decision, with its own evidence, rather than an invisible step that makes malformed input appear to pass a strict decoder.

2. Are repeated object names rejected before they become a dictionary?

Reject repeated names while the decoder still exposes each object's ordered pairs. A dictionary created by ordinary decoding retains only the last value for a repeated name, so later field validation cannot recover the discarded input. Test nested duplicates, escaped equivalent names, and legitimate reuse of a name in separate objects.

Python provides object_pairs_hook for processing the pairs before ordinary dictionary construction. The hook below implements an application policy that names must be unique within each object. That policy is deliberately stronger than simply accepting whatever the default decoder produces.

def unique_object(pairs):
    result = {}
    for name, value in pairs:
        if name in result:
            raise ValueError("duplicate object name")
        result[name] = value
    return result

def decode_unique(text):
    return json.loads(text, object_pairs_hook=unique_object)

assert json.loads('{"role":"reader","role":"owner"}') == {"role": "owner"}
for text in [
    '{"role":"reader","role":"owner"}',
    '{"user":{"role":"reader","role":"reader"}}',
    r'{"role":1,"\u0072ole":2}',
]:
    expect_error(ValueError, decode_unique, text)
assert decode_unique('{"left":{"id":1},"right":{"id":2}}') == {
    "left": {"id": 1}, "right": {"id": 2}
}

Equal repeated values are rejected too. The policy is about ambiguous source names, not whether two values happen to agree today. Escaped equivalent names belong in the fixture because their decoded strings compare equal even though the source spelling differs. This check does not perform Unicode normalization or case folding; those would be additional application rules.

The duplicate JSON keys guide discusses cross-parser behavior. Preserve the original text for this test instead of pretty-printing it first. A formatter that decodes into an ordinary object can erase the very evidence you are trying to inspect.

After acceptance, use the JSON Viewer to explore a sanitized, duplicate-free fixture. Expanding nested objects helps confirm where a field lives. The tree is a view of a parsed value; it does not prove that the original source contained no repeated names. Keep duplicate detection in the Python boundary where the complete pairs are available.

If a framework has already parsed the body before your handler runs, attaching this hook to a second serialization of its dictionary is too late. Find the framework's raw-body decoding extension point, or enforce the policy in an earlier component that forwards only accepted input. Test that integration rather than assuming the hook runs because its helper exists in the codebase.

3. Are named non-finite values and float overflow handled independently?

Use parse_constant to reject named extensions such as NaN and Infinity, then separately check the result of floating-point token conversion. A valid numeric exponent can overflow a Python float without passing through parse_constant. A finite-float policy needs both checks, and it still does not promise exact decimal arithmetic or protection from underflow.

Python's interoperability notes describe the named extensions. The math.isfinite reference defines the finite-value check used for converted floating-point tokens. These are distinct paths, so the negative examples exercise each one explicitly.

def reject_constant(_token):
    raise ValueError("non-finite constant")

def finite_float(token):
    value = float(token)
    if not math.isfinite(value):
        raise ValueError("float outside finite range")
    return value

def decode_finite(text):
    return json.loads(text, object_pairs_hook=unique_object,
                      parse_constant=reject_constant, parse_float=finite_float)

assert math.isinf(json.loads("1e400"))
assert math.isinf(json.loads("1e400", parse_constant=reject_constant))
for token in ["NaN", "Infinity", "-Infinity", "1e400", "-1e400"]:
    expect_error(ValueError, decode_finite, '{"value":' + token + '}')
assert decode_finite('{"value":1.25,"count":3}') == {"value": 1.25, "count": 3}

Do not label every rejected input a JSON syntax error. An overflowing exponent can follow the JSON grammar while exceeding the finite range your application permits. That distinction helps support teams give the producer a useful explanation: the producer may need a different numeric representation rather than a punctuation fix.

This demonstration chooses binary floating-point results. It does not establish that a money calculation is exact or that a tiny nonzero token remains nonzero after conversion. If those properties matter, use a decimal policy with domain-specific limits and test it separately. Likewise, parse_int is not changed here; integer range and digit-count policies remain independent.

Keep the negative control that uses only parse_constant. It demonstrates why a partial fix is insufficient and protects against a future refactor that drops the finite conversion hook. Add nested numeric fixtures to your application's tests if a mapper later coerces values again. Acceptance at the decoder cannot guarantee that downstream arithmetic or storage preserves the chosen representation.

4. Does the endpoint require one complete object document?

Parse one complete JSON document, then apply the endpoint's top-level object rule explicitly. Arrays and primitive values can be valid JSON even when an endpoint rejects them. Test trailing whitespace, extra documents, and missing content separately so the client receives a shape failure or syntax failure that reflects the actual problem.

RFC 8259's JSON text grammar permits a serialized value, not just an object. An object-only request is therefore an application contract. Keep that distinction clear in documentation, validation messages, and tests so developers do not learn an incorrect rule about the format itself.

def decode_object(text):
    value = decode_finite(text)
    if not isinstance(value, dict):
        raise ValueError("top-level object required")
    return value

assert decode_object(' {"ok":true} \n') == {"ok": True}
for text in ["[]", "null", "false", "0", '"message"']:
    expect_error(ValueError, decode_object, text)
for text in ["", "{} {}", '{"ok":true} trailing']:
    expect_error(json.JSONDecodeError, decode_object, text)
assert json.loads("[]") == []

The final assertion preserves an important positive control: the array is valid for the general decoder. In an application test suite, distinguish rejection by the endpoint contract from failure of JSON syntax. Both can become client errors, but they need not share the same diagnostic code.

Avoid replacing loads with raw_decode merely to stop an extra-data error. A prefix decoder can be useful when an explicit framing protocol tells the caller how to consume subsequent content. Ignoring the remaining text is a different behavior and can hide an accidentally concatenated response or configuration file.

For multiple records, choose a documented framing format such as the one described in the JSON Lines guide. Do not quietly reinterpret an object-only endpoint as a stream because two individually valid documents arrived together. Test the intended protocol at its actual boundary.

This wrapper still accepts an empty object. Requiring fields, rejecting unknown names, validating types, and enforcing business permissions happen afterward. The Pydantic JSON validation tests address model-level behavior. Keeping those responsibilities separate makes it possible to say exactly which policy failed without treating a successful dictionary conversion as a complete validation result.

5. Is the supplied UTF-8 body bounded before decoding begins?

Check the length of the supplied bytes before decoding text, then decode UTF-8 without replacing invalid sequences. Test the exact byte boundary and a multibyte character so character counts cannot masquerade as byte limits. This wrapper bounds an already supplied body; transport buffering, decompression, nesting, and execution limits need separate enforcement.

The Python bytes.decode documentation defines decoding and error handling. The fixture uses a small limit solely to make the boundary easy to inspect. It is not a recommended production request size; choose your service's limits from the documented workload and operational constraints.

def decode_body(body, max_bytes=64):
    if not isinstance(body, bytes):
        raise TypeError("bytes required")
    if len(body) > max_bytes:
        raise ValueError("body exceeds byte limit")
    return decode_object(body.decode("utf-8"))

boundary = b'{"ok":true}'
boundary += b" " * (64 - len(boundary))
assert decode_body(boundary) == {"ok": True}
expect_error(ValueError, decode_body, boundary + b" ")
text = '{"label":"é"}'
encoded = text.encode("utf-8")
assert len(encoded) > len(text)
assert decode_body(encoded, len(encoded)) == {"label": "é"}
expect_error(ValueError, decode_body, encoded, len(text))
expect_error(UnicodeDecodeError, decode_body, b'{"label":"\xff"}')
expect_error(TypeError, decode_body, '{"ok":true}')
print("All five Python JSON decoding policy test groups passed")

Do not decode with replacement characters to avoid an error unless that transformation is a deliberate contract choice. Replacing bytes can change a field while making the body appear acceptable. A producer sending a different encoding should receive a clear failure or use an explicitly supported encoding path.

Place an independent request-size limit where the server reads the transport. By the time this function receives bytes, another component may already have allocated the entire body. Compressed requests add another boundary: a small compressed input can expand into a much larger decoded body. These fixtures do not measure either resource path.

Review nesting limits and time budgets separately, and do not catch every exception as if it were harmless malformed JSON. Resource failures need operational handling that differs from a duplicate-name policy rejection. The large JSON file guide discusses broader size considerations without making this small wrapper a complete resource-defense mechanism.

Keep the five groups alongside the real decoder's integration tests. Run them after framework upgrades or changes to request preprocessing, and verify that rejected requests do not reach business handlers. Record the selected policies in the endpoint documentation. That gives producers a concrete acceptance contract and makes future changes reviewable instead of hiding them behind the reassuring word strict.

Frequently Asked Questions

Does strict=True reject every non-standard JSON value?
No. In Python's standard JSON decoder, strict controls unescaped control characters inside strings. It does not automatically reject duplicate object names or the NaN and Infinity extensions. Define those policies with the relevant decoder hooks, then test the complete entry point rather than treating strict as a universal validation switch.
Can a schema find duplicate keys after json.loads?
An ordinary dictionary produced by json.loads has already discarded earlier values for repeated names. A schema applied to that dictionary cannot reconstruct the original pairs. Detect duplicates during decoding with object_pairs_hook, retain the original input when permitted, and apply schema or business validation after the duplicate policy has succeeded.
Does parse_constant reject an overflowing exponent?
No. parse_constant handles the decoder's named non-finite extensions, such as NaN and Infinity. A syntactically valid numeric token such as 1e400 follows the floating-point conversion path instead. If your contract requires finite Python floats, check that conversion explicitly; choose a decimal representation when exact decimal arithmetic is required.
Is a top-level JSON array invalid?
No. JSON can represent an array or a primitive as its complete top-level value. An endpoint can independently require an object because its request contract needs named fields. Report a rejected array as an application shape violation, rather than suggesting that every top-level array is malformed JSON syntax.
Does a byte limit make this decoder resource-safe?
A byte limit bounds the supplied input size, but it does not independently bound nesting depth, parsing time, decompression, or earlier network buffering. Enforce limits at the transport boundary too, and test the relevant resource policies separately. The small fixtures here demonstrate acceptance rules rather than a complete denial-of-service defense.

Can you inspect the accepted document?

Explore a sanitized, duplicate-free fixture as a tree after checking the original input with your Python decoder.

Open JSON Viewer