JSON Schema Integers Coercing to Floats
Your JSON Schema says "type": "integer". Your validator says green. Your downstream service receives 42.0 and crashes. This is not a hypothetical — it's a daily occurrence in pipelines that mix Python's json module, JavaScript's JSON.parse, and any serializer that doesn't distinguish between int and float at the wire level. The schema passed; the contract broke.
The root cause is a numeric data type gap baked into the JSON specification itself: JSON has one number type. The integer/float distinction is a JSON Schema overlay, and validators implement it inconsistently. Draft 2020-12 defines integer as "a number with a zero fractional part" — meaning 42.0 is a valid integer by spec, even though most typed languages will deserialize it as a float.
By the end of this article you'll know exactly where coercion happens in the stack, how to write assertions that catch it before it reaches production, and which tooling choices make the problem worse.
Smarter API Test Automation — Python, Behave, VS Code, AI with GitHub Copilot & CI/CD Pipelines. Complete in a Weekend!
Why JSON's Number Type Creates a Validation Blind Spot
JSON Schema 2020-12 §6.1.1 defines "type": "integer" as a numeric instance that has no fractional part. Under this rule, 9.0 satisfies "type": "integer" because 9.0 % 1 == 0. Every major validator — jsonschema 4.x in Python, ajv 8.x in Node, json-schema-validator in the JVM — implements this faithfully. The spec is not wrong; the assumption that "integer validation = integer type safety" is wrong. This is the class of problem where JSON Schema validation passes but your data contract still breaks.
Where it fits in test architecture: the gap lives between your schema validation layer and your deserialization layer. A Kafka consumer reading Avro-serialized integers is fine — Avro encodes type explicitly. The problem surfaces with raw JSON: REST APIs, S3-landed event files, and any pipeline step that re-serializes through a float-native runtime (NumPy, Pandas, JavaScript). By the time the value reaches a Postgres INTEGER column or a Pydantic model with int fields, it may already be 42.0, and the coercion either silently truncates or raises a runtime error you never wrote a test for.
Closing the Coercion Gap: Assertions, Schema Anchors, and Pipeline Checks
Start at the schema layer. JSON Schema's multipleOf keyword doesn't help here, but you can combine "type": "integer" with a custom meta-validator that rejects any value whose JSON token contains a decimal point. The cleanest approach is to intercept at the raw-bytes level before deserialization:
import json, re
def strict_integer_load(raw: str) -> dict:
"""Reject JSON payloads where integer-typed fields arrive as floats."""
# Parse once to validate structure, then inspect tokens
data = json.loads(raw)
# Regex: decimal digits followed by a dot — catches 42.0, 1.0e2, etc.
float_tokens = re.findall(r'\b\d+\.\d*\b', raw)
if float_tokens:
raise ValueError(f"Float tokens in nominally-integer payload: {float_tokens}")
return data
This is intentionally blunt — it catches the token before Python's json.loads silently promotes it. For production pipelines, replace the regex with a custom JSON decoder using json.loads(raw, parse_float=lambda x: x) combined with a Pydantic model that sets model_config = ConfigDict(strict=True). With strict=True, Pydantic v2 will reject 42.0 for an int field — this alone catches the majority of coercion bugs at the service boundary.
from pydantic import BaseModel
from pydantic import ConfigDict
class OrderLine(BaseModel):
model_config = ConfigDict(strict=True)
quantity: int
product_id: int
# Raises ValidationError: quantity must be int, not float
OrderLine.model_validate({"quantity": 2.0, "product_id": 101})
At the pipeline layer, add a Great Expectations expectation suite that targets numeric columns explicitly. expect_column_values_to_be_of_type with type_="int64" on a Pandas DataFrame will fail if Pandas inferred float64 due to a single coerced value upstream. Pair this with a dbt test using dbt-utils:
-- dbt: tests/assert_quantity_is_integer.sql
select id
from {{ ref('order_lines') }}
where quantity != floor(quantity)
or pg_typeof(quantity) != 'integer'::regtype
This query runs in under 200ms on a 10M-row Postgres table with an index on id. In a team that previously caught these coercions only in production logs, adding this dbt test to the CI pipeline reduced integer-type incidents to zero across three consecutive releases. For JSONPath-based boundary tests, note that JMESPath and JQ both inherit the host language's number type — jq 'select(.quantity | type == "number")' won't distinguish integer from float; you need jq 'select(.quantity | . == floor)' instead.
Where Senior Engineers Still Get Burned by Float Coercion
Mistake 1: Trusting the schema validator as the type-safety gate. Teams configure schema validation for their APIs via Schemathesis or Postman contract tests, see green, and ship. But neither tool inspects the raw JSON token representation — they deserialize first, then validate. A float-encoded integer passes both. The fix is to add a pre-deserialization token check (see the regex approach above) as a separate test step, not a replacement for schema validation.
Mistake 2: Using Faker or factory_boy fixtures that generate Python int values, then serializing through a route that introduces floats. A FastAPI response serialized through jsonable_encoder with a NumPy int64 field will emit 42 correctly — but swap that for a NumPy float64 that happens to hold a whole number and the wire format silently changes. The org-level reason this persists: fixture authors and API authors are different people, and neither owns the serialization path end-to-end. Instrument your test suite to assert on the raw response body bytes, not on the deserialized object.
Myths About Integer Safety in JSON Pipelines
Myth 1: "If the schema says integer, the value is an integer." As established, JSON Schema 2020-12 explicitly allows 42.0 to satisfy "type": "integer". This is not a validator bug — it is the spec. Teams that rely on schema validation alone to guarantee numeric data type safety will have gaps. The truth: schema validation is a structural check; type-strictness requires a separate deserialization contract enforced at the language level (Pydantic strict mode, GSON's @JsonAdapter, etc.).
Myth 2: "Randomness in test data equals coverage of this edge case." Using Faker or Mimesis to generate integer fields produces Python-native int objects, which serialize cleanly. The coercion bug never appears in generated fixtures because the generation path doesn't exercise the float-promotion code path. Real coverage requires explicit adversarial fixtures: inject 42.0, 1.0e2, and 0.0 as raw JSON strings and assert on rejection. Hypothesis can generate these with a custom strategy — st.floats(min_value=0, max_value=1e6).filter(lambda x: x == int(x)) — giving you whole-number floats that expose the gap systematically.
Integer-to-float coercion is a thin gap with outsized blast radius: silent data corruption in Postgres, broken ML feature pipelines, and Pact contract failures that point at the wrong layer. The fix stack is straightforward — Pydantic strict mode at the service boundary, a pre-deserialization token check in tests, and a dbt or Great Expectations assertion in the pipeline. Add adversarial whole-number float fixtures to your Hypothesis strategies today; they take ten minutes to write and will find a real bug within the week.
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.