Verified against Claude Code · 2026-07-25
Design Pydantic v2 models that validate a real domain, not just shapes
A prompt for designing Pydantic v2 models with field constraints, cross-field validators, and deliberate optional/required decisions from a description of real-world business rules, instead of models that just describe field names and types.
The prompt
Ready to copy — highlighted parts are example details you can swap.
Design Pydantic v2 models for the domain below. The goal is models that reject bad data at the boundary, not models that just describe field names and types and hope validation happens somewhere else.
DOMAIN
A booking for a rental property, plus the guest making it
BUSINESS RULES TO ENFORCE
check-out date must be after check-in date; a booking under 2 nights requires a minimum-stay override flag
CROSS-MODEL RULE
number of guests can't exceed the property's max_occupancy, which lives on a separate Property record
REQUIREMENTS
1. Use Pydantic v2 syntax specifically: model_config = ConfigDict(...), not the v1-style class Config. Use Field(...) constraints (gt, max_length, pattern, etc.) for anything checkable declaratively, before reaching for a custom validator.
2. Use @field_validator for single-field logic that Field() can't express, and @model_validator(mode="after") for anything depending on more than one field within the same model (e.g. end_date must be after start_date). Name which business rule each validator enforces, in a comment.
3. If number of guests can't exceed the property's max_occupancy, which lives on a separate Property record depends on data outside this one model — a lookup against another record, like guest_count against a property's max_occupancy stored elsewhere — say explicitly that this cannot be a Pydantic validator alone and belongs at the service layer, rather than silently faking it or silently dropping it.
4. Be deliberate about Optional versus required — a field should only be optional if the domain genuinely allows it to be absent, not because it's convenient during construction. Justify each optional field in one line.
5. Use Annotated[...] types for any constraint you'd otherwise repeat across multiple models, instead of copy-pasting the same Field(...) arguments everywhere.
6. Serialization: API is camelCase JSON (checkInDate), Python code should stay snake_case. If any field needs a different name on the wire than in Python, use alias or an AliasGenerator and set populate_by_name correctly, rather than manually renaming keys after the fact.
7. Give every model a docstring stating what real-world entity it represents and what invalid state it's specifically guarding against.
8. Every raised ValueError inside a validator must produce a message that names the actual rule violated and, where useful, the offending value — a validator that just raises ValueError("invalid") forces the caller to re-derive which of several possible rules actually failed, defeating the point of having named, separate business rules in the first place.
9. If a field represents money, a quantity with units, or anything else where a plain float or int silently permits a nonsensical value (a negative price, a fractional item count where only whole units make sense), constrain it precisely with Field(...) rather than a bare numeric type that happens to also accept the invalid range.
OUTPUT FORMAT
1. The models, in dependency order.
2. A table: business rule, and which model, validator, or "service layer, not Pydantic" enforces it.
3. One example payload that should raise ValidationError, and what the error tells the caller.
4. Confirmation that every validator's error message names the specific rule it enforces, not a generic "invalid" string.Customize
Optional — swap in your own details for the highlighted parts above.
Why this works
Requiring Pydantic v2 syntax by name matters because a large share of tutorials and training data still show v1 patterns (class Config, the bare @validator decorator), and an underspecified prompt will produce a plausible-looking mix of both APIs that fails at import time on a v2-only install, or worse, silently ignores a v1-style validator that v2 no longer calls the way v1 did. Requiring a one-line justification for every Optional field directly counters the reflexive habit of marking everything Optional to avoid construction friction during development, which quietly defeats the entire point of using Pydantic for validation — a field that's "optional" only because it was annoying to require lets genuinely missing data flow silently downstream into code that assumes it's there. The cross_model_rule field is the one that most separates a model design that actually understands Pydantic's boundaries from one that fakes it: a single model's validator, field or model level, can only see that model's own fields, so a rule like guest count against a property's max_occupancy stored on a different record literally cannot be expressed inside BookingCreate's own validators — a prompt that doesn't force this distinction reliably gets back a model that either silently omits the check or hallucinates a way to "validate" it that doesn't actually run against real data. Separating @field_validator from @model_validator(mode="after") by exact use case, rather than leaving the choice to the model, mirrors this same real API constraint one level down: a single-field validator cannot see other fields on the same model either, so a cross-field rule like end date after start date has to live in the model-level validator or it genuinely cannot be expressed at all, regardless of how the prompt is worded. Requiring every raised ValueError to name the specific rule it enforces closes a gap that's easy to miss when writing validators quickly: Pydantic surfaces every validator's raised message directly in the resulting ValidationError, so a vague message doesn't just look sloppy in the source, it actually degrades what the API caller receives back, turning a structured, actionable 422 response into one that tells a client "something about this payload is wrong" without saying what.
What you get back
class BookingCreate(BaseModel): """A guest's request to book a property for a date range.""" model_config = ConfigDict(populate_by_name=True) check_in: date = Field(alias="checkInDate") check_out: date = Field(alias="checkOutDate") guest_count: int = Field(gt=0) override_min_stay: bool = False # optional: only relevant when a stay is under the minimum @model_validator(mode="after") def check_out_after_check_in(self) -> "BookingCreate": # enforces: check-out must be after check-in if self.check_out <= self.check_in: raise ValueError("check_out must be after check_in") return self Rule table: "check-out after check-in" -> check_out_after_check_in model_validator; "guest_count <= property.max_occupancy" -> service layer, not Pydantic, since it requires a database lookup against a separate Property record that this model has no access to. Invalid payload: {"checkInDate": "2026-09-10", "checkOutDate": "2026-09-09", "guestCount": 2} raises ValidationError: "check_out must be after check_in" — tells the caller exactly which rule failed, not just that the payload is invalid.
Verified against
Claude Code Sonnet 4.6 · 2026-07-25
ChatGPT GPT-5.1 · 2026-07-26
Changelog
- 2026-07-26 — Initial publish, verified against Claude Code (Sonnet 4.6) and ChatGPT (GPT-5.1) on Pydantic 2.9.
Need this built into your business?
If a prompt isn't enough — custom software, built and maintained for you — that's Scult's day job.
EXPLORE CUSTOM SOFTWARE
