Skip to content

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"]))
Output (your times will be different)
title='Shopping' body='' tags=[] created_at=datetime.datetime(2026, 10, 27, 11, 16, 58, 641613) priority=3
title='Meeting' body='' tags=['work'] created_at=datetime.datetime(2026, 10, 27, 11, 16, 58, 641673) priority=1
  • 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 wrote default=datetime.now(), every note would get the same time: the moment the class was defined.
  • description and examples don't change validation. They are documentation, and FastAPI shows them on its /docs page.

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")
Output
OK -> name='Riya' roll_no='STU-0042' mobile='9876543210' pin_code='411001' email='riya@example.com'

Wrong values:
   ('name',): String should have at least 2 characters
   ('roll_no',): String should match pattern '^STU-\d{4}$'
   ('mobile',): String should match pattern '^[6-9]\d{9}$'
   ('pin_code',): String should match pattern '^\d{6}$'
   ('email',): value is not a valid email address: An email address must have an @-sign.

Reading the regex patterns in simple words:

PatternMeaningValid example
^STU-\d{4}$Starts with "STU-", then exactly 4 digits, then the endSTU-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
Output
OK -> name='Pen' price=Decimal('25.50') discount_pct=0 quantity=10 pack_size=12 rating=4.5

Wrong values:
   ('name',): String should have at least 1 character
   ('price',): Input should be greater than 0
   ('discount_pct',): Input should be less than or equal to 90
   ('quantity',): Input should be greater than or equal to 1
   ('pack_size',): Input should be a multiple of 6
   ('rating',): Input should be less than or equal to 5
   ('price',): Decimal input should have no more than 2 decimal places
RuleMeaningExample
gtgreater thangt=0: 0 is not allowed, 0.01 is
gegreater than or equalge=1: 1 is allowed
ltless thanlt=100: 100 is not allowed
leless than or equalle=90: 90 is allowed
multiple_ofmust divide exactlymultiple_of=6: 12 yes, 10 no
max_digits, decimal_placessize of a Decimalmax_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"])
Output
OK -> items=['Pen', 'Book'] coupon_codes=[]
   ('items',): List should have at least 1 item after validation, not 0
   ('coupon_codes', 1): String should have at most 10 characters
  • On a list, min_length/max_length count 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")
Output
OK -> first_name='Anil' last_name='Joshi' mobile='9123456789' attendance=96.5
   ('name',): String should have at least 2 characters
   ('mobile',): String should match pattern '^[6-9]\d{9}$'

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")
Output
OK -> user_id=5 birth_date=datetime.date(1995, 6, 15) email='a@b.com'
   ('user_id',): Input should be greater than 0
   ('birth_date',): Date should be in the past

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")
Output
OK -> amount=500 reference='TXN1'
   ('amount',): Input should be a valid integer

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))
Output (part of it)
{
  "name": {
    "minLength": 1,
    "title": "Name",
    "type": "string"
  },
  "discount_pct": {
    "default": 0,
    "maximum": 90,
    "minimum": 0,
    "title": "Discount Pct",
    "type": "number"
  },
  "quantity": {
    "maximum": 1000,
    "minimum": 1,
    "title": "Quantity",
    "type": "integer"
  },
  "pack_size": {
    "default": 1,
    "multipleOf": 6,
    "title": "Pack Size",
    "type": "integer"
  },
  ...
}

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:

  1. Make a model BankAccount with:
    • account_no: exactly 12 digits (pattern)
    • ifsc: like "SBIN0001234" (pattern ^[A-Z]{4}0[A-Z0-9]{6}$)
    • balance: Decimal, ge=0, 2 decimal places
    • holder: 3 to 60 characters
  2. Create a reusable type Pan = Annotated[str, Field(pattern=r"^[A-Z]{5}\d{4}[A-Z]$")] and use it in two different models.
  3. Make a model Exam with marks: 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_factory for 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 like EmailStr, PositiveInt, PastDate.
  • strict=True turns off automatic conversion; every rule appears in the JSON Schema used by FastAPI.

Next: writing your own validation logic with validators.