Skip to main content

Conditional and Logical Schemas

Logical schemas in pydantic-core provide the infrastructure for complex validation branching, sequential processing, and fallback logic. These schemas, defined in pydantic-core/python/pydantic_core/core_schema.py, allow developers to compose simple validators into sophisticated data structures.

Basic Logical Branching

The most fundamental logical schemas are UnionSchema and NullableSchema, which allow a value to match one of several possible types.

Union Matching Modes

UnionSchema supports two primary validation modes via the mode field:

  • smart (default): The validator attempts to find the "best" match among the choices. It evaluates all choices and selects the one that results in the fewest validation errors.
  • left_to_right: The validator checks choices in the order they are defined and returns the first successful validation result.
from pydantic_core import core_schema

# A union that accepts either an integer or a string
schema = core_schema.union_schema(
choices=[
core_schema.int_schema(),
core_schema.str_schema(),
],
mode='smart'
)

Nullable Values

NullableSchema is a specialized wrapper that allows a value to be None. While this can be achieved with a UnionSchema, NullableSchema is more explicit and optimized for this specific use case.

# Equivalent to Union[str, None]
schema = core_schema.nullable_schema(schema=core_schema.str_schema())

Discriminated Unions

For complex unions, especially those involving multiple TypedDict schemas, TaggedUnionSchema is the preferred approach. It uses a "discriminator" (often a LiteralSchema) to determine which schema to use without attempting to validate against every choice.

Using Tagged Unions

A TaggedUnionSchema requires a discriminator which can be a field name, a path to a field, or a callable. This makes validation significantly faster and provides clearer error messages when the tag is missing or invalid.

As seen in pydantic-core/python/pydantic_core/core_schema.py:

from pydantic_core import SchemaValidator, core_schema

apple_schema = core_schema.typed_dict_schema(
{
'foo': core_schema.typed_dict_field(core_schema.str_schema()),
'bar': core_schema.typed_dict_field(core_schema.int_schema()),
}
)
banana_schema = core_schema.typed_dict_schema(
{
'foo': core_schema.typed_dict_field(core_schema.str_schema()),
'spam': core_schema.typed_dict_field(
core_schema.list_schema(items_schema=core_schema.int_schema())
),
}
)

# Use the 'foo' field to decide between apple and banana
schema = core_schema.tagged_union_schema(
choices={
'apple': apple_schema,
'banana': banana_schema,
},
discriminator='foo',
)

v = SchemaValidator(schema)
# Validates as an 'apple' because foo='apple'
assert v.validate_python({'foo': 'apple', 'bar': '123'}) == {'foo': 'apple', 'bar': 123}

Sequential Validation with ChainSchema

ChainSchema allows for a pipeline of validation steps. The output of one step is passed as the input to the next. This is commonly used to transform data (e.g., parsing a string into a complex object) after initial type validation.

In pydantic-core/tests/validators/test_chain.py, ChainSchema is used to convert a string to a Decimal:

from decimal import Decimal
from pydantic_core import SchemaValidator, core_schema as cs

validator = SchemaValidator(
cs.chain_schema(
steps=[
cs.str_schema(),
cs.with_info_plain_validator_function(lambda v, info: Decimal(v))
]
)
)

assert validator.validate_python('1.44') == Decimal('1.44')

Contextual and Mode-Based Schemas

pydantic-core provides schemas that branch logic based on the validation context or mode.

Lax vs. Strict Validation

LaxOrStrictSchema allows defining different logic for strict and lax validation modes. This is useful when you want to allow type coercion in lax mode but require exact types in strict mode.

JSON vs. Python Validation

JsonOrPythonSchema allows for different validation logic depending on whether the input is a Python object or a JSON string. This is useful for types that have different representations in JSON (like dates or UUIDs).

Default Values and Error Handling

WithDefaultSchema wraps another schema to provide a default value if the input is missing. It also provides advanced error handling through the on_error parameter.

Omission on Error

A powerful feature of WithDefaultSchema is on_error='omit'. When used within a TypedDict or a collection, it allows the validator to simply drop the field or item if it fails validation, rather than raising a ValidationError.

Example from pydantic-core/tests/validators/test_with_default.py:

v = SchemaValidator(
core_schema.typed_dict_schema(
fields={
'x': core_schema.typed_dict_field(schema=core_schema.str_schema()),
'y': core_schema.typed_dict_field(
schema=core_schema.with_default_schema(
schema=core_schema.str_schema(),
on_error='omit'
),
required=False,
),
}
)
)

# 'y' is an integer (invalid for str_schema), so it is omitted from the result
assert v.validate_python({'x': 'x', 'y': 42}) == {'x': 'x'}

This mechanism is essential for implementing "best-effort" parsing where invalid optional fields should not crash the entire validation process.