Python course · Module 6: Async and FastAPI
Pydantic - data validation powered by Rust
In this lesson5
Welcome! Darwin here. A park ranger typed the population as "one hundred twenty" instead of 120, and a date into the species field. Without checks, such an entry lands in the database and ruins the statistics of the whole reserve. Pydantic is a library for data validation and serialization built on type hints: you describe the shape of the data with type annotations, and it checks every value.
Pydantic is the core of FastAPI - the framework uses it to validate request parameters and bodies and to shape responses. In version 2 the validation logic moved to the pydantic-core package written in Rust, and according to its authors a typical model validates about 17x faster than in version 1.
Safari Analogy: Pydantic is like a quality control system for observations - it checks whether each observation has correct data (species exists, population is a number, date is valid). If something is wrong, it reports an error immediately!
Pydantic Basics
BaseModel - the base class
A model is a class that inherits from BaseModel. Each field is a name with a type annotation, and the value after = becomes the default:
1from pydantic import BaseModel
2
3class Species(BaseModel):
4 id: int
5 name: str
6 population: int
7 endangered: bool = False # Default value
8
9# Creating an instance
10lion = Species(id=1, name="Lion", population=120, endangered=True)
11
12print(lion.name) # Lion
13print(lion.model_dump()) # {'id': 1, 'name': 'Lion', 'population': 120, 'endangered': True}
14print(lion.model_dump_json()) # {"id":1,"name":"Lion","population":120,"endangered":true}model_dump() turns the model into a dictionary, and model_dump_json() into JSON text. Older tutorials use .dict() and .json() - they still work in Pydantic 2, but emit a deprecation warning and will disappear in version 3.
Optional fields
Not every observation has a description. A field that may stay empty gets the type Optional[str] and the default value None:
1from typing import Optional
2from pydantic import BaseModel
3
4class Sighting(BaseModel):
5 species: str
6 count: int
7 description: Optional[str] = None
8
9print(Sighting(species="Lion", count=3)) # species='Lion' count=3 description=NoneOptional[str] alone lets you pass None, but only = None lets you skip the field. Since Python 3.10 you can write the same as str | None = None.
Automatic type conversion
Data from forms and URLs arrives as text. Pydantic tries to convert it to the declared type:
1# Pydantic automatically converts types!
2species = Species(id="1", name="Lion", population="120", endangered="yes")
3
4print(type(species.id)) # <class 'int'> (conversion str→int)
5print(type(species.population)) # <class 'int'>
6print(species.endangered) # True (conversion "yes"→True)Conversion happens only when no information is lost: "120" becomes a number, but "abc" or 1.5 won't pass into an int field. Pydantic doesn't insert None then, it raises an error.
Validation errors
When a conversion fails, Pydantic raises a ValidationError listing every problem. We catch it like any other exception:
1from pydantic import ValidationError
2
3try:
4 # Invalid data
5 species = Species(id="abc", name="Lion", population=-50)
6except ValidationError as e:
7 print(e.error_count()) # 1
8 print(e.json()) # Detailed validation errors (JSON)There is one error, because -50 is a valid int - the model knows no rule that a population can't be negative. Field adds such rules.
Field - advanced validation
Field attaches constraints to a field: number ranges, text length, a pattern. Three dots ... mark a required field:
1from pydantic import BaseModel, Field
2
3class Species(BaseModel):
4 id: int = Field(..., gt=0, description="Unique species ID")
5 name: str = Field(..., min_length=2, max_length=100)
6 scientific_name: str = Field(..., pattern=r'^[A-Z][a-z]+ [a-z]+$') # Regex
7 population: int = Field(..., ge=0, le=1_000_000) # >= 0, <= 1M
8 habitat: str = Field(default="unknown")
9
10# Validation works!
11lion = Species(
12 id=1,
13 name="Lion",
14 scientific_name="Panthera leo", # Must match the regex
15 population=120
16)Now population=-50 would be rejected by ge=0. Field(gt=0) without a default also means a required field - it's shorter, so I recommend it.
Field validators:
gt,ge- greater than, greater or equallt,le- less than, less or equalmin_length,max_length- string lengthpattern- regex validationdescription- description for documentation
Custom validators
You write an unusual rule as a function with the @field_validator decorator. It returns the value or raises a ValueError:
1from pydantic import BaseModel, ValidationError, field_validator
2
3class Species(BaseModel):
4 name: str
5 population: int
6
7 @field_validator('name')
8 @classmethod
9 def name_must_be_capitalized(cls, v):
10 if not v[0].isupper():
11 raise ValueError('Name must start with an uppercase letter')
12 return v
13
14 @field_validator('population')
15 @classmethod
16 def population_realistic(cls, v):
17 if v > 1_000_000:
18 raise ValueError('Population exceeds realistic range')
19 return v
20
21# Validation works
22lion = Species(name="Lion", population=120) # OK
23
24try:
25 lion = Species(name="lion", population=120)
26except ValidationError as e:
27 print(e.errors()[0]["msg"]) # Value error, Name must start with an uppercase letterPydantic wraps your ValueError in a ValidationError with the "Value error" prefix. The documentation adds @classmethod, because the validator runs before the object exists.
Safari API with Pydantic
Finally the model goes into FastAPI. Shared fields live in SpeciesBase, and separate classes describe input and response:
1from fastapi import FastAPI, HTTPException
2from pydantic import BaseModel, ConfigDict, Field, field_validator
3from typing import Literal
4from datetime import datetime
5
6app = FastAPI()
7
8class SpeciesBase(BaseModel):
9 name: str = Field(..., min_length=2, max_length=100)
10 scientific_name: str = Field(..., pattern=r'^[A-Z][a-z]+ [a-z]+$')
11 population: int = Field(..., ge=0, le=1_000_000)
12 habitat: Literal["savanna", "forest", "desert", "wetland"]
13
14 @field_validator('scientific_name')
15 @classmethod
16 def validate_scientific_name(cls, v):
17 parts = v.split()
18 if len(parts) != 2:
19 raise ValueError('Scientific name must have the format: Genus species')
20 return v
21
22class SpeciesCreate(SpeciesBase):
23 pass
24
25class Species(SpeciesBase):
26 id: int
27 created_at: datetime = Field(default_factory=datetime.now)
28
29 model_config = ConfigDict(from_attributes=True)
30
31@app.post("/species", response_model=Species)
32async def create_species(species: SpeciesCreate):
33 # Pydantic validates the data automatically!
34 new_species = Species(id=1, **species.model_dump())
35 return new_speciesWhen a client sends "habitat": "ocean", FastAPI answers with status 422 and create_species never runs. The old nested class Config gave way to model_config in Pydantic 2, and from_attributes=True will help with the database.
Summary
- BaseModel - the base class,
model_dump()andmodel_dump_json() - Optional fields:
Optional[str] = None - Automatic type conversion
- Field validators (gt, ge, pattern, etc.)
- Custom validators (@field_validator)
- Safari API with Pydantic
Next lesson: Async databases with SQLAlchemy!
Remember: a Pydantic model is the ranger at the park gate - it lets in only data of the right shape.
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is Pydantic?
2. Which class does a Pydantic model inherit from?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Vertical ordering
Arrange the Pydantic model definition for a Safari species:
- Horizontal ordering
How do you define an optional field in Pydantic?
- Click in order
Arrange a field with a Field validator:
- Code editor
Write a Species model with fields: name (str), population (int > 0), habitat (str), endangered (bool, default False).