Mathew K Analytics

Lesson 5 · FastAPI deep dive

FastAPI Tutorial #5: Data Validation with Pydantic

Video five of the eighteen-part series: constraining and validating data far beyond a plain type hint. Field, Query, Path constraints, custom validators,…

⬇ Download notebookOpen in Colab ↗

📓 Full notebook

Download .ipynb

FastAPI Deep-Dive, Video 5: Data Validation Deep-Dive#

  • Video five of the eighteen-part series: constraining and validating data far beyond a plain type hint.
  • Field, Query, Path constraints, custom validators, and reading validation errors.
  • Let's get into it.

Part 1: Field() - Constraints on Model Fields#

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
app = FastAPI()
class Product(BaseModel):
    name: str = Field(min_length=2, max_length=50)
    price: float = Field(gt=0)
@app.post('/products')
def create_product(product: Product):
    return product.model_dump()
client = TestClient(app)
print(client.post('/products', json={'name': 'Mug', 'price': 9.99}).json())
{'name': 'Mug', 'price': 9.99}

Part 2: Constraint Violations#

response = client.post('/products', json={'name': 'M', 'price': -5})
print(response.status_code)
for err in response.json()['detail']:
    print(err['loc'], err['msg'])
422
['body', 'name'] String should have at least 2 characters
['body', 'price'] Input should be greater than 0

Part 3: Field with Default and Description#

class ProductV2(BaseModel):
    name: str = Field(min_length=2, max_length=50)
    price: float = Field(gt=0)
    quantity: int = Field(default=0, ge=0, description='Units currently in stock')
schema = ProductV2.model_json_schema()
print(schema['properties']['quantity'])
{'default': 0, 'description': 'Units currently in stock', 'minimum': 0, 'title': 'Quantity', 'type': 'integer'}

Part 4: Query() with Constraints#

from fastapi import Query
@app.get('/search')
def search(q: str = Query(min_length=2, max_length=20, pattern='^[a-zA-Z0-9 ]+$')):
    return {'q': q}
print(client.get('/search?q=fastapi').json())
print(client.get('/search?q=a').status_code)
print(client.get('/search?q=bad$chars').status_code)
{'q': 'fastapi'}
422
422

Part 5: Path() with Constraints#

from fastapi import Path
@app.get('/products/{product_id}')
def get_product(product_id: int = Path(gt=0, le=10000)):
    return {'product_id': product_id}
print(client.get('/products/42').json())
print(client.get('/products/0').status_code)
print(client.get('/products/99999').status_code)
{'product_id': 42}
422
422

Part 6: field_validator - Custom Validation Logic#

from pydantic import field_validator
class Username(BaseModel):
    username: str
    @field_validator('username')
    @classmethod
    def no_spaces(cls, value):
        if ' ' in value:
            raise ValueError('username cannot contain spaces')
        return value.lower()
@app.post('/users')
def create_user(user: Username):
    return user.model_dump()
print(client.post('/users', json={'username': 'Alex'}).json())
print(client.post('/users', json={'username': 'Alex Smith'}).status_code)
{'username': 'alex'}
422

Part 7: model_validator - Cross-Field Validation#

from pydantic import model_validator
class SignupForm(BaseModel):
    password: str
    confirm_password: str
    @model_validator(mode='after')
    def passwords_match(self):
        if self.password != self.confirm_password:
            raise ValueError('passwords do not match')
        return self
@app.post('/signup-form')
def signup_form(form: SignupForm):
    return {'status': 'ok'}
print(client.post('/signup-form', json={'password': 'a', 'confirm_password': 'a'}).status_code)
print(client.post('/signup-form', json={'password': 'a', 'confirm_password': 'b'}).status_code)
200
422

Part 8: A Custom Email Check - No Extra Dependency Required#

class Contact(BaseModel):
    name: str
    email: str
    @field_validator('email')
    @classmethod
    def email_must_look_valid(cls, value):
        if '@' not in value or '.' not in value.split('@')[-1]:
            raise ValueError('invalid email format')
        return value
@app.post('/contacts')
def create_contact(contact: Contact):
    return contact.model_dump()
print(client.post('/contacts', json={'name': 'Sam', 'email': 'sam@example.com'}).json())
print(client.post('/contacts', json={'name': 'Sam', 'email': 'not-an-email'}).status_code)
{'name': 'Sam', 'email': 'sam@example.com'}
422

Part 9: Reading the Validation Error Format#

response = client.post('/contacts', json={'name': 'Sam', 'email': 'bad'})
error = response.json()['detail'][0]
print(sorted(error.keys()))
print(error['loc'])
print(error['type'])
['ctx', 'input', 'loc', 'msg', 'type']
['body', 'email']
value_error

Part 10: A Real Pattern - a Validated Signup Model#

class RealSignup(BaseModel):
    username: str = Field(min_length=3, max_length=20)
    email: str
    password: str = Field(min_length=8)
    confirm_password: str
    @field_validator('username')
    @classmethod
    def username_no_spaces(cls, value):
        if ' ' in value:
            raise ValueError('username cannot contain spaces')
        return value
    @field_validator('email')
    @classmethod
    def signup_email_must_look_valid(cls, value):
        if '@' not in value or '.' not in value.split('@')[-1]:
            raise ValueError('invalid email format')
        return value
    @model_validator(mode='after')
    def check_passwords(self):
        if self.password != self.confirm_password:
            raise ValueError('passwords do not match')
        return self
@app.post('/real-signup', response_model=None)
def real_signup(form: RealSignup):
    return {'status': 'account created', 'username': form.username}
good = {'username': 'alex', 'email': 'alex@example.com', 'password': 'hunter22', 'confirm_password': 'hunter22'}
print(client.post('/real-signup', json=good).status_code)
bad = {**good, 'confirm_password': 'different'}
print(client.post('/real-signup', json=bad).status_code)
200
422

Wrap-Up: What You Learned#

  • Field adds real constraints on top of a type hint: min_length, max_length, gt, ge, and le.
  • A violated Field constraint triggers the same structured 422 response, naming the specific failed rule.
  • Field also accepts a default and a description, which shows up directly in the auto-generated docs.
  • Query applies the same kind of constraints to query parameters, including a regex pattern.
  • Path mirrors Query for path parameters, restricting a numeric id to a sensible range.
  • field_validator runs custom logic on a single field; raising ValueError becomes a proper 422 error.
  • model_validator(mode='after') runs after every field passes, enabling cross-field rules like matching passwords.
  • Pydantic's built-in EmailStr works the same way, but needs the optional email-validator package installed first.
  • Every validation error follows a consistent shape: type, loc, msg, and input, good for programmatic handling.
  • That wraps up data validation. Next up: Path Operation Configuration.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.