DEV Community

Cover image for JSON and Data Validation in Python: From File Reading to Strict Contracts
Den
Den

Posted on

JSON and Data Validation in Python: From File Reading to Strict Contracts

JSON (JavaScript Object Notation) is a text-based data interchange format that has become a de facto standard in modern web development. Thanks to its simplicity, readability, and language independence, JSON is used everywhere: for exchanging data between clients and servers through APIs, storing configuration files such as settings.json, and recording application events in logs.

In the Python ecosystem, JSON support is available directly in the standard library, while data validation can be handled with specialized libraries.

Working with JSON in Python

You don't need to install any third-party packages to work with JSON. Python's standard library includes the json module, which can convert JSON strings and files into native Python structures such as dictionaries and lists, and convert them back to JSON.

Reading JSON from a file

import json

with open("data.json", "r", encoding="utf-8") as file:
    data = json.load(file)
Enter fullscreen mode Exit fullscreen mode

If the JSON data is already available as a string, for example as a response received from an external API, use loads():

import json

json_string = '{"name": "John", "age": 42}'
data = json.loads(json_string)

print(data["age"])  # Output: 42
Enter fullscreen mode Exit fullscreen mode

The distinction is simple:

  • json.load() reads JSON from a file-like object.
  • json.loads() parses JSON from a string.

Writing JSON back to a file

It is important to remember that the json module works with objects in memory. If you modify the data, you must explicitly write it back to a file if you want to persist the changes.

import json

# Add a new field
data["company"] = "Tech Company"

with open("data.json", "w", encoding="utf-8") as file:
    json.dump(data, file, indent=4, ensure_ascii=False)
Enter fullscreen mode Exit fullscreen mode

The indent=4 parameter formats the JSON with indentation, making the file easier for humans to read.

ensure_ascii=False keeps non-ASCII characters readable instead of converting them into Unicode escape sequences.

For example, without it, text such as:

{
    "name": "\u0410\u043d\u0434\u0440\u0435\u0439"
}
Enter fullscreen mode Exit fullscreen mode

may be produced instead of:

{
    "name": "Андрей"
}
Enter fullscreen mode Exit fullscreen mode

Why Do We Need Data Validation?

Imagine that your application receives JSON from a mobile client.

A developer working on the mobile application accidentally renames the user_id field to userid. Your backend may then fail when it tries to access the missing key:

user_id = data["user_id"]
Enter fullscreen mode Exit fullscreen mode

This results in a KeyError.

An even more dangerous situation occurs when the structure is correct but the data type is not. For example, the server expects an integer:

{
    "age": 25
}
Enter fullscreen mode Exit fullscreen mode

but receives:

{
    "age": "twenty-five"
}
Enter fullscreen mode Exit fullscreen mode

An operation expecting a number may then fail with a TypeError.

This is why applications often use schemas and data validation.

A schema describes the expected structure of the data: which fields are allowed, which fields are required, what types their values must have, and what additional constraints apply.

In other words, a schema acts as a contract between different parts of a system.

Strict Validation with jsonschema

You can write validation manually:

if not isinstance(data.get("age"), int):
    ...
Enter fullscreen mode Exit fullscreen mode

However, as the data structure becomes more complicated, manually maintaining dozens or hundreds of such checks quickly becomes difficult.

A more standardized approach is the jsonschema library.

Install it with:

pip install jsonschema
Enter fullscreen mode Exit fullscreen mode

Let's define a strict user contract.

from jsonschema import validate, ValidationError

# 1. Define the contract
user_schema = {
    "type": "object",
    "properties": {
        "username": {
            "type": "string",
            "minLength": 3
        },
        "age": {
            "type": "integer",
            "minimum": 0
        },
        "email": {
            "type": "string",
            "format": "email"
        },
        "is_active": {
            "type": "boolean"
        }
    },
    "required": ["username", "age"],
    "additionalProperties": False
}

# 2. Data received from an external source
incoming_data = {
    "username": "john_smith",
    "age": 25,
    "email": "john@example.com",
    "is_active": True
}

# 3. Validate the data
try:
    validate(instance=incoming_data, schema=user_schema)
    print("Data is valid. It can be stored in the database.")

except ValidationError as error:
    print(f"Data validation error: {error.message}")
    # The API could return HTTP 400 Bad Request here.
Enter fullscreen mode Exit fullscreen mode

The important part is:

"additionalProperties": False
Enter fullscreen mode Exit fullscreen mode

It means that fields not explicitly defined in the schema are rejected.

For example, this object would fail validation:

incoming_data = {
    "username": "john_smith",
    "age": 25,
    "role": "admin"
}
Enter fullscreen mode Exit fullscreen mode

because role is not part of the contract.

This can be extremely useful for APIs where silently accepting unexpected fields could lead to bugs or security problems.

More Complex Validation Rules

JSON Schema supports much more than simple type checking.

For example, enum restricts a value to a predefined set:

{
    "type": "string",
    "enum": ["admin", "editor", "viewer"]
}
Enter fullscreen mode Exit fullscreen mode

