Numeric Coercion Kills JSONPath Boundary Tests
Your boundary test passes. The value is 2147483647. The JSONPath expression resolves. The assertion is green. And somewhere downstream, a 32-bit integer silently became a float, a string became a number, or a JSON decoder handed you 2147483647.0 instead of 2147483647. The assertion never noticed because JSONPath doesn't enforce types — it navigates structure and compares values, and in most runtimes, 2147483647 == 2147483647.0 evaluates to True.
This is the coercion trap: numeric type boundaries are structurally valid, semantically wrong, and invisible to standard JSONPath queries. It's not a fringe case — it shows up wherever JSON crosses a language or serialization boundary: Python's json module, PostgreSQL's jsonb, Kafka consumers deserializing Avro, and any REST API that accepts both 42 and "42" without complaint.
By the end of this article you'll know exactly where the coercion happens, how to write assertions that catch it before production does, and which tooling combinations — JSON Schema 2020-12, Pydantic, jq, and Hypothesis — give you the tightest boundary coverage without false confidence.
Explore the data, models, mistakes, and methods behind identifying overlooked players.
Why JSONPath Is Blind to Numeric Type Boundaries
JSONPath (RFC 9535) and JMESPath are path languages, not type-enforcement layers. A query like $.order.total returns whatever value lives at that key — integer, float, or string — and most assertion libraries then compare it with Python's ==, JavaScript's ===, or SQL's =. Python's == considers 1 == 1.0 true. That single language-level decision is enough to let a coerced float sail through an integer boundary check. The deeper problem is that JSON itself has one numeric type; the distinction between integer and float is an interpreter concern, not a format concern.
In a modern test architecture — where JSONPath, JMESPath, and jq are the standard toolbox for API response assertions — this blind spot sits exactly at the layer where boundary violations are most dangerous. An INT4 max value stored as 2147483647.0 in a jsonb column will overflow on write to a typed column. A price field that accepts "99.99" as a string will break arithmetic downstream. JSONPath finds the field; it just doesn't tell you what you actually have.
Catching Coercion at the Assertion Layer
The fix starts with separating navigation from validation. Use JSONPath to locate the field, then enforce type and range explicitly. In Python with jsonpath-ng and Pydantic v2:
import json
from jsonpath_ng import parse
from pydantic import BaseModel, field_validator, conint
payload = json.loads(response.text) # could silently produce floats
expr = parse("$.order.quantity")
matches = expr.find(payload)
raw = matches[0].value # might be 10, 10.0, or "10"
class QuantityField(BaseModel):
quantity: conint(ge=1, le=2147483647)
@field_validator("quantity", mode="before")
@classmethod
def reject_float_disguised_as_int(cls, v):
if isinstance(v, float) and not v.is_integer():
raise ValueError(f"Non-integer float: {v}")
if isinstance(v, str):
raise ValueError(f"String where int expected: {v!r}")
return v
QuantityField(quantity=raw) # raises on coerced type, not just range
The validator fires before Pydantic's type coercion kicks in, which is critical — Pydantic v2 will happily cast 10.0 to 10 by default. The mode="before" intercepts the raw value first. This pattern catches the three most common coercions: float-disguised-as-int, numeric string, and None promoted to zero by some deserializers.
JSON Schema 2020-12 for Contract-Level Enforcement
For API contract tests, JSON Schema 2020-12 added the type: integer keyword with stricter semantics than Draft 7 — validators conforming to 2020-12 must reject 1.0 as not an integer. Pair this with Schemathesis to fuzz boundary values automatically:
# openapi.yaml excerpt
components:
schemas:
OrderQuantity:
type: integer
format: int32
minimum: 1
maximum: 2147483647
# Run Schemathesis against a live service, targeting numeric boundaries
schemathesis run http://localhost:8080/openapi.yaml \
--checks all \
--hypothesis-max-examples=500 \
--hypothesis-deriving=positive_data_examples
Schemathesis uses Hypothesis under the hood and will generate values at minimum, maximum, minimum - 1, maximum + 1, and several float representations of each. In one project, enabling --checks all on a payment service found a coercion bug at INT4_MAX + 1 that had existed for 14 months — the previous tests checked range but not type, so 2147483648.0 passed silently.
jq for Pipeline and CI Assertions
When you're asserting on JSON in Bash pipelines or GitHub Actions, jq's type builtin is your fastest guard:
# Fail the pipeline if any price field is not a number, or is a float where int expected
echo "$response" | jq -e '
.items[] |
select(.price | type != "number") |
error("price is not numeric: \(.price)")
'
# Strict integer check (jq treats 1.0 as a number, not an integer)
echo "$response" | jq -e '
.items[] |
if (.quantity | . == floor) then . else error("quantity is float: \(.quantity)") end
'
The . == floor idiom is the idiomatic jq way to distinguish integers from floats — jq's type system doesn't have a separate integer type, so this is the only reliable check. Wire this into your CI step before downstream integration tests run and you eliminate an entire class of environment-specific coercion failures. When JSONPath assertions silently pass on bad data, jq at the pipeline layer is often the last line of defense.
Where Senior Engineers Still Get Burned
The most common mistake is trusting the test data factory to produce the right type. factory_boy, FactoryBot, and Mimesis all generate values from Python or Ruby objects — which are typed — but once that data is serialized to JSON and round-tripped through an HTTP response, the type information is gone. Engineers assume the factory's int is still an int after deserialization. It usually is, until the service adds a middleware layer that touches the field, or a logging interceptor serializes and re-parses the body. The fix: assert on the deserialized response, not on the factory's input value.
The second pitfall is scoping boundary tests only to the happy path. Teams test MAX_INT and MIN_INT but skip MAX_INT + 1, -0, 0.9999999999999999 (which rounds to 1.0 in IEEE 754), and numeric strings like "2147483647". These aren't exotic — they appear in real payloads when upstream services change serializers, when Kafka Avro schemas evolve, or when a frontend sends form data as strings. Hypothesis with st.integers(min_value=2**31-2, max_value=2**31+2) covers this in ten lines.
Myths That Keep Coercion Bugs in Production
"If the JSON Schema validates, the type is safe." Only if your validator implements 2020-12 semantics and you haven't set coerceTypes: true (Ajv's default in some configs). Draft 7 validators vary on whether 1.0 satisfies type: integer. Check your validator version and configuration explicitly — don't assume strictness. Similarly, "Pydantic catches this" is wrong by default: Pydantic v2's int fields accept 1.0 unless you use model_config = ConfigDict(strict=True) or a mode="before" validator as shown above.
"Boundary testing is a QA concern, not a data engineering concern." This is the organizational myth that keeps the bug alive longest. Coercion violations surface at the data layer — in Postgres jsonb extractions, in dbt models that cast json_extract results, in Great Expectations column type assertions that check Python types post-deserialization. When designing test data for REST APIs, type boundary fixtures need to be first-class citizens of the data contract, not an afterthought in the QA checklist. The team that owns the schema owns the boundary.
Silent numeric coercion is a class of bug that passes every structural check and fails only when it costs you. The combination that closes the gap: JSON Schema 2020-12 with a strict validator, Pydantic strict=True or mode="before" validators on deserialized responses, jq type guards in CI, and Schemathesis/Hypothesis for boundary fuzzing. Pick one layer to harden this sprint. Start with the jq guard — it takes twenty minutes and catches the most egregious coercions before they reach integration.
Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.