FastAPI x GenAI

Pydantic: Intermediate

Last time our bouncer learned to check types. But real projects need more than "is this a string?". Titles should not be 2 characters. View counts should not be negative. And nobody gets to put "delete from users" in a form field and walk away smiling. 😤

Time to hand the bouncer a rulebook.

Field: rules beyond types

Field() lets you attach constraints to a field, right where you declare it:

from pydantic import BaseModel, Fieldclass Blog(BaseModel):    title: str = Field(min_length=5)    views: int = Field(default=0, ge=0)

Send in a tiny title:

Blog(title="hi")
1 validation error for Blogtitle  String should have at least 5 characters  [type=string_too_short, input_value='hi', input_type=str]

How it works ?

  1. min_length=5 rejects any title shorter than 5 characters. No default, so the field stays required.

  2. ge=0 means greater than or equal to 0. Negative view counts are now impossible.

  3. Other favorites: max_length, gt and lt for numbers, pattern for regex checks. Same idea, different rule.

Restricting choices

Say we only allow blogs about Python, Java, and Go. Literal locks the options down:

from typing import Literalclass Blog(BaseModel):    title: str = Field(min_length=5)    language: Literal["Python", "Java", "Go"] = "Python"Blog(title="My C++ masterpiece", language="C++")
1 validation error for Bloglanguage  Input should be 'Python', 'Java' or 'Go'  [type=literal_error, input_value='C++', input_type=str]

Literal[...] allows exactly those values and nothing else. Sorry, C++ folks. Wrong tutorial site anyway. 😅

Dynamic defaults with default_factory

Every blog should record when it was created. Tempting shortcut:

from datetime import datetimeclass Blog(BaseModel):    title: str    created_at: datetime = datetime.now()  # bug!

This evaluates datetime.now() once, when the class is defined. Every blog created afterwards gets that same stale timestamp. The fix:

class Blog(BaseModel):    title: str    created_at: datetime = Field(default_factory=datetime.now)

default_factory takes a function (note: datetime.now without brackets, we are passing it, not calling it). Pydantic calls it fresh for every new object, so each blog gets its own timestamp.

Custom rules with field_validator

Built-in constraints only go so far. Remember the SQL injection crowd from last lesson? Let's ban them properly:

from pydantic import field_validatorclass Blog(BaseModel):    title: str = Field(min_length=5)    @field_validator("title")    @classmethod    def no_sql_injection(cls, value: str) -> str:        if "delete from" in value.lower():            raise ValueError("Our terms strictly prohibit SQL injection attacks")        return value

Let's understand this:

  1. @field_validator("title") runs this method every time a title comes in.

  2. Raise a ValueError to reject the value. Pydantic wraps it into a proper ValidationError with your message.

  3. Everything fine? Return the value. You can even modify it here, like return value.strip().

When fields depend on each other

Classic case: user registration with a confirm password box. A single field validator cannot compare two fields, so we validate the whole model:

from pydantic import model_validatorclass CreateUser(BaseModel):    email: str    password: str    confirm_password: str    @model_validator(mode="after")    def passwords_match(self):        if self.password != self.confirm_password:            raise ValueError("The two passwords did not match")        return self
  1. mode="after" runs once all individual fields have passed their own checks.

  2. Inside, self is the fully built model, so you can compare any fields you like.

  3. Return self when everything checks out.

Our models can now validate in a much better way.