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.
@validator → Config → .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 (Config → model_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 |
Related
- Schema Validation Frameworks — the multi-tier validation gate these pydantic models implement.
- Async Batch Processing Pipelines — the high-volume workers whose throughput the Rust core directly improves.
- Container Hierarchy Data Models — the equipment topology validated container records resolve into.
- EDIFACT vs ANSI X12 for B/L exchange — the interchange-format comparison upstream of schema validation.
- pydantic envelope validation for UN/EDIFACT interchanges — envelope-level modelling built on these version choices.
Up: Document Ingestion & EDI Parsing Workflows — the parent framework governing ingestion, schema, and resilience contracts.