Saying more about a value than its type: bounds, lengths, patterns - and the metadata that becomes your documentation.
Overview
The gap between a type and a meaning
minutes: int is a weak statement. It permits 0, -5000, and a number larger than the age of the universe in seconds. None of those are durations. The annotation captures the shape of the value and nothing about its meaning.
Field closes that gap:
minutes: int = Field(gt=0, le=180)
Now the model refuses what the domain refuses. Two things follow from that, and the second matters more than people expect.
The first is that bad data stops at the door. The second is that the rule is now written down in the one place a reader will look. A check buried in a service function is invisible to everyone who does not open that function; a constraint on the field is part of the model's definition, and it appears in the generated schema, the API documentation and your editor's tooltips.
Worth knowing
Field narrows a type. The annotation says what kind of value; Field says which values of that kind are acceptable.
Numbers: gt, ge, lt, le, multiple_of. Strings and collections: min_length, max_length. Strings also take pattern.
pattern must match from the start of the string. Anchor it with ^...$ when you mean the whole value, or a prefix match will let more through than you intended.
The first argument of Field is the default. Field(gt=0) with no default is still a required field.
description and examples land in model_json_schema(), which FastAPI renders as documentation. This is the cheapest documentation you will ever write.
Constraints run after coercion. Field(gt=0) on an int sees "5" as 5 and compares the number, not the text.
Field Constraints: Saying What You Actually Mean
A type allows far more values than your domain does. Field is where you close the gap.
A type is a weak promise
int allows every integer, including the ones your domain has no meaning for. Field is where you narrow it.
example_01.pyPydantic
Output
Number bounds
Four comparisons, named after the operators they stand for: gt, ge, lt, le. Plus multiple_of for step sizes.
example_02.pyPydantic
Output
String length and shape
min_length and max_length bound the size; pattern is a regular expression the whole value must match.
example_03.pyPydantic
Output
Collection sizes
The same length arguments work on lists, sets and dicts — useful for “at least one” rules that would otherwise be a validator.
example_04.pyPydantic
Output
Constraints and defaults together
Field carries the default as well as the rules. The first positional argument is the default; default_factory covers anything that must be computed.
example_05.pyPydantic
Output
Metadata is not decoration
description, title and examples go into the generated JSON Schema — which is what FastAPI turns into your API documentation. Writing them is writing your docs.
example_06.pyPydantic
Output
The number constraints
Four comparisons, named for the operators: gt, ge, lt, le. There is no eq, because that is what a Literal is for.
multiple_of constrains the step, which is more useful than it first appears — prices in whole pence, durations in five-minute blocks, page sizes in powers of ten.
Choosing between gt=0 and ge=1 on an integer field is worth a moment. They accept exactly the same values, but they say different things. gt=0 says *positive*; ge=1 says *at least one*. Pick the one that matches the sentence you would say out loud, because the error message the caller sees is generated from it.
The string constraints
min_length and max_length bound the number of characters.
pattern takes a regular expression, and there is one detail that catches people: it is a *match from the start*, not a full-string search. pattern=r"[a-z]+" will happily accept "abc123!!!", because the pattern matched at the beginning and nothing said it had to reach the end. If you mean the whole value, anchor it: r"^[a-z]+$".
That single omission is the most common bug in this area, and it fails in the permissive direction — letting bad values through rather than rejecting good ones — which is exactly the way a validation bug survives longest.
Keep patterns simple. A regular expression that needs a comment is usually better as a field_validator, where you can name the rule and write a clear message. There is a module on those in the next tier.
Collections
min_length and max_length work on lists, sets and dicts too, counting items rather than characters.
min_length=1 is the useful one. "This list must not be empty" is a real rule that would otherwise cost you a validator, and it produces a better error than a downstream IndexError.
Constraints and defaults
Field carries the default as well as the rules:
minutes: int = Field(default=10, gt=0, le=180)
A field with constraints and no default is still required — Field(gt=0) does not make anything optional. This trips people who read Field(...) as being like a default.
For mutable or computed defaults, default_factory belongs here too: Field(default_factory=list, max_length=8) gives you an empty list per instance and a cap on how many items it may grow to.
You may also see Field(...) with a literal ellipsis in older code. That was the v1 way of writing "required", and it still works, but simply omitting the default is clearer.
Order of operations
Constraints run after coercion, and knowing that removes a class of confusion.
A field declared int = Field(gt=0) given the string "5" first becomes the integer 5, then is compared against zero. The comparison never sees the text. Likewise a str field with min_length=3 counts characters after any string coercion has happened.
So a value can fail in two distinct ways, with two distinct error types: int_parsing if it could not become an integer at all, greater_than if it became one and was too small. Your error handling gets both for free, and they mean genuinely different things to a caller.
Metadata is documentation
Field also carries title, description and examples. They change no behaviour whatsoever, and they are the highest-value thing in this module.
They land in model_json_schema(). If you are using FastAPI, that schema *is* your API documentation — the descriptions appear next to the fields, the examples pre-fill the interactive request form, the constraints show as the documented limits. You write a sentence in the model and it appears in the docs your consumers read.
For anything with an audience beyond yourself, describe the fields whose meaning is not obvious from the name. minutes needs no description. weight badly does — of what, in what unit?
What constraints are not for
A constraint is a statement about a single value in isolation. Anything that depends on another field — an end date after a start date, a discount not exceeding a price — cannot be expressed here, because a field constraint cannot see its siblings. That is what model_validator is for.
Anything requiring a lookup — does this track exist, is this name taken — also does not belong here. A model should be able to validate without touching a database. Keep I/O-dependent rules in the layer that owns the I/O.
And a constraint is not a substitute for thinking about the type. If a field can only be one of four strings, Literal["maths", "python", "dsa", "ml"] is better than a pattern: it is clearer, it produces a better error, and it appears in the schema as an enumeration a client can render as a dropdown.
Annotated: the other spelling
Everything in this module can also be written with Annotated, and in modern Pydantic that spelling is often the better one:
from typing import Annotated
from pydantic import Field
minutes: Annotated[int, Field(gt=0, le=180)]
The two forms behave identically for a simple field. The difference appears when a default is involved, and it is a real improvement:
minutes: Annotated[int, Field(gt=0, le=180)] = 10
Here the constraint lives with the type and the default sits where defaults normally sit. In the = Field(default=10, gt=0) form the two are tangled together in one call, and it is easy to misread which part is the default.
The bigger win is reuse, which the next section is about.
Constrained types you can name
Because Annotated produces a type, you can give it a name and use it everywhere:
This is the single highest-value habit in this module. The rule for what a slug is now exists once. Change it and every model that uses it changes. Without this, the same regular expression gets copied into six models and four of them are updated when it changes.
It also improves the reading. slug: Slug says what the field is; slug: str = Field(pattern=r"^[a-z0-9_]+$") makes the reader parse a regular expression to find out.
Pydantic ships some of these ready-made — PositiveInt, NonNegativeInt, PositiveFloat, StrictStr and others — and they are worth using where they fit, for the same reason.
Strictness on a single field
Whole-model strictness is a blunt instrument. Usually the need is narrower: lax about most of a payload, exact about one field where a silent conversion would be dangerous.
user_id: int = Field(strict=True)
Now user_id="123" raises while the rest of the model still accepts strings for its numbers. Identifiers are the classic case: an id that arrives as text is usually a sign that something upstream is confused, and quietly converting it hides that.
The Annotated form works too: Annotated[int, Field(strict=True)], which can then be named and reused like any other constrained type.
More than one constraint, and how failures report
A field can carry several constraints, and they are all checked. If more than one fails, you get more than one error entry for that field:
Given "ab", both min_length and pattern fail, and both appear. That is worth knowing when you build the field-to-messages mapping described in the errors module: a field maps to a *list* of messages, not one.
Constraints are checked after coercion, and a coercion failure short-circuits the rest — there is no point comparing a value against zero when it never became a number. So a field produces either one *_parsing error or one or more constraint errors, never both.
What the schema does with them
Every constraint has a JSON Schema equivalent, and Pydantic emits it:
gt becomes exclusiveMinimum, ge becomes minimum, lt becomes exclusiveMaximum, le becomes maximum. min_length and max_length become minLength/maxLength for strings and minItems/maxItems for arrays. pattern becomes pattern. multiple_of becomes multipleOf.
This is why constraints are better than validators when either would do. A field_validator that checks v > 0 is invisible to the schema: the documentation says "integer", the client-side form has no idea, and the generated client will happily send -1. Field(gt=0) appears in the documentation as a documented minimum, and tooling that reads the schema can enforce it before a request is ever made.
The general rule: express a rule as a constraint if a constraint can express it, and reach for a validator only when it cannot.
Constraints that are really types
Finally, a check worth running on yourself. If a constraint is trying to enumerate a small set of allowed values, it is the wrong tool.
The second version produces a clearer error, appears in the schema as an enumeration a client can render as a dropdown, and is checked by mypy in your own code. The regular expression does none of that, and it will be the thing somebody forgets to update when a fifth track is added.
A short catalogue to work from
Everything available, in one place, so you can stop looking it up.
Numbers:gt, ge, lt, le, multiple_of. Also allow_inf_nan=False for floats, which is worth setting on anything that will be serialised to JSON — Infinity and NaN are not valid JSON, and a value that validated happily will fail on the way out.
Strings:min_length, max_length, pattern. Plus the config-level str_strip_whitespace, str_to_lower and str_to_upper, which apply to every string field on a model — stripping whitespace on input is almost always the right default for form data.
Collections:min_length, max_length.
Decimals:max_digits and decimal_places, which are what you want for money and which no amount of float will give you.
Everything:strict, frozen on a single field, description, title, examples, deprecated, repr=False, exclude=True.
The habit this module is really teaching
Look at the constraints you have written and ask what a reader learns from them. A well-constrained model is a specification: someone can read the class and know what the system considers a valid module, without opening a single function.
An unconstrained model is a list of types, and the actual rules are scattered through the code that consumes it — a check here, an assertion there, an assumption somewhere else that nobody wrote down. Those rules still exist. They are just not anywhere you can read them, and they disagree with each other more often than anyone expects.
Moving a rule into the model is not primarily about catching bad data, though it does that. It is about having one place where the shape of your domain is stated, which is the same reason the annotations were worth enforcing in the first place.
Next
That completes the foundations. You can define a model, know exactly what it will and will not convert, control what is required, narrow values to your domain, and read the errors when something does not fit.
The last module in this tier steps back to a question worth answering before you reach for a model at all: when a plain dataclass is the better tool.
Check yourself
0 of 4
Answer without scrolling back up.
What does `pattern=r"[a-z]+"` accept?
`pattern` matches from the start rather than requiring the whole string. Without `^...$` anchors it fails permissively, which is how this bug survives longest.
A field is `int = Field(gt=0)` and receives `"5"`. What happens?
Constraints run after coercion. That is also why the two failure modes have different error types: `int_parsing` versus `greater_than`.
Does `Field(gt=0)` with no default make a field optional?
Requiredness is decided by the presence of a default. `Field` with only constraints supplies no default, so the field stays required.
Why write `description=` on a field?
Metadata changes no behaviour but flows into `model_json_schema()`, which FastAPI renders as documentation. It is the cheapest documentation available.
Cheat sheet
Field Constraints
minutes: int is a weak statement. It permits 0, -5000, and a number larger than the age of the universe in seconds. None of those are durations. The annotation captures the shape of the value and nothing about its meaning.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.