pydantic v1 vs v2 for maritime schema validation

Choosing between pydantic v1 vs v2 for maritime schema validation decides how fast — and how safely — your EDI ingest turns raw IFCSUM, VERMAS, and CODECO payloads into typed, audited records. For a pipeline that must deserialize tens of thousands of UN/EDIFACT interchanges per peak hour without ever letting a drifting ISO 6346 check digit or an over-tolerance Verified Gross Mass reach a stowage planner, the version you standardise on is not a cosmetic preference: it changes throughput budgets, validator ergonomics, and the shape of every custom maritime type you hand-roll. This guide sits inside Document Ingestion & EDI Parsing Workflows and gives shipping-ops and Python automation teams a concrete, migration-ready comparison — with real container, port, and vessel models throughout — so you can decide whether to move, when to move, and exactly how to move without dropping a manifest mid-transit.

Two validation paths from a raw IFCSUM payload into one validated container manifest A raw IFCSUM payload diverges into two lanes. The pydantic v1 lane runs an @validator, a Config inner class carrying constr and conint constraints, and a .dict() export. The pydantic v2 lane, backed by the Rust pydantic-core, runs @field_validator and @model_validator stages and a .model_dump() export. Both lanes converge on a single validated ContainerManifest model, showing that a staged migration can keep either path feeding the same downstream contract. pydantic v1 path pydantic v2 path · rust core pydantic-core (Rust) Raw IFCSUM payload @validator pre / each field class Config constr · conint .dict() .json() @field_validator mode=before/after @model_validator mode=after .model_dump() mode=json Validated ContainerManifest
The two validation call paths: the v1 lane (@validatorConfig.dict()) and the v2 lane (@field_validator@model_validator.model_dump()) both resolve into one ContainerManifest — the contract a staged migration keeps stable.

Comparison matrix

The version difference is not incremental. pydantic v2 rewrote the validation core in Rust (the pydantic-core crate), changed the validator decorators, replaced the Config inner class with model_config, and renamed the serialization surface. For a maritime gate that parses IFCSUM, VERMAS, and COARRI interchanges under a fixed acknowledgment SLA, the practical consequences line up like this:

Dimension pydantic v1 pydantic v2
Validation engine Pure-Python ValidationError construction on every field Rust pydantic-core; typically 5–50× faster parse on the same models
Field validators @validator("field", pre=True, each_item=True) @field_validator("field", mode="before") + @classmethod
Cross-field validators @root_validator(pre=…) @model_validator(mode="before" / "after")
Model config inner class Config: model_config = ConfigDict(...)
Serialization .dict(), .json() .model_dump(), .model_dump_json() (with mode="json")
Constrained scalars constr(...), conint(...) as field types Annotated[str, StringConstraints(...)], Annotated[int, Field(...)]
Custom domain types subclass + __get_validators__ Annotated[T, AfterValidator(fn)] / __get_pydantic_core_schema__
Strict typing opt-in per field, coercion-heavy by default first-class strict=True on field, model, or TypeAdapter
Parsing raw JSON bytes parse_raw() (Python decode) model_validate_json(bytes) (Rust decode, no intermediate dict)
Error detail .errors() list, Python-built .errors() with typed URLs, input, and location tuples
Coexistence native from pydantic.v1 import BaseModel shim ships inside v2
Status maintenance / security only actively developed; the default for new work

The single line that matters most for high-volume EDI ingest is the first one. Because the Async Batch Processing Pipelines that drain a carrier backlog call the validation gate once per message, a 10× faster parse is not a micro-optimisation — it is the difference between one worker pool clearing a peak-hour surge and three pools doing the same work. That gate contract itself is specified in Schema Validation Frameworks; this page only decides which pydantic version implements it.

When each version fits a maritime pipeline

Version choice is a risk-and-throughput decision, not a fashion one. A greenfield ingestion service should start on v2 unqualified: the Rust core buys headroom, strict=True removes an entire class of silent coercion bugs, and the Annotated-based custom-type model is cleaner for the ISO 6346, UN/LOCODE, and IMO-number validators a maritime schema layer is built from. There is no reason to inherit a deprecated API on new code.

