Skip to main content

BaseModel: Runtime Validation from Types

James stares at the 30-line validate_note_data function from Chapter 54. Six fields, twelve tests, and a nagging feeling that there must be a better way. Emma opens a new file. "Same problem, different approach."

She types six lines and runs the file. When she passes title=999, the program raises an error immediately. No isinstance check. No if statement. No manual raise. Just a type annotation and an automatic rejection.

"Where is the validation code?" James asks.

"You are looking at it," Emma says, pointing at the class definition. "The type annotations ARE the validation rules."

If you're new to programming

Pydantic is a Python library that reads your type annotations and automatically checks that incoming data matches those types. You define WHAT valid data looks like, and Pydantic handles HOW to check it. This lesson shows you the basics.

If you know validation from another language

Pydantic is similar to Java's Bean Validation or C#'s Data Annotations, but it uses Python's type hint syntax instead of separate annotation decorators. If you have used TypeScript's Zod or io-ts, the concept is the same: schema validation derived from type declarations.


Installing Pydantic

Open your terminal in your SmartNotes project folder and run:

uv add pydantic

Notice there is no --dev flag. Until now, every package you installed (pytest, pyright, ruff) used uv add --dev because those tools help you during development but do not ship with your program. Pydantic is different. It runs inside your application, validating data while the program executes. Your program depends on it, so it goes into the main dependencies.

After installation, check your pyproject.toml. You should see pydantic listed under [project.dependencies], not under [tool.uv.dev-dependencies]:

[project]
dependencies = [
"pydantic>=2.0",
]

Your First BaseModel

Here is the Pydantic equivalent of the Note dataclass:

from pydantic import BaseModel


class NoteCreate(BaseModel):
title: str
body: str
word_count: int
author: str
tags: list[str]
is_draft: bool

The syntax looks almost identical to a dataclass. The difference is in the parentheses: NoteCreate(BaseModel). The parentheses tell Python that NoteCreate gets its validation powers from BaseModel. Read it as "NoteCreate is a kind of BaseModel." Full inheritance is covered in Phase 5; for now, just know that this syntax gives your class automatic validation.

The name NoteCreate is intentional. It represents data coming IN to the system (creating a note). Your internal Note dataclass from Chapter 51 stays the same. You will see why this distinction matters in Lesson 4.


Automatic Validation on Instantiation

Create a valid instance:

note = NoteCreate(
title="My First Note",
body="Learning Pydantic validation.",
word_count=42,
author="James",
tags=["python", "pydantic"],
is_draft=False,
)

print(note)
print(note.title)
print(note.word_count)

Output:

title='My First Note' body='Learning Pydantic validation.' word_count=42 author='James' tags=['python', 'pydantic'] is_draft=False
My First Note
42

So far, this looks exactly like a dataclass. The magic happens when you pass wrong types.


Wrong Types Are Rejected Immediately

Try what you tried in Lesson 1, but with BaseModel instead of @dataclass:

broken = NoteCreate(
title=999,
body="Valid body",
word_count=42,
author="James",
tags=["python"],
is_draft=False,
)

Output:

pydantic_core._pydantic_core.ValidationError: 1 validation error for NoteCreate
title
Input should be a valid string [type=string_type, input_value=999, input_type=int]

One line of type annotation (title: str) replaced three lines of manual validation (isinstance check, if block, raise TypeError). Pydantic read the annotation, checked the input, and raised a ValidationError with a clear message.


Multiple Errors at Once

Pass wrong types for several fields:

try:
broken = NoteCreate(
title=999,
body=42,
word_count="many",
author=True,
tags="not-a-list",
is_draft="yes",
)
except Exception as e:
print(e)

Output:

4 validation errors for NoteCreate
title
Input should be a valid string [type=string_type, input_value=999, input_type=int]
body
Input should be a valid string [type=string_type, input_value=42, input_type=int]
word_count
Input should be a valid integer [type=int_type, input_value='many', input_type=str]
tags
Input should be a valid list [type=list_type, input_value='not-a-list', input_type=str]

Pydantic checks ALL fields and reports ALL errors in one pass. Your manual validate_note_data from Chapter 54 stopped at the first error. Pydantic collects every problem and presents them together. This is more helpful for the person fixing the data.

Notice that author=True did not cause an error. Pydantic automatically converts True to the string "True" because booleans can be represented as strings. This is called coercion: Pydantic tries to convert compatible types rather than rejecting them. For the core pattern, focus on this: wrong types that cannot be converted (like title=999 where an int cannot meaningfully become a string in all cases) are caught; compatible conversions (like bool to str) are allowed. You can explore Pydantic's full coercion rules in the extension exercise.


Catching ValidationError

Import ValidationError and catch it with try/except, just like the exceptions you learned in Chapter 54:

from pydantic import BaseModel, ValidationError


class NoteCreate(BaseModel):
title: str
body: str
word_count: int
author: str
tags: list[str]
is_draft: bool


try:
note = NoteCreate(
title=999,
body="Valid body",
word_count=42,
author="James",
tags=["python"],
is_draft=False,
)
except ValidationError as e:
print(f"Validation failed with {e.error_count()} error(s)")
print(e)

Output:

Validation failed with 1 error(s)
1 validation error for NoteCreate
title
Input should be a valid string [type=string_type, input_value=999, input_type=int]

The pattern is identical to except ValueError as e from Chapter 54. The only difference is the exception type: ValidationError instead of ValueError or TypeError.


Safe Defaults for Lists

In Chapter 51, Lesson 4, you learned that mutable defaults in dataclasses require field(default_factory=list):

from dataclasses import dataclass, field

@dataclass
class Note:
tags: list[str] = field(default_factory=list) # Required in dataclasses

Pydantic handles this safely without the extra syntax:

