Field Constraints and Error Messages
James creates a NoteCreate model from Lesson 2 and passes title="". No error. An empty string is a valid str, so Pydantic accepts it. But the manual validation from Chapter 54 rejected empty titles with raise ValueError("title cannot be empty").
"The types are validated," Emma says. "The values are not. Yet."
She adds four words to the title field: Field(min_length=1). James runs the code again with title="". This time, Pydantic rejects it.
"Each constraint replaces one if check from your manual function," Emma says. "min_length replaces if len(title) == 0. max_length replaces if len(title) > 200. ge replaces if word_count < 0. One parameter per rule."
Constraints are rules about what values a field can hold, beyond just the type. A title must be a string (type constraint), but it must also be non-empty and under 200 characters (value constraints). Pydantic's Field function lets you declare these rules alongside the type.
Pydantic's Field(min_length=1, max_length=200) is similar to Java's @Size(min=1, max=200) or C#'s [StringLength(200, MinimumLength=1)]. The concept is declarative constraints on fields.
Adding Field Constraints
Import Field alongside BaseModel:
from pydantic import BaseModel, Field
Now add constraints to each field that had manual checks in Chapter 54:
class NoteCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
body: str = Field(min_length=1)
word_count: int = Field(ge=0)
author: str
tags: list[str] = []
is_draft: bool = False
Here is what each constraint replaces from the manual validation function:
| Constraint | Replaces This Manual Check | Meaning |
|---|---|---|
min_length=1 | if len(title) == 0: raise ValueError | At least 1 character |
max_length=200 | if len(title) > 200: raise ValueError | At most 200 characters |
ge=0 | if word_count < 0: raise ValueError | Greater than or equal to 0 |
Three lines of constraints replace nine lines of if/raise pairs. The author field has no constraints beyond its type, so it stays as a plain annotation. Same for tags and is_draft.
Constraint Parameters Reference
Pydantic's Field function accepts several constraint parameters for strings and numbers:
String constraints:
| Parameter | Meaning | Example |
|---|---|---|
min_length | Minimum character count | Field(min_length=1) |
max_length | Maximum character count | Field(max_length=200) |
Numeric constraints:
| Parameter | Meaning | Example |
|---|---|---|
gt | Greater than (exclusive) | Field(gt=0) means > 0 |
ge | Greater than or equal to | Field(ge=0) means >= 0 |
lt | Less than (exclusive) | Field(lt=100) means < 100 |
le | Less than or equal to | Field(le=100) means <= 100 |
You can combine multiple constraints on one field: Field(ge=0, le=100) means the value must be between 0 and 100, inclusive.
Testing Constraint Violations
Each constraint needs a test. Use pytest.raises(ValidationError) the same way you used pytest.raises(ValueError) in Chapter 54:
import pytest
from pydantic import ValidationError
def test_valid_note() -> None:
note = NoteCreate(
title="Test Note",
body="A valid body.",
word_count=42,
author="James",
)
assert note.title == "Test Note"
assert note.word_count == 42
assert note.tags == []
assert note.is_draft is False
def test_title_empty_rejected() -> None:
with pytest.raises(ValidationError):
NoteCreate(
title="",
body="Valid body",
word_count=42,
author="James",
)
def test_title_too_long_rejected() -> None:
with pytest.raises(ValidationError):
NoteCreate(
title="A" * 201,
body="Valid body",
word_count=42,
author="James",
)
def test_word_count_negative_rejected() -> None:
with pytest.raises(ValidationError):
NoteCreate(
title="Valid Title",
body="Valid body",
word_count=-1,
author="James",
)
def test_body_empty_rejected() -> None:
with pytest.raises(ValidationError):
NoteCreate(
title="Valid Title",
body="",
word_count=42,
author="James",
)
Five tests replace twelve from the manual version. The manual tests needed separate tests for wrong types AND wrong values. Pydantic handles type checking automatically, so your tests focus only on constraint violations.
Reading Pydantic's Error Output
When validation fails, the ValidationError contains structured information about every problem. Try creating a note with multiple violations:
from pydantic import BaseModel, Field, ValidationError
class NoteCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
body: str = Field(min_length=1)
word_count: int = Field(ge=0)
author: str
tags: list[str] = []
is_draft: bool = False
try:
note = NoteCreate(
title="",
body="",
word_count=-5,
author="James",
)
except ValidationError as e:
print(f"Error count: {e.error_count()}")
print()
print(e)
Output:
Error count: 3
3 validation errors for NoteCreate
title
String should have at least 1 character [type=string_too_short, input_value='', input_type=str]
body
String should have at least 1 character [type=string_too_short, input_value='', input_type=str]
word_count
Input should be greater than or equal to 0 [type=greater_than_equal, input_value=-5, input_type=int]
Each error includes:
- The field name (
title,body,word_count) - A human-readable message ("String should have at least 1 character")
- The error type (
string_too_short,greater_than_equal) - The actual input value and its type
Compare this to the manual error messages from Chapter 54: raise ValueError("title cannot be empty"). Pydantic's messages are more structured and include more context, and you did not write any of them.
Accessing Errors Programmatically
For tests or logging, you can access errors as a list of dictionaries:
try:
note = NoteCreate(title="", body="x", word_count=-1, author="J")
except ValidationError as e:
for error in e.errors():
print(f"Field: {error['loc'][0]}")
print(f"Message: {error['msg']}")
print(f"Type: {error['type']}")
print()
Output:
Field: title
Message: String should have at least 1 character
Type: string_too_short
Field: word_count
Message: Input should be greater than or equal to 0
Type: greater_than_equal
The e.errors() method returns a list where each item is a dictionary with keys like loc (location/field name), msg (message), and type (error category). This is useful when you want to process errors in code rather than just printing them.
PRIMM-AI+ Practice: Constraint Predictions
Predict [AI-FREE]
Look at this model and instantiation without running it. Predict whether Pydantic accepts or rejects the input. If it rejects, predict which fields fail and why. Write your predictions and a confidence score from 1 to 5 before checking.
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1, max_length=50)
price: float = Field(gt=0)
quantity: int = Field(ge=0)
product = Product(name="Widget", price=0.0, quantity=0)
Check your prediction
Pydantic REJECTS this input with 1 error:
1 validation error for Product
price
Input should be greater than 0 [type=greater_than, input_value=0.0, input_type=float]
The price field uses gt=0 (greater than), which means strictly greater than zero. A price of 0.0 is not greater than 0, so it fails.
The quantity field uses ge=0 (greater than or equal to), so 0 is accepted.
If you confused gt and ge, revisit the constraint table above. The difference: gt excludes the boundary value, ge includes it.
Run
Create a file with the Product model. Run uv run python product.py. Change price=0.0 to price=0.01 and run again. Then change quantity=0 to quantity=-1 and predict the result before running.
Investigate
Create a Product with name="A" * 51 (one character over the max_length). Read the error message. Does it tell you the actual length and the maximum? Now try name="". Does the error message differ from the max_length violation?
Modify
Add a new field rating: float = Field(ge=0, le=5) to the Product model. Predict what happens with rating=5.0, rating=5.1, and rating=-0.1. Test each one.
Make [Mastery Gate]
Without looking at any examples, define a BaseModel called BlogPost with:
title: str = Field(min_length=1, max_length=100)body: str = Field(min_length=10)likes: int = Field(ge=0)
Write four test functions:
- Valid BlogPost is accepted
- Empty title is rejected
- Body under 10 characters is rejected
- Negative likes is rejected
Run uv run pytest to verify all tests pass.
Try With AI
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: Generate a Constrained Model
Write a Pydantic BaseModel called Employee with these
fields and constraints:
- name: str, at least 1 character, at most 100
- salary: float, must be positive (greater than 0)
- department: str
- years_employed: int, at least 0
Use Field from pydantic for all constraints.
Include type annotations on everything.
Review the AI's output. Check: does it use gt=0 for salary (not ge=0)? Does it use ge=0 for years_employed? A salary of zero would be wrong, but zero years employed is valid for a new hire. The constraint choice depends on the business rule.
What you're learning: You are evaluating whether the AI chose the right constraint (gt vs ge) based on the domain logic.
Prompt 2: Map Manual to Declarative
Here is a manual validation check:
if len(title) > 200:
raise ValueError(f"title too long: {len(title)} chars (max 200)")
What is the Pydantic Field equivalent? Show me just
the field declaration, not the whole model.
Compare the AI's answer to what you learned in this lesson. It should show title: str = Field(max_length=200). One parameter replaces three lines.
What you're learning: You are practicing the mental mapping from manual if/raise patterns to declarative Field() parameters.
Key Takeaways
-
Field()adds value constraints beyond type checking.min_length,max_length,gt,ge,lt,leeach replace a manualif/raisepair from Chapter 54. -
Each constraint maps to one manual check.
Field(min_length=1)replacesif len(title) == 0: raise ValueError. The mapping is direct and predictable. -
Pydantic reports all constraint violations at once. A single
ValidationErrorcan contain errors for multiple fields. Usee.error_count()to see how many ande.errors()to process them programmatically. -
Error messages include context automatically. Each error shows the field name, a human-readable message, the error type, the actual input value, and its Python type. You did not write any of these messages.
-
Test constraints with
pytest.raises(ValidationError). The pattern is identical topytest.raises(ValueError)from Chapter 54. One import changes, the rest stays the same.
Looking Ahead
Your model now validates types AND enforces value constraints. But so far, you have only created models from Python keyword arguments. In Lesson 4, you will learn to convert models to dictionaries and JSON strings, parse JSON directly into models, and apply the boundary pattern that separates Pydantic models (for external data) from dataclasses (for internal logic).