Conditional Validation Modes
Conditional validation in this codebase is primarily managed through two specialized core schemas: LaxOrStrictSchema and JsonOrPythonSchema. These structures allow the validation logic to branch dynamically based on the validation mode (strict vs. lax) or the input format (JSON vs. Python objects). This design is central to the performance and flexibility goals of Pydantic V2, enabling "fast paths" for native Python objects while maintaining robust parsing for JSON data.
Lax vs. Strict Validation
The LaxOrStrictSchema is used to define two distinct validation paths. In Pydantic, "lax" mode typically allows for type coercion (e.g., converting the string "123" to the integer 123), while "strict" mode requires the input to match the target type exactly.
By using a structural branch rather than a simple boolean flag inside a single validator, the engine can execute entirely different logic paths. This is visible in pydantic-core/tests/validators/test_lax_or_strict.py:
from pydantic_core import SchemaValidator, core_schema
# Define a schema that is a string in lax mode but an integer in strict mode
v = SchemaValidator(core_schema.lax_or_strict_schema(
lax_schema=core_schema.str_schema(),
strict_schema=core_schema.int_schema()
))
# Default is lax -> uses str_schema
assert v.validate_python('aaa') == 'aaa'
# Runtime strict=True -> uses int_schema
assert v.validate_python(123, strict=True) == 123
Reasoning and Tradeoffs
The primary reason for this design is performance. In strict mode, the validator can skip all coercion logic and jump straight to type checking. However, this introduces a maintenance tradeoff: developers must ensure that both the lax_schema and strict_schema produce compatible results if they are intended to represent the same logical field.
The strict property on the LaxOrStrictSchema itself acts as a default for that specific branch, which can be overridden by the global SchemaValidator configuration or runtime arguments.
JSON vs. Python Inputs
The JsonOrPythonSchema addresses the difference between data coming from a JSON string (via validate_json) and data already present as Python objects (via validate_python).
When validating from JSON, the data is already in a "primitive" state (strings, numbers, lists, dicts). When validating from Python, the input might already be an instance of the target class. JsonOrPythonSchema allows for a "fast path" instance check for Python inputs while providing a parsing path for JSON.
A common pattern found in pydantic-core/tests/validators/test_json_or_python.py demonstrates this:
from pydantic_core import SchemaValidator, core_schema as cs
class Foo:
def __init__(self, value):
self.value = value
def __eq__(self, other):
return isinstance(other, Foo) and self.value == other.value
s = cs.json_or_python_schema(
json_schema=cs.no_info_after_validator_function(Foo, cs.str_schema()),
python_schema=cs.is_instance_schema(Foo)
)
v = SchemaValidator(s)
# validate_python uses python_schema: a simple isinstance check
assert v.validate_python(Foo('abc')) == Foo('abc')
# validate_json uses json_schema: parses string then calls constructor
assert v.validate_json('"abc"') == Foo('abc')
Serialization Branching
Unlike many other schemas, JsonOrPythonSchema also influences serialization. It branches based on the target format:
to_json(ormodel_dump_json) will typically follow thejson_schemapath.to_python(ormodel_dump) will follow thepython_schemapath.
This ensures that types like Enums or custom classes are handled correctly when converting back to Python objects versus preparing data for a JSON string. For LaxOrStrictSchema, serialization defaults to using the strict_schema unless a specific serialization key is provided in the schema definition.
Composition: The IP Address Pattern
In complex real-world scenarios, these two schemas are often nested to handle all possible combinations of strictness and input format. A prime example is found in pydantic/_internal/_generate_schema.py for IP address validation:
# Simplified logic from pydantic/_internal/_generate_schema.py
return core_schema.lax_or_strict_schema(
lax_schema=core_schema.no_info_plain_validator_function(IP_VALIDATOR_LOOKUP[tp]),
strict_schema=core_schema.json_or_python_schema(
json_schema=core_schema.no_info_after_validator_function(tp, core_schema.str_schema()),
python_schema=core_schema.is_instance_schema(tp),
),
serialization=core_schema.plain_serializer_function_ser_schema(ser_ip, info_arg=True, when_used='always'),
)
In this implementation:
- Lax Mode: Uses a plain validator function that likely handles both strings and existing IP objects with coercion.
- Strict Mode:
- If the input is JSON, it requires a string and then validates it.
- If the input is Python, it requires the object to already be an instance of the IP class (
is_instance_schema).
- Serialization: A custom serializer is provided to ensure IP objects are always converted to strings, regardless of which validation branch was used.
This nested approach allows Pydantic to be extremely strict with Python types (preventing accidental coercion) while remaining flexible enough to parse valid data from JSON.