Skip to main content

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."

If you're new to programming

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.

If you know validation from another language

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:

ConstraintReplaces This Manual CheckMeaning
min_length=1if len(title) == 0: raise ValueErrorAt least 1 character
max_length=200if len(title) > 200: raise ValueErrorAt most 200 characters
ge=0if word_count < 0: raise ValueErrorGreater 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:

ParameterMeaningExample
min_lengthMinimum character countField(min_length=1)
max_lengthMaximum character countField(max_length=200)

Numeric constraints:

ParameterMeaningExample
gtGreater than (exclusive)Field(gt=0) means > 0
geGreater than or equal toField(ge=0) means >= 0
ltLess than (exclusive)Field(lt=100) means < 100
leLess than or equal toField(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:

  1. Valid BlogPost is accepted
  2. Empty title is rejected
  3. Body under 10 characters is rejected
  4. Negative likes is rejected

Run uv run pytest to verify all 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: 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

  1. Field() adds value constraints beyond type checking. min_length, max_length, gt, ge, lt, le each replace a manual if/raise pair from Chapter 54.

  2. Each constraint maps to one manual check. Field(min_length=1) replaces if len(title) == 0: raise ValueError. The mapping is direct and predictable.

  3. Pydantic reports all constraint violations at once. A single ValidationError can contain errors for multiple fields. Use e.error_count() to see how many and e.errors() to process them programmatically.

  4. 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.

  5. Test constraints with pytest.raises(ValidationError). The pattern is identical to pytest.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).