Pydantic & core / Chapter 5 of 5

Custom types and core schemas

Build Pydantic custom types and pydantic-core schemas. Python examples cover SchemaValidator, SchemaSerializer, context, unions, and tests.

13 topicsUpdated
32 / 44

Custom domain types and extension hooks#

Teach Pydantic how to validate, store, serialize, and document a type your application owns.

Start with Annotated, Field, and high-level validators. When you need a distinct runtime object or an integration for a third-party class, implement __get_pydantic_core_schema__. It receives the source type and a schema-generation handler. __get_pydantic_json_schema__ separately describes its external representation.

PythonAn immutable SHA-256 digest with distinct Python and JSON behavior
from dataclasses import dataclass
from pydantic import BaseModel, TypeAdapter, ValidationError
from pydantic_core import core_schema

PATTERN = r'^[0-9a-f]{64}$'

@dataclass(frozen=True)
class Digest:
    hex: str

    @classmethod
    def __get_pydantic_core_schema__(cls, source_type, handler):
        text_schema = core_schema.str_schema(strict=True, pattern=PATTERN)
        from_text = core_schema.no_info_after_validator_function(cls, text_schema)
        # Existing objects are rechecked too, rather than trusted blindly.
        existing = core_schema.chain_schema([
            core_schema.is_instance_schema(cls),
            core_schema.no_info_plain_validator_function(lambda value: value.hex),
            from_text,
        ])
        return core_schema.json_or_python_schema(
            json_schema=from_text,
            python_schema=core_schema.union_schema([existing, from_text]),
            serialization=core_schema.plain_serializer_function_ser_schema(
                lambda value: value.hex,
                return_schema=text_schema
            )
        )

    @classmethod
    def __get_pydantic_json_schema__(cls, schema, handler):
        result = handler(core_schema.str_schema(pattern=PATTERN))
        result['description'] = 'Lowercase SHA-256 digest, 64 hexadecimal characters'
        return result

class Upload(BaseModel):
    filename: str
    digest: Digest

adapter = TypeAdapter(Digest)
digest = adapter.validate_json('"' + 'a' * 64 + '"')
upload = Upload(filename='paper.pdf', digest=digest)
print(type(upload.digest).__name__)
print(type(upload.model_dump()['digest']).__name__)
print(upload.model_dump_json())
print(adapter.json_schema()['pattern'])
assert adapter.validate_json(adapter.dump_json(digest)) == digest
for bad in ['short', Digest('broken')]:
    try:
        adapter.validate_python(bad)
    except ValidationError:
        print('Rejected:', type(bad).__name__)