An established pipeline is a different calculation. If your validation stage is CPU-bound — you can see it in the worker flame graph, and pydantic construction dominates the parse of large stowage plans — the migration pays for itself quickly, because the hot path is exactly where pydantic-core wins. If, instead, your workers are I/O-bound on registry lookups (UN/LOCODE, HS/HTS, IMO vessel data) and validation is a rounding error in the profile, the throughput argument alone does not justify the churn; you migrate for the ergonomics and the supported-version guarantee rather than for speed.

Two constraints can pin you to v1 temporarily. First, a heavy dependency that still imports pydantic<2 in its own public models — some ORM and settings libraries lagged for a full release cycle — forces coexistence rather than a clean cut. Second, a large surface of @root_validator cross-field logic (VGM-versus-summed-line-items checks, dangerous-goods segregation rules) needs careful porting to @model_validator, and rushing it risks changing validation semantics on safety-critical fields. In both cases the answer is the same: run v2 as the host runtime and reach for the pydantic.v1 shim only for the models you have not yet ported, so container-level records still resolve cleanly into the Container Hierarchy Data Models topology during the transition.

Migration playbook

The mechanical changes are small in count and large in blast radius, because every model in a maritime schema layer touches at least one of them. The four that appear in nearly every file are the validator decorator, the Config block, the serialization call, and the constrained-type imports. The worked example below is a single container manifest model, shown first as production v1 and then as the ported v2, so the diff is concrete rather than abstract.

Here is the v1 model — a ContainerManifest that validates an ISO 6346 equipment identifier, a UN/LOCODE discharge port, and a SOLAS Verified Gross Mass, logging every rejection through structlog:

from __future__ import annotations

from decimal import Decimal

import structlog
from pydantic import BaseModel, conint, constr, validator

log = structlog.get_logger("maritime.schema")

_ISO6346_LETTERS: dict[str, int] = {}
_v = 10
for _c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
    if _v % 11 == 0:
        _v += 1
    _ISO6346_LETTERS[_c] = _v
    _v += 1


def _iso6346_ok(container_id: str) -> bool:
    def cv(ch: str) -> int:
        return int(ch) if ch.isdigit() else _ISO6346_LETTERS[ch]

    total = sum(cv(ch) * (2 ** i) for i, ch in enumerate(container_id[:10]))
    digit = total % 11
    return (0 if digit == 10 else digit) == int(container_id[-1])


class ContainerManifestV1(BaseModel):
    container_id: constr(regex=r"^[A-Z]{4}\d{7}$")  # ISO 6346 BIC form
    discharge_locode: constr(regex=r"^[A-Z]{2}[A-Z2-9]{3}$")  # UN/LOCODE
    teu_count: conint(ge=1, le=2)
    verified_gross_mass_kg: Decimal

    class Config:
        anystr_strip_whitespace = True
        max_anystr_length = 20

    @validator("container_id")
    def check_iso6346(cls, v: str) -> str:
        if not _iso6346_ok(v):
            log.warning("iso6346_check_digit_drift", container_id=v)
            raise ValueError("ISO 6346 check digit mismatch")
        return v

    @validator("verified_gross_mass_kg")
    def check_vgm(cls, v: Decimal) -> Decimal:
        if v <= 0:
            raise ValueError("VGM must be positive kilograms")
        return v

The ported v2 model keeps the exact same maritime contract but adopts @field_validator with an explicit @classmethod, replaces the Config inner class with model_config, encodes the constrained scalars as reusable Annotated custom types, and turns on strict=True so a stringified TEU count from a sloppy carrier is rejected rather than silently coerced:

from __future__ import annotations

from decimal import Decimal
from typing import Annotated

import structlog
from pydantic import (
    AfterValidator,
    BaseModel,
    ConfigDict,
    Field,
    StringConstraints,
    field_validator,
)

log = structlog.get_logger("maritime.schema")


def _validate_iso6346(v: str) -> str:
    if not _iso6346_ok(v):  # same helper as the v1 module
        log.warning("iso6346_check_digit_drift", container_id=v)
        raise ValueError("ISO 6346 check digit mismatch")
    return v


