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)
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
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)
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"
}
may be produced instead of:
{
"name": "Андрей"
}
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"]
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
}
but receives:
{
"age": "twenty-five"
}
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):
...
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
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.
The important part is:
"additionalProperties": False
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"
}
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"]
}
Only one of these three values will be accepted.
Regular expressions can be used with pattern:
{
"type": "string",
"pattern": "^[A-Za-z]+$"
}
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())
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"
}
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
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):
...
you can work with a well-defined model:
def process_user(user: User):
...
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
The basic process is:
- Receive the JSON request body.
- Parse the JSON.
- Validate it against the expected schema or model.
- If validation fails, collect the validation errors.
- Return a structured HTTP error response, typically with a
4xxstatus code. - Only after successful validation pass the data to the business logic.
- 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:
...
Then another function adds its own checks:
if "age" in data and isinstance(data["age"], int):
...
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)
additionalProperties: falseis doing more architectural work here than the basicjson.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.