Field Constraints

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

A type tells Pydantic what kind of value to expect. It says nothing about what the value should mean. An integer can be negative. A string can be empty. A list can hold a thousand items. For most real data, the type is only the first half of the rule.

Field constraints are the second half. You attach limits to a field with Field, and Pydantic enforces them during validation. A price must be above zero. A username must be between three and twenty characters. A tag list must hold at least one item. The rules sit next to the field, so anyone reading the model can see them.

This article covers the constraints by kind. The number constraints handle ranges and multiples. The string constraints handle length and patterns. Collections have their own limits on size. The last section looks at how constraints combine with default values, and where a default can quietly break a rule you thought you had set. Each constraint also ends up in the JSON Schema, so your documentation stays in step with your checks.

Read it with a real model in mind, such as a form you already have. You will find that most of the rules you currently check by hand can move into the field itself, where they are shorter and easier to see.

minutes: int is a weak statement. It permits 0, -5000. 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. 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 all who doesn't open that function. A constraint on the field is part of the model's meaning. It appears in the made schema, the API docs 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 checks, named for the ops: gt, ge, lt, le. There's no eq. That is what a Literal is for.

multiple_of constrains the step. This Is more handy 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 int field is worth a moment. They accept just the same values. They say other things. gt=0 says *plus*. ge=1 says *at least one*. Pick the one that matches the sentence you'd say out loud. The error message the caller sees is made from it.

The string constraints

min_length and max_length bound the number of characters.

pattern takes a regular formula, and there's one detail that catches people. It's a *match from the start*, not a full-string search. pattern=r"[a-z]+" will happily accept "abc123!!!". 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. It fails in the permissive direction. Letting bad values through rather than rejecting good ones. Which is just the way a check bug lives longest.

Keep patterns simple. A regular formula that needs a comment is often better as a field_validator. Here You can name the rule and write a clear message. There's 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 handy one. "This list must not be empty" is a real rule that would else cost you a checker. It makes a better error than a downstream IndexError.

Constraints and defaults

Field carries the default and the rules:

minutes: int = Field(default=10, gt=0, le=180)

A field with constraints and no default is still needed. Field(gt=0) doesn't make anything optional. This trips people who read Field(...) as being like a default.

For mutable or found 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 "needed". It still works. Simply omitting the default is clearer.

Order of operations

Constraints run after coercion, and knowing that drops a class of confusion.

A field declared int = Field(gt=0) given the string "5" first turns the int 5, then is checked against zero. The compare 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 couldn't become an int at all, greater_than if it became one and was too small. Your error handling gets both for free. They mean truly other things to a caller.

Metadata is documentation

Field also carries title, description and examples. They change no action whatsoever. They're the highest-value thing in this module.

They land in model_json_schema(). If you're using FastAPI, that schema *is* your API docs. 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, show the fields whose meaning isn't 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 alone. Anything that depends on another field. An end date after a start date, a discount not exceeding a price. Can't be expressed here. A field constraint can't see its siblings. That is what model_validator is for.

Anything needing a lookup — does this track exist, is this name taken — also doesn't belong here. A model should be able to validate without touching a DB. Keep I/O-dependent rules in the layer that owns the I/O.

And a constraint isn't 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's clearer, it makes a better error. It appears in the schema as an enumeration a client can render as a dropdown.

Annotated: the other spelling

all in this module can also be written with Annotated. 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 the same way for a simple field. The gap appears when a default is involved, and it's a real gain:

minutes: Annotated[int, Field(gt=0, le=180)] = 10

Here the constraint lives with the type and the default sits where defaults most times sit. In the = Field(default=10, gt=0) form the two are tangled together in one call. It's easy to misread which part is the default.

The bigger win is reuse. This The next section is about.

Constrained types you can name

Because Annotated makes a type, you can give it a name and use it all over:

Minutes = Annotated[int, Field(gt=0, le=180)]
Slug = Annotated[str, Field(pattern=r"^[a-z0-9_]+$")]

class Module(BaseModel):
    slug: Slug
    minutes: Minutes = 10

class Lesson(BaseModel):
    slug: Slug
    minutes: Minutes

This is the single highest-value habit in this module. The rule for what a slug is now exists once. Change it and each model that uses it changes. Without this, the same regular formula gets copied into six models and four of them are updated when it changes.

It also raises the reading. slug: Slug says what the field is. slug: str = Field(pattern=r"^[a-z0-9_]+$") makes the reader parse a regular formula to find out.

