Writing Pydantic v2 Validators for ATA Chapters and Part Numbers

Getting an ATA chapter or a part-number grammar wrong at ingestion time propagates a bad key into every downstream traceability query, so the cheapest place to catch it is the schema boundary. This page walks through reusable Pydantic v2 field and model validators that enforce the ATA 21–100 range, part-number grammar, and serial formats before a record is ever persisted, deepening the contract-authoring work described in Schema Validation & Error Handling. The emphasis is on validators you can lift into a shared library and reuse across every OEM feed.

Prerequisites & inputs

Validation assumes the raw fields have already been isolated upstream — the chapter token has been pulled from free text, the part and serial strings have been de-hyphenated into a canonical shape. If you are still recovering the chapter integer from prose like Ch. 32-41-00, handle that first in the extraction layer covered by Extracting ATA Chapter Codes with Python; the validators below assume you already hold a plausible value and need to decide whether it is admissible, not whether it is present.

The inputs to a single record are: an ata_chapter (integer, expected 21–100 per ATA iSpec 2200), an optional ata_subsystem (the two-digit section, e.g. 41 in landing-gear 32-41), a part_number string, and a serial_number. A representative record for an Airbus A320 in the G-EZAB fleet describes a flight-control actuator: chapter 27, part AS1234-56, serial SN-0099823.

Validator layering

Custom validator layeringRawfieldFieldvalidatorModelvalidatorTypedrecord

Step-by-step implementation

  1. Constrain the chapter with a typed alias. Use an Annotated[int, Field(ge=21, le=100)] so the 21–100 range is declared once and reused everywhere, rather than repeated per model.
  2. Normalize before you validate part numbers. A field_validator with mode="before" upper-cases and strips whitespace so as1234-56 and AS1234-56 both reach the grammar check in canonical form.
  3. Enforce grammar, not just length. Part numbers follow an alphanumeric-with-single-hyphen grammar; a compiled pattern rejects double hyphens, trailing separators, and embedded spaces that OCR noise introduces.
  4. Cross-check at the model level. A model_validator(mode="after") confirms the subsystem is consistent with the chapter and that a serial is present whenever the chapter denotes a serialized rotable.

The ordering matters because Pydantic runs field validators first and only invokes the mode="after" model validator once every field has been individually coerced and typed. That sequencing lets the model validator assume ata_chapter is already a bounded integer and part_number is already canonical, so a cross-field rule such as “rotables must carry a serial” never has to re-parse or re-clean anything. Keep each field validator single-purpose and push every rule that spans two or more fields into the model validator.

from __future__ import annotations

import logging
import re
from typing import Annotated, Optional

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("mro.field_validators")

# ATA iSpec 2200 assigns aircraft system chapters in the 21-100 band; chapters
# below 21 are reserved for general/administrative sections not used as system keys.
ATAChapter = Annotated[int, Field(ge=21, le=100)]

# Grammar: 3-32 chars, alphanumeric groups joined by at most single hyphens.
_PART_NUMBER = re.compile(r"^[A-Z0-9]+(?:-[A-Z0-9]+)*$")
_SERIAL = re.compile(r"^SN-\d{4,10}$")

# Chapters whose components are serialized rotables and must carry a serial number.
_SERIALIZED_ROTABLE_CHAPTERS = frozenset({27, 29, 32, 49})


class ComponentRecord(BaseModel):
    """Validated component removal/installation record keyed by ATA chapter."""

    model_config = ConfigDict(strict=True, extra="forbid")

    aircraft_registration: str = Field(pattern=r"^[A-Z0-9\-]{3,10}$")
    ata_chapter: ATAChapter
    ata_subsystem: Optional[int] = Field(default=None, ge=0, le=99)
    part_number: str = Field(min_length=3, max_length=32)
    serial_number: Optional[str] = None

    @field_validator("part_number", mode="before")
    @classmethod
    def canonicalize_part_number(cls, v: str) -> str:
        if not isinstance(v, str):
            raise ValueError("part_number must be a string")
        candidate = v.strip().upper().replace("_", "-")
        if not _PART_NUMBER.fullmatch(candidate):
            raise ValueError(f"part_number {v!r} violates alphanumeric-hyphen grammar")
        return candidate

    @field_validator("serial_number", mode="before")
    @classmethod
    def canonicalize_serial(cls, v: Optional[str]) -> Optional[str]:
        if v is None:
            return None
        candidate = v.strip().upper()
        if not _SERIAL.fullmatch(candidate):
            raise ValueError(f"serial_number {v!r} does not match SN-<digits> format")
        return candidate

    @model_validator(mode="after")
    def require_serial_for_rotables(self) -> "ComponentRecord":
        if self.ata_chapter in _SERIALIZED_ROTABLE_CHAPTERS and self.serial_number is None:
            raise ValueError(
                f"ATA {self.ata_chapter} is a serialized rotable; serial_number is mandatory"
            )
        if self.ata_subsystem is not None and self.ata_subsystem > 99:
            raise ValueError("ata_subsystem must be a two-digit section code")
        logger.info(
            "validated reg=%s ata=%02d part=%s",
            self.aircraft_registration, self.ata_chapter, self.part_number,
        )
        return self

Worked example

Validate the G-EZAB flight-control actuator: {"aircraft_registration": "G-EZAB", "ata_chapter": 27, "ata_subsystem": 30, "part_number": "as1234-56", "serial_number": "sn-0099823"}. The mode="before" validators canonicalize as1234-56 to AS1234-56 and sn-0099823 to SN-0099823. Because chapter 27 is a serialized rotable, the model validator confirms a serial is present — it is — and the record passes, emitting validated reg=G-EZAB ata=27 part=AS1234-56.

Now feed a landing-gear record that names chapter 32 but omits the serial: {"aircraft_registration": "G-EZAB", "ata_chapter": 32, "part_number": "AS7788-01"}. Chapter 32 is in the serialized-rotable set, so require_serial_for_rotables raises ATA 32 is a serialized rotable; serial_number is mandatory. A third record with ata_chapter: 105 never reaches the model validator at all — the ATAChapter constraint rejects it at the field boundary because it exceeds the iSpec 2200 upper bound. Each failure surfaces a precise location the routing layer can classify.

Edge cases & compliance gotchas

  • Ambiguous ATA suffixes. 32-41 and 3241 denote the same landing-gear subsystem, but a naive integer parse of 3241 yields an out-of-range chapter. Split the subsystem in extraction, then validate the two halves independently rather than the concatenation.
  • Part numbers with legitimate zeros. OCR sometimes reads a leading O as zero or vice versa. The grammar accepts both letters and digits, so lean on a checksum or OEM catalog cross-check downstream; the field validator only guarantees shape, not existence.
  • Strict mode surprises. ConfigDict(strict=True) means a JSON string "27" will not silently coerce to the integer 27. Coerce chapter tokens to int in extraction, or the record fails structural validation for the right reason but the wrong stage.
  • Downstream re-drive. When a record fails these validators it should land in quarantine, not vanish — the safe replay path for a corrected part number is exactly the one described in Building a Dead-Letter Queue for Invalid MRO Records.

Reusable validators pay for themselves the first time an OEM changes its part-number export format: you fix one compiled pattern instead of chasing coercion bugs across a dozen models. Declare your ATA range once as a typed alias, normalize in mode="before", and reserve the model validator for the cross-field rules that only make sense once every field is individually sound. That layering keeps each rule testable in isolation and keeps malformed keys out of the traceability ledger entirely.