class NoteCreate(BaseModel):
title: str
body: str
word_count: int
author: str
tags: list[str] = [] # Safe in Pydantic, no default_factory needed
is_draft: bool = False

Each instance gets its own fresh list. Pydantic prevents the shared-mutable-default trap internally. This is a small convenience, but it removes one piece of boilerplate you had to remember with dataclasses.


The Comparison: 30 Lines vs 6

Here is the side-by-side comparison:

Manual validation (Chapter 54): 30+ lines

def validate_note_data(data: dict[str, object]) -> Note:
title: object = data["title"]
if not isinstance(title, str):
raise TypeError(f"title must be a string, got {type(title).__name__}")
if len(title) == 0:
raise ValueError("title cannot be empty")
if len(title) > 200:
raise ValueError(f"title too long: {len(title)} chars (max 200)")
# ... 20+ more lines for body, word_count, author, tags, is_draft
return Note(title=title, body=body, ...)

Pydantic model: 6 field declarations

class NoteCreate(BaseModel):
title: str
body: str
word_count: int
author: str
tags: list[str] = []
is_draft: bool = False

Same validation rules. Same type safety. But the Pydantic version declares WHAT is valid instead of coding HOW to check it. You will add constraints like min_length and max_length in Lesson 3 to match the full manual validation from Chapter 54.


PRIMM-AI+ Practice: BaseModel Validation

Predict [AI-FREE]

Look at this code without running it. Predict whether Pydantic accepts or rejects the input. If it rejects, predict how many errors. Write your prediction and a confidence score from 1 to 5 before checking.

from pydantic import BaseModel


class Config(BaseModel):
name: str
version: int
debug: bool


config = Config(name="myapp", version="3", debug=True)
print(config.version)
print(type(config.version))
Check your prediction

Output:

3
<class 'int'>

Pydantic ACCEPTS this input. The string "3" is coerced to the integer 3 because Pydantic's default mode converts compatible types. version ends up as an int with value 3, not a str with value "3".

If you predicted a validation error, your caution is reasonable. Pydantic's coercion behavior is a deliberate design choice: it handles common cases (like JSON where all numbers may arrive as strings) without requiring the caller to pre-convert.

Run

Create a file called basemodel_practice.py with the code above. Run uv run python basemodel_practice.py. Compare the output to your prediction. Then change version="three" (a non-numeric string) and predict what happens.

Investigate

After the print statements, add:

config2 = Config(name="myapp", version="three", debug=True)

Run the file. Read the ValidationError message. It tells you exactly what went wrong: "three" cannot be parsed as an integer. Compare this error message to the manual raise TypeError(f"version must be an integer, got {type(version).__name__}") you would write in Chapter 54 style. Which message is more helpful?

Modify

Add a fourth field max_retries: int = 3 with a default value. Create a Config without passing max_retries. Predict: does Pydantic use the default? Then create a Config with max_retries="five". Predict: does Pydantic reject it or coerce it?

Make [Mastery Gate]

Without looking at any examples, define a BaseModel called UserProfile with:

  • username: str
  • age: int
  • email: str
  • is_active: bool = True

Write a test function that creates a valid UserProfile and asserts each field value. Write a second test function that passes age="not-a-number" and uses pytest.raises(ValidationError) to verify Pydantic rejects it. Run uv run pytest to verify both tests pass.


Try With AI

Opening Claude Code

If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.

Prompt 1: Understand Inheritance Syntax

In Python, what does the parentheses in
class NoteCreate(BaseModel) mean? I know it gives
NoteCreate validation powers, but what is the
general concept? Give me a one-paragraph explanation
without going into advanced inheritance topics.

Read the AI's response. It should explain that the parentheses indicate NoteCreate inherits from BaseModel. You do not need to understand all of inheritance yet; just know that this is how Python shares behavior between classes.

What you're learning: You are connecting the syntax you just used to the broader concept of inheritance, which you will study in depth later.

Prompt 2: Compare Approaches

I wrote a 30-line validate_note_data function with
isinstance checks in Chapter 54. Now I replaced it
with a 6-line Pydantic BaseModel. What are the
tradeoffs? Is there anything the manual approach
does better than Pydantic?

Read the AI's response. It should mention that manual validation gives you full control over error messages and validation order. Pydantic trades that control for brevity and consistency. Both approaches are valid; Pydantic is better when your validation follows standard patterns (type checks, length checks, range checks).

What you're learning: You are building judgment about when to use each approach, not just accepting that "new is always better."


Key Takeaways

  1. Pydantic validates types automatically on instantiation. When you create a BaseModel instance, Pydantic checks every field against its type annotation. Wrong types raise ValidationError immediately.

  2. Install Pydantic as a runtime dependency. Use uv add pydantic (no --dev flag) because Pydantic runs inside your application, not just during development.

  3. The parentheses (BaseModel) give your class validation powers. This is Python's inheritance syntax. NoteCreate(BaseModel) means NoteCreate is a kind of BaseModel and inherits its validation behavior. Full inheritance is covered in Phase 5.

  4. Pydantic reports all errors at once. Unlike manual validation that stops at the first error, Pydantic checks every field and collects all failures into a single ValidationError.

  5. list[str] = [] is safe in Pydantic. Each instance gets its own fresh list. No field(default_factory=list) needed.

  6. 30 lines become 6, but the validation is the same. Pydantic replaces manual isinstance checks with declarative field types. In Lesson 3, you will add constraints to match the full manual validation from Chapter 54.


Looking Ahead

Your BaseModel validates types, but it does not enforce value constraints yet. NoteCreate(title="", ...) succeeds because an empty string is still a valid str. In Lesson 3, you will add Field(min_length=1) and other constraints to replicate every rule from your manual validation function.