Python course · Module 6: Async and FastAPI

Pydantic - data validation powered by Rust

6 min read
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=None

Optional[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 equal
  • lt, le - less than, less or equal
  • min_length, max_length - string length
  • pattern - regex validation
  • description - 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 letter

Pydantic 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_species

When 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() and model_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. 1. What is Pydantic?

  2. 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).

Useful articles