Pydantic ships some of these ready-made. PositiveInt, NonNegativeInt, PositiveFloat, StrictStr and others. And they're worth using where they fit, for the same reason.

Strictness on a single field

Whole-model strictness is a blunt instrument. often 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 often a sign that a thing upstream is confused. Quietly converting it hides that.

The Annotated form works too: Annotated[int, Field(strict=True)]. This Can then be named and reused like any other constrained type.

More than one constraint, and how failures report

A field can carry a few constraints. They're all checked. If more than one fails, you get more than one error entry for that field:

title: str = Field(min_length=3, max_length=10, pattern=r"^[A-Z]")

Given "ab", both min_length and pattern fail. Both appear. That is worth knowing when you build the field-to-messages mapping given 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's no point checking a value against zero when it never became a number. So a field makes either one *_parsing error or one or more constraint errors, never both.

What the schema does with them

each constraint has a JSON Schema equal, and Pydantic emits it:

gt turns exclusiveMinimum, ge turns min, lt turns exclusiveMaximum, le turns max. min_length and max_length become minLength/maxLength for strings and minItems/maxItems for arrays. pattern turns pattern. multiple_of turns multipleOf.

This is why constraints are better than checkers when either would do. A field_validator that checks v > 0 is invisible to the schema. The docs says "int", the client-side form has no idea. The made client will happily send -1. Field(gt=0) appears in the docs as a documented min. 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. Reach for a checker only when it can't.

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's the wrong tool.

track: str = Field(pattern=r"^(maths|python|dsa|ml)$")     # works
track: Literal["maths", "python", "dsa", "ml"]             # better

The second version makes a clearer error, appears in the schema as an enumeration a client can render as a dropdown. Is checked by mypy in your own code. The regular formula does none of that. It will be the thing somebody forgets to update when a fifth track is added.

A short catalogue to work from

all on hand, in one place. So You can stop looking it up.

Numbers: gt, ge, lt, le, multiple_of. Also allow_inf_nan=False for floats. This Is worth setting on anything that will be serialised to JSON. Infinity and NaN aren't valid JSON. A value that checked 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. This Apply to each string field on a model. Stripping whitespace on input is almost always the right default for form data.

sets: min_length, max_length.

Decimals: max_digits and decimal_places. This Are what you want for money and which no amount of float will give you.

all: 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've 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. The actual rules are scattered through the code that consumes it. A check here, an assertion there, an assumption one spot else that no one wrote down. Those rules still exist. They're just not anywhere you can read them. They disagree with each other more often than anyone expects.

Moving a rule into the model isn't primarily about catching bad data, though it does that. It's about having one place where the shape of your domain is stated. This Is the same reason the annotations were worth enforcing in the first place.

Questions people ask about Field Constraints

How do I require a number to be positive? Use Field(gt=0) for strictly positive or Field(ge=0) for zero and up. The same keywords, lt and le, set upper bounds.

What are the string constraints? min_length, max_length and pattern cover most cases. For example Field(min_length=3, pattern=r"^[a-z-]+$").

Why does Field(max_length=5) also work on lists? The constraint applies to anything with a length, so strings, lists, sets and dicts all honour it. The error type changes with the container, for example string_too_long or too_long.

Do constraints check the default value? No, defaults are not validated unless you set validate_default=True. A default that breaks its own rule will not raise until that option is on.

What is the difference between Field(gt=0) and PositiveInt? They enforce the same rule. PositiveInt is a reusable named type, while Field(gt=0) is attached to one field and can sit next to a description or default.

Where do constraints appear in the JSON schema? They become minimum, exclusiveMinimum, minLength, maxLength and pattern keywords, so clients and API docs see them for free.

Where to go after Field Constraints

That ends the foundations. You can define a model, know just what it will and won't convert, control what is needed, narrow values to your domain. Read the errors when a thing doesn't 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.

  1. What does `pattern=r"[a-z]+"` accept?

  2. A field is `int = Field(gt=0)` and receives `"5"`. What happens?

  3. Does `Field(gt=0)` with no default make a field optional?

  4. Why write `description=` on a field?

Cheat sheet

Field Constraints

Use Field to set min, max, regex and other constraints that close the gap between what a type allows and what your domain accepts.

PYDANTIC · vizlearn.in/pydantic/field_constraints.html

About the author

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.