Only one of these three values will be accepted.

Regular expressions can be used with pattern:

{
    "type": "string",
    "pattern": "^[A-Za-z]+$"
}
Enter fullscreen mode Exit fullscreen mode

This example allows only Latin letters.

JSON Schema can also describe arrays, nested objects, numeric limits, string lengths, conditional requirements, and relationships between fields.

As a result, even fairly complex API contracts can be described declaratively instead of implementing every rule manually in Python.

An Alternative Approach: Pydantic and Type Hints

There is another popular approach in modern Python applications: using Pydantic models.

Instead of describing the structure as a dictionary containing a JSON Schema, you describe it using Python classes and type annotations.

For example:

from pydantic import BaseModel, EmailStr, ValidationError


class User(BaseModel):
    username: str
    age: int
    email: EmailStr
    is_active: bool = True


try:
    user = User(**incoming_data)

    print(user.model_dump_json(indent=2))

except ValidationError as error:
    print(error.errors())
Enter fullscreen mode Exit fullscreen mode

This approach provides several useful features.

Pydantic can parse incoming data and, where appropriate, convert compatible values to the declared Python types.

For example:

incoming_data = {
    "username": "john_smith",
    "age": "25",
    "email": "john@example.com"
}
Enter fullscreen mode Exit fullscreen mode

Depending on the field and Pydantic configuration, the string "25" can be parsed as the integer 25.

The resulting object is no longer just an arbitrary dictionary. It is a User instance with a defined structure and validated fields.

The default value also means that is_active does not have to be supplied:

class User(BaseModel):
    username: str
    age: int
    email: EmailStr
    is_active: bool = True
Enter fullscreen mode Exit fullscreen mode

If the field is omitted, Pydantic uses True.

Pydantic in API Development

Pydantic is widely used in modern Python web applications and is a core part of frameworks such as FastAPI.

One of its major advantages is that the same model can serve multiple purposes:

  • validate incoming data;
  • provide typed Python objects;
  • serialize data back to JSON;
  • describe API schemas;
  • generate API documentation.

This makes Pydantic particularly convenient when building typed REST APIs.

Instead of passing unvalidated dictionaries throughout the application:

def process_user(data):
    ...
Enter fullscreen mode Exit fullscreen mode

you can work with a well-defined model:

def process_user(user: User):
    ...
Enter fullscreen mode Exit fullscreen mode

The difference becomes increasingly important as the application grows.

A Practical API Validation Workflow

In a typical backend application, validation should happen at the system boundary — before untrusted external data reaches the business logic.

The workflow looks like this:

Client
   │
   ▼
JSON request
   │
   ▼
Validation
   │
   ├── Invalid ──► HTTP 400
   │
   ▼
Validated data
   │
   ▼
Business logic
   │
   ▼
Database
Enter fullscreen mode Exit fullscreen mode

The basic process is:

  1. Receive the JSON request body.
  2. Parse the JSON.
  3. Validate it against the expected schema or model.
  4. If validation fails, collect the validation errors.
  5. Return a structured HTTP error response, typically with a 4xx status code.
  6. Only after successful validation pass the data to the business logic.
  7. Store or process the validated data.

This creates a clear boundary between untrusted external input and internal application state.

Why Strict Contracts Matter

Without validation, every function that receives external data has to protect itself:

if data and "username" in data and data["username"] is not None:
    ...
Enter fullscreen mode Exit fullscreen mode

Then another function adds its own checks:

if "age" in data and isinstance(data["age"], int):
    ...
Enter fullscreen mode Exit fullscreen mode

And another one checks something else.

Over time, this leads to duplicated validation logic scattered throughout the codebase.

A schema or model moves these checks to a single, well-defined boundary.

After validation succeeds, the rest of the application can operate under a much stronger assumption:

If the data reached the business logic, it already satisfies the contract.

That does not eliminate the need for error handling, but it dramatically reduces the number of defensive checks required throughout the code.

JSON Is More Than Just a File Format

JSON itself is simple. The difficult part is usually not parsing JSON — it is ensuring that the data represented by JSON actually matches what the application expects.

For small scripts, the standard json module is usually enough.

For APIs and larger applications, schema validation becomes increasingly important. Depending on the architecture, jsonschema, Pydantic, or another validation system can turn loosely structured JSON into a well-defined data contract.

The result is not just cleaner code. It is a system where the boundaries between components are explicit, errors are detected earlier, and changes to data structures are much easier to control.

Top comments (1)

Collapse
 
marcusykim profile image
Marcus Kim

additionalProperties: false is doing more architectural work here than the basic json.load()/json.loads() distinction: it turns an input shape into an enforceable boundary instead of a hopeful convention. The contrast with Pydantic accepting "25" as an integer is important, because coercion can be convenient while also hiding a client regression. In production, I'd make strictness an explicit per-boundary decision and return field-level 4xx errors with stable codes; that gives client teams something actionable while keeping malformed data out of business logic.