# Reusable maritime domain types — declare once, apply across every model.
ContainerId = Annotated[
    str,
    StringConstraints(pattern=r"^[A-Z]{4}\d{7}$", strip_whitespace=True),
    AfterValidator(_validate_iso6346),
]
Locode = Annotated[str, StringConstraints(pattern=r"^[A-Z]{2}[A-Z2-9]{3}$", to_upper=True)]
TeuCount = Annotated[int, Field(ge=1, le=2)]


class ContainerManifestV2(BaseModel):
    model_config = ConfigDict(strict=True, str_max_length=20)

    container_id: ContainerId
    discharge_locode: Locode
    teu_count: TeuCount
    verified_gross_mass_kg: Decimal

    @field_validator("verified_gross_mass_kg")
    @classmethod
    def check_vgm(cls, v: Decimal) -> Decimal:
        if v <= 0:
            raise ValueError("VGM must be positive kilograms")
        return v

Three changes deserve emphasis for maritime code specifically. First, moving the ISO 6346 check into an AfterValidator-backed ContainerId type means every model that references an equipment identifier inherits the modulo-11 guard automatically, instead of each model re-declaring a @validator — the check-digit rule lives in one place. Second, strict=True matters more here than in a typical CRUD app: a verified_gross_mass_kg arriving as the string "21000" from an OCR-extracted bill should not be silently coerced to a number, because a coercion that hides a decimal-point error is a SOLAS reporting defect. Third, replace every .dict() with .model_dump() and every parse_raw() with model_validate_json(raw_bytes) — the latter lets the Rust core decode the interchange bytes directly, skipping the intermediate Python dict that v1 built on the way in.

Cross-field rules port from @root_validator to @model_validator(mode="after"), which in v2 receives the fully constructed model instance and returns it:

from pydantic import model_validator


class StowageSummaryV2(BaseModel):
    declared_gross_kg: Decimal
    summed_line_items_kg: Decimal

    @model_validator(mode="after")
    def reconcile_weights(self) -> "StowageSummaryV2":
        drift = abs(self.declared_gross_kg - self.summed_line_items_kg)
        if drift > Decimal("50"):
            log.error(
                "vgm_line_item_mismatch",
                declared=str(self.declared_gross_kg),
                summed=str(self.summed_line_items_kg),
            )
            raise ValueError("declared VGM does not reconcile with summed line items")
        return self

Coexistence with the pydantic.v1 shim

You rarely migrate every model in one commit, and you should not try. pydantic v2 ships the entire v1 API under pydantic.v1, so a service can run the v2 runtime and Rust core while a handful of not-yet-ported models keep their old semantics untouched:

from pydantic import BaseModel as BaseModelV2      # the Rust-backed v2 model
from pydantic.v1 import BaseModel as BaseModelV1   # legacy semantics, unchanged


class VermasDeclaration(BaseModelV1):
    """Left on v1 until its @root_validator VGM logic is audited and ported."""

    weighbridge_ticket: str
    verified_gross_mass_kg: float

The one rule that keeps this safe: never mix a v1 model and a v2 model in the same nested schema. A v2 model cannot embed a pydantic.v1 model as a field type and validate it natively — the two cores do not share a schema representation. Keep the boundary at whole-model granularity, convert at the seam with an explicit .dict()model_validate() hop, and port leaf models before the aggregates that contain them. The migration order that causes the least churn is always bottom-up: equipment and party models first, then the manifests and stowage summaries that compose them.

Step-by-step Implementation Guide

The sequence below is a staged migration a running ingestion service can execute without a flag-day cutover. Each step is independently shippable and leaves the pipeline green.

Step 1 — Pin, branch, and run the automated codemod

Install pydantic v2 on a branch and run the official bump-pydantic codemod, which mechanically rewrites the high-frequency changes (Configmodel_config, constr/conint usages, .dict().model_dump()). Treat its output as a first draft, not a finished port.

pip install "pydantic>=2.6" bump-pydantic
bump-pydantic maritime_schema/

Step 2 — Port validators and turn on strict mode per model

