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 ?
min_length=5rejects any title shorter than 5 characters. No default, so the field stays required.ge=0means greater than or equal to 0. Negative view counts are now impossible.Other favorites:
max_length,gtandltfor numbers,patternfor 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 valueLet's understand this:
@field_validator("title")runs this method every time a title comes in.Raise a
ValueErrorto reject the value. Pydantic wraps it into a properValidationErrorwith your message.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 selfmode="after"runs once all individual fields have passed their own checks.Inside,
selfis the fully built model, so you can compare any fields you like.Return
selfwhen everything checks out.
Our models can now validate in a much better way.