TextExpected output
Digest
str
{"filename":"paper.pdf","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
^[0-9a-f]{64}$
Rejected: str
Rejected: Digest
Trace one digest through the schema

The string rule requires 64 lowercase hexadecimal characters before constructing Digest. Existing Python instances have their hex value checked again. Dumping returns that string. The frozen dataclass prevents reassignment, but its ordinary constructor does not validate the format.

This validates the digest’s format, not the contents of a file. Compute a file’s SHA-256 with your hashing code and compare it separately when integrity matters.

Schema paths

json_or_python_schema accepts a string in JSON and a string or existing object in Python. Validation constructs the domain object; serialization returns its hex value. The JSON Schema hook documents the string format.

PythonA short schema-generation hook through GetPydanticSchema
from typing import Annotated
from pydantic import BaseModel, GetPydanticSchema
from pydantic_core import core_schema

def canonical_name_schema(source_type, handler):
    return core_schema.no_info_after_validator_function(
        str.casefold, handler(source_type)
    )

CanonicalName = Annotated[str, GetPydanticSchema(canonical_name_schema)]

class SearchQuery(BaseModel):
    author: CanonicalName

print(SearchQuery(author='MIRA').author)
TextExpected output
mira
Handlers, generics, and API stability

Calling handler(source_type) preserves the underlying type’s validation and other metadata. For an unrelated generic argument, use handler.generate_schema(item_type) to avoid applying the enclosing metadata to it. Calling the handler on an unsupported custom class can recurse or fail; build its schema or delegate to a supported underlying type. Core hooks are more sensitive to API changes than the high-level helpers, so pin compatible versions and test upgrades.

Schema generation and validation

A schema hook runs when the model or adapter is prepared. Its handler(source_type) builds rules; a wrap validator’s handler(value) checks input at runtime. no_info callbacks receive a value, while with_info callbacks also receive validation information.

Official reference: Custom domain types and extension hooks.

33 / 44

pydantic-core: core_schema building blocks#

A core schema is an executable validation plan, not a public JSON Schema document.

core_schema contains helper functions that create schema dictionaries. They do not validate a value by themselves. SchemaValidator(schema) compiles the validation plan; SchemaSerializer(schema) compiles its output rules. Pydantic normally builds these plans from your type hints, so hand-author them only when your integration needs that level of control.

BuilderMeaning
str_schema / int_schema / decimal_schemaA primitive check with constraints
list_schema / dict_schema / tuple_schemaValidate container shape and item values
typed_dict_schema + typed_dict_fieldNamed dictionary fields and required/optional keys
nullable_schemaAllow None in addition to an inner schema
with_default_schemaSupply a default when a value is missing
union_schema / tagged_union_schemaTry alternatives / pick a tagged alternative
chain_schemaFeed each stage’s output into the next stage
json_or_python_schemaSelect the input-mode branch
definitions_schema + definition_reference_schemaShared or recursive schema nodes
model_schema / dataclass_schemaConstruct and manage class instances; dictionary schemas are simpler when no class is needed
PythonInspect and execute a small schema tree
from pydantic_core import SchemaValidator, core_schema

schema = core_schema.list_schema(core_schema.int_schema(gt=0), min_length=1)
print(schema)
print(SchemaValidator(schema).validate_python(['2', 3]))
TextExpected output
{'type': 'list', 'items_schema': {'type': 'int', 'gt': 0}, 'min_length': 1}
[2, 3]

The schema requires a nonempty list of positive integers. SchemaValidator compiles those rules; validate_python() applies them and converts '2' to 2. In a model, put Field(gt=0) on the item type and Field(min_length=1) on the list field.

Three different kinds of schema

A core schema can contain Python functions and classes, so it is not generally JSON-serializable. JSON Schema describes an external data contract. core_schema.json_schema(inner) means “parse a JSON-encoded value stored inside a field”; it is not a JSON Schema generator. Invalid schema definitions raise SchemaError before input validation.

Official reference: pydantic-core: core_schema building blocks.

34 / 44

SchemaValidator: direct validation#

Compile one schema and reuse it to check Python objects or raw JSON.

PythonA core-only inventory command with defaults and unknown-key checks
from pydantic_core import SchemaValidator, ValidationError, core_schema

schema = core_schema.typed_dict_schema({
    'sku': core_schema.typed_dict_field(core_schema.str_schema(pattern=r'^BK-[A-Z]+$')),
    'quantity': core_schema.typed_dict_field(core_schema.int_schema(gt=0)),
    'dry_run': core_schema.typed_dict_field(
        core_schema.with_default_schema(core_schema.bool_schema(), default=False),
        required=False
    ),
}, extra_behavior='forbid')
validator = SchemaValidator(schema)

print(validator.validate_python({'sku': 'BK-RUST', 'quantity': '2'}))
print(validator.validate_json('{"sku":"BK-RUST","quantity":2,"dry_run":true}'))
for raw in [
    {'sku': 'BK-RUST', 'quantity': 0},
    {'sku': 'BK-RUST'},
    {'sku': 'BK-RUST', 'quantity': 2, 'admin': True},
]:
    try:
        validator.validate_python(raw)
    except ValidationError as exc:
        print([(e['loc'], e['type']) for e in exc.errors()])
try:
    validator.validate_python({'sku': 'BK-RUST', 'quantity': '2'}, strict=True)
except ValidationError as exc:
    print('strict:', exc.errors()[0]['type'])
TextExpected output
{'sku': 'BK-RUST', 'quantity': 2, 'dry_run': False}
{'sku': 'BK-RUST', 'quantity': 2, 'dry_run': True}
[(('quantity',), 'greater_than')]
[(('quantity',), 'missing')]
[(('admin',), 'extra_forbidden')]
strict: int_type

validate_python returns the value described by the schema: a dictionary here, not a BaseModel. validate_json parses and validates raw JSON in one operation. validate_strings handles string-valued inputs. Call-level strict, context, and other supported options apply to the same compiled validator. isinstance_python returns whether validation can succeed; it is not a static type check and may execute callbacks.

Missing is a separate case from None

required=False allows a key to be absent. nullable_schema(...) allows explicit None. with_default_schema(...) can insert a default for missing input. Choose these separately; a nullable required field is still required.

typed_dict_schema returns dictionaries, not TypedDict classes. Each typed_dict_field defines a key’s rule; the dry-run field defaults to False. Reuse the compiled validator across calls.

Official reference: SchemaValidator: direct validation.

35 / 44

SchemaSerializer: direct Python and JSON output#

Serialize values using the same schema, after validation has established their shape.

PythonCore serialization of an aware datetime and Decimal amount
from pydantic_core import SchemaValidator, SchemaSerializer, core_schema

schema = core_schema.typed_dict_schema({
    'number': core_schema.typed_dict_field(core_schema.str_schema()),
    'amount': core_schema.typed_dict_field(core_schema.decimal_schema(gt=0)),
    'paid_at': core_schema.typed_dict_field(core_schema.datetime_schema(tz_constraint='aware')),
})
validator = SchemaValidator(schema)
serializer = SchemaSerializer(schema)
invoice = validator.validate_json(
    '{"number":"INV-7","amount":"19.90","paid_at":"2026-09-30T06:00:00Z"}'
)

python_data = serializer.to_python(invoice)
json_data = serializer.to_python(invoice, mode='json')
json_bytes = serializer.to_json(invoice)
print(type(python_data['amount']).__name__, type(python_data['paid_at']).__name__)
print(json_data)
print(type(json_bytes).__name__, json_bytes.decode())
assert validator.validate_json(json_bytes) == invoice
print(serializer.to_python(invoice, exclude={'paid_at'}, mode='json'))
TextExpected output
Decimal datetime
{'number': 'INV-7', 'amount': '19.90', 'paid_at': '2026-09-30T06:00:00Z'}
bytes {"number":"INV-7","amount":"19.90","paid_at":"2026-09-30T06:00:00Z"}
{'number': 'INV-7', 'amount': '19.90'}
Do not use serialization as a validator

to_python returns Python values; mode="json" makes them JSON-compatible. to_json returns bytes. A serializer may warn about mismatched types and does not enforce all input constraints. Validate first. Use warnings="error" to turn serialization warnings into errors when exporting data that must match its schema, while retaining validation as a separate step.

Official reference: SchemaSerializer: direct Python and JSON output.

36 / 44

Custom core validation, context, and serialization#

Compose native checks with the small amount of Python logic your domain requires.

Core offers before, after, plain, and wrap validation callbacks, with no_info_... variants or with_info_... variants that receive context and input mode. A serializer is attached under a node’s serialization key. Its behavior is separate from the validation callback.

PythonA canonical invoice reference with a policy and wire-format serializer
from pydantic_core import SchemaValidator, SchemaSerializer, PydanticCustomError, ValidationError, core_schema

def clean(value):
    return value.strip().upper() if isinstance(value, str) else value

def check_allowed(value, info):
    blocked = (info.context or {}).get('blocked', set())
    if value in blocked:
        raise PydanticCustomError('invoice_blocked', 'Invoice is blocked by the import policy')
    return value

schema = core_schema.with_info_after_validator_function(
    check_allowed,
    core_schema.no_info_before_validator_function(
        clean, core_schema.str_schema(strict=True, pattern=r'^INV-[0-9]+$')
    ),
    serialization=core_schema.plain_serializer_function_ser_schema(
        lambda value: {'reference': value},
        return_schema=core_schema.typed_dict_schema({
            'reference': core_schema.typed_dict_field(core_schema.str_schema())
        }),
        when_used='json'
    )
)
validator = SchemaValidator(schema)
serializer = SchemaSerializer(schema)
reference = validator.validate_python(' inv-42 ', context={'blocked': {'INV-99'}})
print(reference)
print(serializer.to_python(reference))
print(serializer.to_json(reference).decode())
for raw in ['bad', 'INV-99']:
    try:
        validator.validate_python(raw, context={'blocked': {'INV-99'}})
    except ValidationError as exc:
        print(exc.errors()[0]['type'])
TextExpected output
INV-42
INV-42
{"reference":"INV-42"}
string_pattern_mismatch
invoice_blocked
Different input and output shapes

The validator accepts a string; the serializer emits an object for the partner’s export format. That output cannot be parsed by the same validator. Test round trips only for reversible formats. Prefer native constraints where possible to avoid Python callback overhead.

The before callback normalizes the reference, the native string rule checks its pattern, and the after callback checks the blocked set. The serializer emits a reference object. Its return_schema describes output and does not change accepted input.

Official reference: Custom core validation, context, and serialization.

37 / 44

Different Python and JSON input behavior#

Preserve rich local objects while accepting the appropriate wire representation.

PythonRequire a real date in Python, but accept a date string in JSON
from datetime import date
from pydantic_core import SchemaValidator, SchemaSerializer, core_schema, ValidationError

schema = core_schema.json_or_python_schema(
    python_schema=core_schema.date_schema(strict=True),
    json_schema=core_schema.date_schema(strict=True)
)
validator = SchemaValidator(schema)
serializer = SchemaSerializer(schema)
day = validator.validate_python(date(2026, 9, 30))
print(validator.validate_json('"2026-09-30"'))
print(serializer.to_json(day).decode())
try:
    validator.validate_python('2026-09-30')
except ValidationError as exc:
    print(exc.errors()[0]['type'])
TextExpected output
2026-09-30
"2026-09-30"
date_type

The date schemas are both strict, but the original input mode still matters. validate_json stays in JSON mode through its branch; decoding with json.loads first and then calling validate_python is a different operation. Use json_or_python_schema for deliberately different contracts; use lax_or_strict_schema when the choice depends on strictness instead.

PythonObserve input mode in a core callback
from pydantic_core import SchemaValidator, core_schema

def report_mode(value, info):
    print(info.mode, value)
    return value

validator = SchemaValidator(core_schema.with_info_after_validator_function(
    report_mode, core_schema.int_schema()
))
assert validator.validate_python('7') == 7
assert validator.validate_json('7') == 7
TextExpected output
python 7
json 7
Strict dates and input mode

A strict Python date must already be a date object; strict JSON accepts a date string. One strict date schema handles this built-in distinction. Use json_or_python_schema when a custom type needs different branch rules.

Official reference: Different Python and JSON input behavior.

38 / 44

Core tagged unions and recursive definitions#

Build reusable schema graphs when your integration constructs schemas dynamically.

PythonA direct core tagged union for import actions
from pydantic_core import SchemaValidator, core_schema, ValidationError

add_schema = core_schema.typed_dict_schema({
    'action': core_schema.typed_dict_field(core_schema.literal_schema(['add'])),
    'quantity': core_schema.typed_dict_field(core_schema.int_schema(gt=0)),
})
remove_schema = core_schema.typed_dict_schema({
    'action': core_schema.typed_dict_field(core_schema.literal_schema(['remove'])),
    'reason': core_schema.typed_dict_field(core_schema.str_schema(min_length=1)),
})
validator = SchemaValidator(core_schema.tagged_union_schema(
    choices={'add': add_schema, 'remove': remove_schema}, discriminator='action'
))
print(validator.validate_json('{"action":"add","quantity":"3"}'))
try:
    validator.validate_python({'action': 'move'})
except ValidationError as exc:
    print(exc.errors()[0]['type'])
TextExpected output
{'action': 'add', 'quantity': 3}
union_tag_invalid
PythonA recursive dictionary tree with explicit references
from pydantic_core import SchemaValidator, SchemaSerializer, core_schema

node = core_schema.typed_dict_schema({
    'name': core_schema.typed_dict_field(core_schema.str_schema()),
    'children': core_schema.typed_dict_field(
        core_schema.with_default_schema(
            core_schema.list_schema(core_schema.definition_reference_schema('folder')),
            default_factory=list
        ), required=False
    ),
}, ref='folder')
schema = core_schema.definitions_schema(
    core_schema.definition_reference_schema('folder'), definitions=[node]
)
validator = SchemaValidator(schema)
serializer = SchemaSerializer(schema)
tree = validator.validate_python({'name': 'papers', 'children': [{'name': '2026'}]})
print(serializer.to_json(tree).decode())
assert tree['children'][0]['children'] == []
TextExpected output
{"name":"papers","children":[{"name":"2026","children":[]}]}
References are schema references, not data links

The ref name binds a schema node; it does not provide database IDs or serialize cyclic object graphs. Pydantic’s recursive BaseModel or a named type alias is usually easier to maintain. Choose core definitions when a schema-generation integration truly owns the graph.

The tagged union’s choices maps actions to schemas. For the tree, ref="folder" names a reusable schema and definition_reference_schema("folder") reuses it for children. definitions_schema supplies the definition and root.

Official reference: Core tagged unions and recursive definitions.

39 / 44

Function validation, dynamic models, and private attributes#

Apply validation beyond a conventional request class when the use case needs it.

PythonValidate a calculation function’s inputs and result
from decimal import Decimal
from typing import Annotated
from pydantic import validate_call, Field, ValidationError

@validate_call(validate_return=True)
def line_total(price: Annotated[Decimal, Field(gt=0)],
               quantity: Annotated[int, Field(gt=0)]) -> Decimal:
    return price * quantity

print(line_total('9.90', '3'))
try:
    line_total('9.90', 0)
except ValidationError as exc:
    print(exc.errors()[0]['loc'], exc.errors()[0]['type'])
TextExpected output
29.70
(1,) greater_than
PythonBuild a runtime import shape and store a private cache
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, create_model

ImportShape = create_model(
    'ImportShape', __config__=ConfigDict(extra='forbid'),
    sku=(str, ...), quantity=(int, Field(gt=0))
)
print(ImportShape.model_validate({'sku': 'BK-RUST', 'quantity': '2'}).model_dump())

class Invoice(BaseModel):
    number: str
    _cache: dict = PrivateAttr(default_factory=dict)

invoice = Invoice(number='INV-7')
invoice._cache['pdf_ready'] = True
print(invoice.model_dump())
assert '_cache' not in invoice.model_json_schema()['properties']
TextExpected output
{'sku': 'BK-RUST', 'quantity': 2}
{'number': 'INV-7'}
Private and dynamic do not mean trusted

Private attributes are not fields: they are not validated or serialized. They are convenient instance state, not a security boundary. Dynamic field definitions should come from a trusted schema source; do not evaluate arbitrary code from user input. validate_call adds work to each call, so use it at useful boundaries rather than every internal helper.

Official reference: Function validation, dynamic models, and private attributes.

40 / 44

Partial JSON and the experimental missing sentinel#

Distinguish an incomplete stream from a partial update to a complete object.

A PATCH request is complete JSON with some fields omitted. Partial JSON parsing is different: it tries to recover values from a truncated stream. pydantic_core.from_json(..., allow_partial=True) parses the available data but does not validate its domain rules. TypeAdapter partial validation is explicitly experimental and can ignore errors near the end.

PythonRecover complete items from an incomplete JSON stream
from pydantic import TypeAdapter
from pydantic_core import from_json

print(from_json('["INV-7", "INV-8", "INV-', allow_partial=True))
adapter = TypeAdapter(list[int])
print(adapter.validate_json('[1, 2, "unfinished', experimental_allow_partial=True))
TextExpected output
['INV-7', 'INV-8']
[1, 2]
PythonAn experimental sentinel for omitted timeout settings
from pydantic import BaseModel
from pydantic.experimental.missing_sentinel import MISSING

class TimeoutPatch(BaseModel):
    timeout_seconds: int | None | MISSING = MISSING

omitted = TimeoutPatch()
cleared = TimeoutPatch(timeout_seconds=None)
changed = TimeoutPatch(timeout_seconds=30)
print(omitted.model_dump(), cleared.model_dump(), changed.model_dump())
assert omitted.timeout_seconds is MISSING
TextExpected output
{} {'timeout_seconds': None} {'timeout_seconds': 30}
Keep experimental behavior out of correctness-critical boundaries

Partial validation can accept some invalid complete inputs and drop a failing final item. It is useful for previews while streaming, not for committing a finished webhook or payment. MISSING is automatically excluded from dumps, but its API and type-checker support are experimental, and models holding it do not support pickling. Use the earlier model_fields_set recipe for a stable PATCH contract.

MISSING distinguishes omission from null: omitting the timeout excludes it from dumps, None clears it, and 30 sets it. Use model_fields_set for a stable alternative.

Official reference: Partial JSON and the experimental missing sentinel.

41 / 44

Performance and validation shortcuts#

Measure your actual boundary and reuse compiled schemas before changing abstractions.

ChoiceTrade-off
Reuse a model class, TypeAdapter, or core validatorSchema construction has a cost; avoid rebuilding in a loop
Validate raw JSON with model_validate_jsonUsually avoids a separate Python JSON-decoding step; custom before/wrap validators can change the trade-off
Use concrete list/dict and tagged unionsOften simpler and faster than broad abstract containers or ambiguous unions
Use native constraintsAvoid unnecessary Rust-to-Python callbacks
TypedDict plus TypeAdapterUseful dictionary-shaped boundary when model-instance conveniences are unnecessary
Direct coreMore schema maintenance; no universal speed-up over Pydantic’s already compiled core
model_constructSkips validation and recursive construction; benchmark because ordinary validation can be equally fast or faster for simple models
SkipValidation or AnyReduces checking and weakens the contract
PythonShow why copy and construct are not input validation
from pydantic import BaseModel, Field

class Stock(BaseModel):
    quantity: int = Field(ge=0)

stock = Stock(quantity=5)
copied = stock.model_copy(update={'quantity': 'broken'})
unchecked = Stock.model_construct(quantity=-1)
print(type(copied.quantity).__name__, unchecked.quantity)
assert copied.quantity == 'broken'
assert unchecked.quantity == -1
print('Neither shortcut checked the input')
TextExpected output
str -1
Neither shortcut checked the input
State what your benchmark measures

Separate schema compilation, Python input validation, JSON parsing plus validation, and serialization. Reuse the same input corpus and include failures as well as successes. I/O, database access, and Python callbacks can dominate runtime. Do not drop checks to win a benchmark that no longer measures equivalent behavior.

Official reference: Performance and validation shortcuts.

42 / 44

Common mistakes and migration changes#

Recognize the shortcuts that change your contract or hide an invalid value.

MistakeUse instead / why
Assume T | None means optional inputGive a default if omission is allowed
Use exclude_none for PATCH changesUse exclude_unset; preserve explicit null
Assume all default values are validatedEnable validate_default where needed
Trust existing model instances without checking policyUse the model’s revalidate_instances when revalidation is required
Forget a validator returnReturn the checked value; an after validator can otherwise store None
Make validators send emails or write dataKeep validation pure; perform actions after validation
Use model_copy(update=...) for an untrusted patchMerge allowed changes and validate the resulting model
Assume strict Python and JSON inputs behave identicallyTest both paths for dates, UUIDs, and custom types
Treat frozen as deep immutabilityUse immutable inner values or protect mutation separately
Use repr=False to hide a field from JSONUse exclude=True or a public response model
Expose raw errors() to usersReturn selected paths, codes, and reviewed messages
Access model_fields on an instanceRead ModelClass.model_fields
Assume field JSON Schema examples restrict inputAdd actual constraints or validators
v1 spelling / patternv2 spelling / pattern
dict() / json()model_dump() / model_dump_json()
parse_obj / parse_rawmodel_validate / model_validate_json
schema()model_json_schema()
construct / copymodel_construct / model_copy
class Configmodel_config = ConfigDict(...)
@validator / @root_validator@field_validator / @model_validator
orm_mode / from_ormfrom_attributes=True / model_validate
__root__RootModel[T]
BaseSettings from pydanticImport from pydantic_settings
json_encoders for new codeField/model serializers or Annotated serializers
Conversion behavior also changed

V2 does not generally coerce numbers to strings or pairs-of-items into a dictionary the way v1 sometimes did. Use a deliberate parser or an explicit setting such as coerce_numbers_to_str for a real compatibility requirement. Model equality is not equality to a plain dictionary; compare a dump when that is your intent.

Official reference: Common mistakes and v1-to-v2 changes.

43 / 44

Tests for your own data boundaries#

Check accepted values, rejected values, and the data that actually leaves the application.

PythonA runnable regression suite for a small API contract
import json
import unittest
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, SecretStr, TypeAdapter, ValidationError
from pydantic_core import SchemaValidator, SchemaSerializer, core_schema

class Request(BaseModel):
    model_config = ConfigDict(extra='forbid')
    quantity: int = Field(gt=0)
    note: str | None = None
    token: SecretStr = Field(exclude=True)

class BoundaryTests(unittest.TestCase):
    def test_coercion_and_strictness(self):
        self.assertEqual(Request(quantity='2', token='demo').quantity, 2)
        with self.assertRaises(ValidationError):
            Request.model_validate({'quantity': '2', 'token': 'demo'}, strict=True)

    def test_omitted_and_null(self):
        missing = Request(quantity=2, token='demo')
        null = Request(quantity=2, token='demo', note=None)
        self.assertNotIn('note', missing.model_dump(exclude_unset=True))
        self.assertIn('note', null.model_dump(exclude_unset=True))

    def test_errors_and_sensitive_output(self):
        with self.assertRaises(ValidationError) as caught:
            Request(quantity=0, token='demo')
        self.assertEqual(caught.exception.errors()[0]['type'], 'greater_than')
        output = Request(quantity=2, token='demo').model_dump_json()
        self.assertNotIn('token', json.loads(output))
        self.assertNotIn('demo', output)

    def test_strict_date_input_modes(self):
        adapter = TypeAdapter(date)
        self.assertEqual(adapter.validate_json('"2026-09-30"', strict=True), date(2026, 9, 30))
        with self.assertRaises(ValidationError):
            adapter.validate_python('2026-09-30', strict=True)

    def test_direct_core_round_trip(self):
        schema = core_schema.list_schema(core_schema.int_schema(gt=0))
        validator, serializer = SchemaValidator(schema), SchemaSerializer(schema)
        value = validator.validate_json('[1,2,3]')
        self.assertEqual(validator.validate_json(serializer.to_json(value)), value)
        with self.assertRaises(ValidationError):
            validator.validate_python([1, 0])

suite = unittest.defaultTestLoader.loadTestsFromTestCase(BoundaryTests)
result = unittest.TextTestRunner(verbosity=0).run(suite)
if not result.wasSuccessful():
    raise SystemExit(1)
print(f'{result.testsRun} boundary tests passed')
TextExpected output
5 boundary tests passed
CaseWhat to assert
Valid and invalid inputsStored Python types and stable error codes
Strict versus coercivePython and JSON paths separately
Missing versus nullmodel_fields_set and actual patch output
Nested errors and unionsPaths include the correct field/index and selected tag
Validators and serializersRules run in the right order, preserve intended type, and shape the output
ContextTrusted policy affects validation; missing context fails safely
Sensitive valuesAbsent from public dumps and error responses
JSON SchemaInput/output properties match the actual wire representation
Core schemas and upgradesCompilation, valid/invalid input, serialization, and reversible round trips

Official reference: Tests for your own data boundaries.

44 / 44

Quick reference and official documentation#

Choose a validation tool by the shape of the input and the output you need.

NeedChoose
A normal API recordBaseModel
A list, union, or standalone domain typeTypeAdapter
A model wrapping a top-level scalar or arrayRootModel
A validated dataclasspydantic.dataclasses.dataclass
Simple reusable constraintsAnnotated[T, Field(...)]
Reusable custom logicBeforeValidator / AfterValidator / WrapValidator
A custom runtime typeA core schema hook plus serialization and JSON Schema behavior
A custom schema engineDirect core_schema + SchemaValidator + SchemaSerializer
Environment and secrets sourcespydantic-settings

Features marked “2.13” require that release or newer. Experimental APIs may change between releases. Check the installed version with python -c "import pydantic; print(pydantic.__version__)".