Convert each @validator to @field_validator with an explicit @classmethod, each @root_validator to @model_validator(mode="after"), and add model_config = ConfigDict(strict=True) on safety-critical models so weight and count fields stop coercing silently.

from pydantic import BaseModel, ConfigDict, field_validator


class EquipmentDetails(BaseModel):
    model_config = ConfigDict(strict=True)

    container_id: str

    @field_validator("container_id")
    @classmethod
    def enforce_iso6346(cls, v: str) -> str:
        if not _iso6346_ok(v):
            raise ValueError("ISO 6346 check digit mismatch")
        return v

Step 3 — Extract reusable maritime domain types

Promote the repeated constr/conint patterns into Annotated types — ContainerId, Locode, ImoNumber, VgmKilograms — so the ISO 6346, UN/LOCODE, and IMO-number rules are declared once and applied everywhere. This is where the v2 model earns its keep across a large schema.

from typing import Annotated

from pydantic import AfterValidator, StringConstraints


def _imo_check(v: str) -> str:
    # IMO number: 7 digits, last is a weighted-sum check digit (weights 7..2).
    digits = v.removeprefix("IMO").strip()
    if len(digits) != 7 or not digits.isdigit():
        raise ValueError("IMO number must be seven digits")
    total = sum(int(d) * w for d, w in zip(digits[:6], range(7, 1, -1)))
    if total % 10 != int(digits[6]):
        raise ValueError("IMO number check digit mismatch")
    return digits


ImoNumber = Annotated[str, AfterValidator(_imo_check)]
Locode = Annotated[str, StringConstraints(pattern=r"^[A-Z]{2}[A-Z2-9]{3}$", to_upper=True)]

Step 4 — Replace the serialization and parsing surface

Swap every .dict() for .model_dump(), every .json() for .model_dump_json(), and every parse_raw() for model_validate_json() so the Rust core decodes interchange bytes without an intermediate Python dict. Audit any code that relied on .dict() returning plain enums or Decimal — v2 model_dump(mode="json") serialises them differently.

raw: bytes = broker.next_payload()
manifest = ContainerManifestV2.model_validate_json(raw)   # Rust decode + validate
audit.write(manifest.model_dump(mode="json"))             # JSON-safe audit record

Step 5 — Gate the cutover on a differential replay

Before deleting the v1 models, replay a captured sample of real carrier interchanges through both versions and diff the validated output field by field. Any divergence is a semantic change to investigate — usually a coercion the v1 model allowed and strict=True now rejects — not a bug to paper over.

def diff_versions(raw: bytes) -> dict[str, object]:
    v1 = ContainerManifestV1.parse_raw(raw).dict()
    v2 = ContainerManifestV2.model_validate_json(raw).model_dump(mode="json")
    delta = {k: (v1.get(k), v2.get(k)) for k in v1 | v2 if v1.get(k) != v2.get(k)}
    if delta:
        log.warning("version_output_divergence", fields=list(delta))
    return delta

Common pitfalls

Symptom Root cause Fix
@validator silently ignored after upgrade v2 no longer honours the v1 decorator Rename to @field_validator and add @classmethod under it
Cross-field rule never runs @root_validator removed in v2 Port to @model_validator(mode="after") returning self
AttributeError: 'Config' inner class Config no longer read Replace with model_config = ConfigDict(...)
.dict() raises AttributeError method renamed Use .model_dump() / .model_dump_json()
String "21000" accepted as VGM v1-style coercion still on Set model_config = ConfigDict(strict=True) on weight fields
constr(regex=...) import error regex= renamed and constr discouraged Use StringConstraints(pattern=...) inside Annotated
Nested v1 model won’t validate in a v2 model the two cores share no schema Keep the v1/v2 boundary at whole-model granularity; convert at the seam
Enums/Decimals serialise differently in audit model_dump() default mode="python" Call model_dump(mode="json") for JSON-safe audit records
ISO 6346 ID ending in 0 rejected modulo-11 remainder of 10 encoded as 0 not handled Map remainder 10 → 0 before comparing to the declared digit

Up: Document Ingestion & EDI Parsing Workflows — the parent framework governing ingestion, schema, and resilience contracts.