Pydantic Field Constraints: Length, Regex and Ranges
In the previous lesson, types told Pydantic what kind of value a field holds: text, a number, a date. But real data needs more rules. A name can't be one letter long, an age can't be 500, a mobile number must have 10 digits.
That's what Field() is for: a type says WHAT a value is; Field() adds RULES for the value.
name: str = Field(min_length=2, max_length=50)
age: int = Field(ge=18, le=60) # ge = greater or equal
pin: str = Field(pattern=r"^\d{6}$") # regex
This lesson follows the file 02_field_constraints.py. The file uses a small helper so each example prints either the model or its errors:
def show_errors(model_class, **data):
"""Try to create the model and print each error (helper for this lesson)."""
try:
print("OK ->", model_class(**data))
except ValidationError as error:
for item in error.errors():
print(f" {item['loc']}: {item['msg']}")
1. Required, default and default_factory
from datetime import datetime
from pydantic import BaseModel, Field
class Note(BaseModel):
title: str = Field() # required (same as title: str)
body: str = Field(default="") # default value
tags: list[str] = Field(default_factory=list) # a NEW empty list for every Note
created_at: datetime = Field(default_factory=datetime.now) # function called for every new Note
priority: int = Field(default=3, description="1 = high, 5 = low", examples=[1, 3])
print(Note(title="Shopping"))
print(Note(title="Meeting", priority=1, tags=["work"]))
Field()with no default means the field is required.default=gives a fixed default value.default_factory=takes a function that is called every time a new object is created.datetime.now(without brackets!) gives each Note its own creation time. If you wrotedefault=datetime.now(), every note would get the same time: the moment the class was defined.descriptionandexamplesdon't change validation. They are documentation, and FastAPI shows them on its/docspage.
2. String rules: min_length, max_length, pattern
from pydantic import EmailStr
class Student(BaseModel):
name: str = Field(min_length=2, max_length=50)
roll_no: str = Field(pattern=r"^STU-\d{4}$") # like STU-0042
mobile: str = Field(pattern=r"^[6-9]\d{9}$") # 10 digits starting 6-9
pin_code: str = Field(pattern=r"^\d{6}$") # 6 digits
email: EmailStr # real e-mail check (needs email-validator)
show_errors(Student, name="Riya", roll_no="STU-0042", mobile="9876543210", pin_code="411001",
email="riya@example.com")
print("\nWrong values:")
show_errors(Student, name="R", roll_no="42", mobile="12345", pin_code="4110", email="riya.example.com")
Reading the regex patterns in simple words:
| Pattern | Meaning | Valid example |
|---|---|---|
^STU-\d{4}$ | Starts with "STU-", then exactly 4 digits, then the end | STU-0042 |
^[6-9]\d{9}$ | First digit 6, 7, 8 or 9, then 9 more digits (an Indian mobile number) | 9876543210 |
^\d{6}$ | Exactly 6 digits (an Indian PIN code) | 411001 |
^ means "start of the text" and $ means "end of the text". Without them, \d{6} would also match the first six digits of "4110019999". The r"..." before the string makes it a raw string, so the backslash in \d is kept as it is.
EmailStr is a ready-made type that does a real e-mail check. It needs the extra package, which is why requirements.txt installs pydantic[email].
3. Number rules: gt, ge, lt, le, multiple_of, decimal places
from decimal import Decimal
class Product(BaseModel):
name: str = Field(min_length=1)
price: Decimal = Field(gt=0, max_digits=8, decimal_places=2) # 0 < price, like 123456.78
discount_pct: float = Field(default=0, ge=0, le=90) # 0 <= discount <= 90
quantity: int = Field(ge=1, le=1000)
pack_size: int = Field(default=1, multiple_of=6) # 6, 12, 18 ...
rating: float | None = Field(default=None, ge=1, le=5)
show_errors(Product, name="Pen", price="25.50", quantity=10, pack_size=12, rating=4.5)
print("\nWrong values:")
show_errors(Product, name="", price="0", discount_pct=95, quantity=0, pack_size=10, rating=6)
show_errors(Product, name="Pen", price="25.505", quantity=1) # 3 decimal places
| Rule | Meaning | Example |
|---|---|---|
gt | greater than | gt=0: 0 is not allowed, 0.01 is |
ge | greater than or equal | ge=1: 1 is allowed |
lt | less than | lt=100: 100 is not allowed |
le | less than or equal | le=90: 90 is allowed |
multiple_of | must divide exactly | multiple_of=6: 12 yes, 10 no |
max_digits, decimal_places | size of a Decimal | max_digits=8, decimal_places=2: up to 123456.78 |
Notice that the first wrong call shows six errors in one go. A user filling in a form sees every mistake at once instead of fixing them one by one.
4. List rules
from typing import Annotated
class Order(BaseModel):
items: list[str] = Field(min_length=1, max_length=5) # 1 to 5 items
coupon_codes: list[Annotated[str, Field(max_length=10)]] = [] # a rule for EACH item
show_errors(Order, items=["Pen", "Book"])
show_errors(Order, items=[])
show_errors(Order, items=["Pen"], coupon_codes=["SAVE10", "THIS-CODE-IS-TOO-LONG"])
- On a list,
min_length/max_lengthcount the items, not characters. - To put a rule on each item, put the rule inside the list type:
list[Annotated[str, Field(max_length=10)]]. - The error location
('coupon_codes', 1)means "the item at index 1 of coupon_codes", which is the second code. Pydantic tells you exactly which item is wrong.
5. Reusable types with Annotated
If many models need the same rule (every name 2 to 50 characters, every mobile number 10 digits), don't repeat the Field(). Give the rule a name with Annotated:
Name = Annotated[str, Field(min_length=2, max_length=50)]
Mobile = Annotated[str, Field(pattern=r"^[6-9]\d{9}$")]
Percent = Annotated[float, Field(ge=0, le=100)]
class Teacher(BaseModel):
first_name: Name
last_name: Name
mobile: Mobile
attendance: Percent
class Parent(BaseModel):
name: Name
mobile: Mobile
show_errors(Teacher, first_name="Anil", last_name="Joshi", mobile="9123456789", attendance=96.5)
show_errors(Parent, name="S", mobile="000")
Annotated[type, rules] means "this type, with these extra rules". Now Name and Mobile work like your own types. If the business changes a rule later, you change it in one place, and every model that uses it gets the new rule.
6. Ready-made constrained types
Pydantic already has types for the most common rules:
from datetime import date
from pydantic import PastDate, PositiveInt
class Account(BaseModel):
user_id: PositiveInt # same as int = Field(gt=0)
birth_date: PastDate # must be before today
email: EmailStr
show_errors(Account, user_id=5, birth_date="1995-06-15", email="a@b.com")
show_errors(Account, user_id=0, birth_date=date(2099, 1, 1), email="a@b.com")
Others you may meet: NegativeInt, NonNegativeInt, PositiveFloat, FutureDate, HttpUrl and SecretStr (we use SecretStr in the settings lesson).
7. strict=True: no automatic conversion
Sometimes you don't want Pydantic's friendly conversion. For a payment amount, maybe only a real number is acceptable, not text:
class Payment(BaseModel):
amount: int = Field(strict=True) # "500" is NOT accepted, only 500
reference: str
show_errors(Payment, amount=500, reference="TXN1")
show_errors(Payment, amount="500", reference="TXN1")
With strict=True the value must already have the exact type. Use it only where you really need it; for data from forms and URLs (which is always text), the normal "lax" mode is what you want.
8. See all the rules as JSON Schema
Every Pydantic model can describe its own rules in the standard JSON Schema format:
import json
print(json.dumps(Product.model_json_schema()["properties"], indent=2))
You don't usually call this yourself, but it is important: FastAPI uses this JSON Schema to build its interactive /docs page. Every Field() rule you write automatically appears in your API documentation.
Exercises
From the end of 02_field_constraints.py:
- Make a model
BankAccountwith:account_no: exactly 12 digits (pattern)ifsc: like "SBIN0001234" (pattern^[A-Z]{4}0[A-Z0-9]{6}$)balance: Decimal,ge=0, 2 decimal placesholder: 3 to 60 characters
- Create a reusable type
Pan = Annotated[str, Field(pattern=r"^[A-Z]{5}\d{4}[A-Z]$")]and use it in two different models. - Make a model
Examwithmarks: list[int]where every mark is between 0 and 100 (hint:list[Annotated[int, Field(ge=0, le=100)]]) and at least 3 marks.
Summary
Field()adds rules on top of the type: length, pattern, ranges and more.- Use
default_factoryfor values that must be new each time (lists, the current time). - Strings:
min_length,max_length,pattern. Numbers:gt,ge,lt,le,multiple_of,decimal_places. - For a rule on each list item, use
list[Annotated[type, Field(...)]]. - Create reusable types with
Annotated; use ready-made ones likeEmailStr,PositiveInt,PastDate. strict=Trueturns off automatic conversion; every rule appears in the JSON Schema used by FastAPI.