Mastering Pydantic 2: Building a Real-World Library Management System

Photo by NEOM on UnsplashPydantic 2 ExamplesIntroduction:In this practical guide, we’ll explore Pydantic 2’s powerful features by building something tangible: a Library Management System. Instead of abstract examples, you’ll learn how to validate libra…


This content originally appeared on Level Up Coding - Medium and was authored by Senthil E

Photo by NEOM on Unsplash

Pydantic 2 Examples

Introduction:

In this practical guide, we’ll explore Pydantic 2’s powerful features by building something tangible: a Library Management System. Instead of abstract examples, you’ll learn how to validate library cards, manage book inventories, track loans, and handle external API integrations — all while ensuring your data remains pristine and type-safe.

Whether you’re new to Pydantic or migrating from version 1, this guide will take you from basic model definitions to advanced validation techniques, all through the lens of a real-world application. Let’s transform complex data validation challenges into elegant, maintainable solutions.

Image generated by AI
Image by the Author

To check the podcast:

Sign in

Basic Models: Starting with Users

Let’s begin with user management:

from datetime import date
from pydantic import BaseModel, EmailStr

class UserBase(BaseModel):
email: EmailStr
first_name: str
last_name: str
birth_date: date

@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"

Key components:

  1. EmailStr: Validates email format automatically
  2. date: Ensures birth_date is a proper date object
  3. @property decorator: Creates a computed attribute full_name
# Valid user creation
user1 = UserBase(
email="john@example.com",
first_name="John",
last_name="Doe",
birth_date=date(1990, 1, 1)
)
print(user1.full_name) # Output: "John Doe"

# Invalid email - raises ValidationError
try:
user2 = UserBase(
email="not-an-email",
first_name="Jane",
last_name="Smith",
birth_date=date(1995, 5, 15)
)
except ValidationError as e:
print(e) # Shows email validation error

# Invalid date format - raises ValidationError
try:
user3 = UserBase(
email="jane@example.com",
first_name="Jane",
last_name="Smith",
birth_date="1995-13-45" # Invalid date
)
except ValidationError as e:
print(e) # Shows date validation error

# Accessing model data
print(user1.email) # john@example.com
print(user1.birth_date) # 1990-01-01
print(user1.model_dump()) # Gets dict of all fields

This model provides:

  • Automatic email validation
  • Date format validation
  • Required fields (all fields must be provided)
  • Computed full_name property
  • Type checking for all fields
  • JSON serialization/deserialization via model_dump()/model_load()
Image generated by AI

Adding Validation: Library Card

Let’s create a library card with custom validation:

from pydantic import BaseModel, BeforeValidator, field_validator
from typing import Annotated
import re

def format_card_number(value: str) -> str:
if not isinstance(value, str):
value = str(value)
return re.sub(r'\D', '', value)

class LibraryCard(BaseModel):
card_number: Annotated[str, BeforeValidator(format_card_number)]
user: UserBase
is_active: bool = True

@field_validator('card_number')
def validate_card_number(cls, v: str) -> str:
if not len(v) == 8:
raise ValueError('Card number must be 8 digits')
return v
# Example usage:
user = UserBase(
email="john@example.com",
first_name="John",
last_name="Doe",
birth_date=date(1990, 1, 1)
)

# Valid examples:
card1 = LibraryCard(
card_number="1234-5678", # Hyphens removed automatically
user=user
)
print(card1.card_number) # Output: "12345678"

card2 = LibraryCard(
card_number=12345678, # Numbers converted to string
user=user
)
print(card2.card_number) # Output: "12345678"

# Invalid examples:
try:
card3 = LibraryCard(
card_number="123-456", # Too few digits after cleaning
user=user
)
except ValueError as e:
print(e) # "Card number must be 8 digits"

try:
card4 = LibraryCard(
card_number="123-456-789", # Too many digits after cleaning
user=user
)
except ValueError as e:
print(e) # "Card number must be 8 digits"

Key concepts:

  • BeforeValidator for data transformation
  • field_validator for custom validation
  • Default values
  • Nested models

This code:

  1. Cleans input by removing non-digits and converting to string
  2. Validates the cleaned number is exactly 8 digits
  3. Links to a UserBase model for user data
  4. Tracks card active status

The validation happens in two steps:

  1. BeforeValidator (format_card_number) cleans the input
  2. field_validator checks the length is exactly 8 digits
Image by the Author

Working with Collections: Books and Categories

from typing import List, Dict
from enum import Enum
from decimal import Decimal

# Define book categories as an enumeration
class BookCategory(str, Enum):
FICTION = "fiction"
NON_FICTION = "non-fiction"
SCIENCE = "science"
HISTORY = "history"

# Book model with validation
class Book(BaseModel):
isbn: str
title: str
author: str
categories: List[BookCategory] # List of categories from enum
price: Decimal # For precise monetary calculations
copies_available: int = 0 # Optional with default 0
metadata: Dict[str, str] = {} # Optional with default empty dict

@field_validator('isbn')
def validate_isbn(cls, v: str) -> str:
# Complex regex to validate ISBN-10 and ISBN-13 formats
if not re.match(r'^(?:ISBN(?:-1[03])?:? )?(?=[0-9X]{10}$|'
r'(?=(?:[0-9]+[- ]){3})[- 0-9X]{13}$|'
r'97[89][0-9]{10}$|'
r'(?=(?:[0-9]+[- ]){4})[- 0-9]{17}$)',
v):
raise ValueError('Invalid ISBN format')
return v

# Examples:

# Valid book creation
book1 = Book(
isbn="978-0-13-235088-4",
title="Python Programming",
author="John Smith",
categories=[BookCategory.SCIENCE],
price=Decimal("29.99"),
metadata={"publisher": "TechBooks"}
)

# Multiple categories
book2 = Book(
isbn="0-7475-3269-9", # ISBN-10 format
title="Science History",
author="Jane Doe",
categories=[BookCategory.SCIENCE, BookCategory.HISTORY],
price=Decimal("19.99"),
copies_available=5
)

# Invalid examples:
try:
# Invalid ISBN format
book3 = Book(
isbn="123-456", # Invalid ISBN
title="Bad ISBN",
author="Test Author",
categories=[BookCategory.FICTION],
price=Decimal("9.99")
)
except ValueError as e:
print(e) # "Invalid ISBN format"

try:
# Invalid category
book4 = Book(
isbn="978-0-13-235088-4",
title="Test Book",
author="Test Author",
categories=["invalid_category"], # Not from enum
price=Decimal("9.99")
)
except ValidationError as e:
print(e) # Invalid category error

Key features:

  1. ISBN validation using regex for ISBN-10 and ISBN-13 formats
  2. Constrained categories using Enum
  3. Precise price handling with Decimal
  4. Optional fields with defaults
  5. Custom metadata support
  6. List validation for categories

The model ensures:

  • Valid ISBN formats
  • Categories must be from predefined enum
  • Price is stored as Decimal for accuracy
  • Optional fields have sensible defaults
  • All required fields are provided
Image generated by AI

Advanced Features: Loan Management

from datetime import datetime, timedelta
from typing import Optional
from pydantic import model_validator

class LoanStatus(str, Enum):
ACTIVE = "active"
OVERDUE = "overdue"
RETURNED = "returned"

class Loan(BaseModel):
book: Book
user: UserBase
borrowed_date: datetime
due_date: datetime
returned_date: Optional[datetime] = None
status: LoanStatus = LoanStatus.ACTIVE

@model_validator(mode='after')
def validate_dates(self) -> 'Loan':
if self.returned_date and self.returned_date < self.borrowed_date:
raise ValueError('Return date cannot be before borrow date')

if self.due_date < self.borrowed_date:
raise ValueError('Due date cannot be before borrow date')

if self.returned_date is None and self.due_date < datetime.now():
self.status = LoanStatus.OVERDUE

if self.returned_date:
self.status = LoanStatus.RETURNED

return self
# Example Book and User instances for the examples
book = Book(
isbn="978-0-13-235088-4",
title="Python Programming",
author="John Smith",
categories=[BookCategory.SCIENCE],
price=Decimal("29.99")
)

user = UserBase(
email="john@example.com",
first_name="John",
last_name="Doe",
birth_date=date(1990, 1, 1)
)

# Create an active loan
active_loan = Loan(
book=book,
user=user,
borrowed_date=datetime.now(),
due_date=datetime.now() + timedelta(days=14)
)
print(active_loan.status) # ACTIVE

# Create an overdue loan
overdue_loan = Loan(
book=book,
user=user,
borrowed_date=datetime.now() - timedelta(days=20),
due_date=datetime.now() - timedelta(days=6)
)
print(overdue_loan.status) # OVERDUE

# Create a returned loan
returned_loan = Loan(
book=book,
user=user,
borrowed_date=datetime.now() - timedelta(days=10),
due_date=datetime.now() + timedelta(days=4),
returned_date=datetime.now()
)
print(returned_loan.status) # RETURNED

# Invalid scenarios
try:
# Return date before borrow date
invalid_loan = Loan(
book=book,
user=user,
borrowed_date=datetime.now(),
due_date=datetime.now() + timedelta(days=14),
returned_date=datetime.now() - timedelta(days=1)
)
except ValueError as e:
print(e) # "Return date cannot be before borrow date"

try:
# Due date before borrow date
invalid_loan2 = Loan(
book=book,
user=user,
borrowed_date=datetime.now(),
due_date=datetime.now() - timedelta(days=1)
)
except ValueError as e:
print(e) # "Due date cannot be before borrow date"

Key features:

  1. LoanStatus enum tracks loan state
  2. Model-level validation with @model_validator
  3. Automatic status updates based on dates
  4. Date comparisons for validity
  5. Links Book and UserBase models

The validator:

  • Ensures return date is after borrow date
  • Ensures due date is after borrow date
  • Automatically marks loans as OVERDUE
  • Sets status to RETURNED when returned_date exists
Image generated by AI

Configuration and Serialization

from pydantic import ConfigDict

class LibraryConfig(BaseModel):
model_config = ConfigDict(
json_encoders={
datetime: lambda v: v.isoformat(),
date: lambda v: v.isoformat()
},
alias_generator=lambda field_name: field_name.lower(),
validate_assignment=True
)

max_loan_days: int = 14
max_loans_per_user: int = 5
late_fee_per_day: Decimal = Decimal('0.50')
# Examples:
config = LibraryConfig()

# Default values
print(config.max_loan_days) # 14
print(config.late_fee_per_day) # 0.50

# Custom values
custom_config = LibraryConfig(
max_loan_days=21,
max_loans_per_user=3,
late_fee_per_day=Decimal('1.00')
)

# JSON serialization example
loan_date = datetime.now()
data = {
"loan_date": loan_date,
"config": config
}
json_data = json.dumps(data, default=lambda o: getattr(o, '__dict__', str(o)))
print(json_data) # datetime will be in ISO format

# Field assignment validation
try:
config.max_loan_days = "invalid" # Raises error - must be int
except ValidationError as e:
print(e)

# Alias generation example
print(config.model_dump(by_alias=True)) # All fields in lowercase

This configuration:

  1. Handles datetime serialization
  2. Converts field names to lowercase
  3. Validates field updates
  4. Sets default library policies
  5. Provides type safety for all fields

Practical Usage Example

# Creating a new loan
def create_loan(book: Book, user: UserBase) -> Loan:
if book.copies_available <= 0:
raise ValueError("Book not available")

borrowed_date = datetime.now()
due_date = borrowed_date + timedelta(days=LibraryConfig().max_loan_days)

loan = Loan(
book=book,
user=user,
borrowed_date=borrowed_date,
due_date=due_date
)

book.copies_available -= 1
return loan

# Serializing for API response
def get_loan_details(loan: Loan) -> dict:
return loan.model_dump(
include={'book': {'isbn', 'title'}, 'user': {'full_name'}, 'due_date', 'status'},
by_alias=True
)
# Example usage of create_loan and get_loan_details functions:

# Setup sample data
book = Book(
isbn="978-0-13-235088-4",
title="Python Programming",
author="John Smith",
categories=[BookCategory.SCIENCE],
price=Decimal("29.99"),
copies_available=2
)

user = UserBase(
email="john@example.com",
first_name="John",
last_name="Doe",
birth_date=date(1990, 1, 1)
)

# Create a loan
try:
loan = create_loan(book, user)
print(book.copies_available) # 1 (decremented by 1)

# Get loan details for API
loan_details = get_loan_details(loan)
print(loan_details)
# Output example:
# {
# 'book': {'isbn': '978-0-13-235088-4', 'title': 'Python Programming'},
# 'user': {'full_name': 'John Doe'},
# 'due_date': '2024-05-14T10:30:00',
# 'status': 'active'
# }

# Try creating another loan when no copies available
book.copies_available = 0
another_loan = create_loan(book, user) # Raises ValueError
except ValueError as e:
print(e) # "Book not available"

Key features:

  1. create_loan():
  • Checks book availability
  • Calculates due date using LibraryConfig
  • Creates loan record
  • Updates book inventory

2. get_loan_details():

  • Selectively serializes loan data
  • Includes only necessary fields
  • Uses model_dump() for clean JSON output

Advanced Features: API Integration

from pydantic import AliasPath, AliasChoices
from typing import Any

class ExternalBookData(BaseModel):
model_config = ConfigDict(
alias_generator=lambda field_name: AliasChoices(
field_name,
AliasPath("data", field_name),
AliasPath("book", field_name)
)
)

title: str
author: str
isbn: str
publication_date: Optional[date] = None

# Usage example:
external_data = {
"data": {
"title": "1984",
"author": "George Orwell",
"isbn": "9780451524935"
}
}

book_data = ExternalBookData(**external_data)
class ExternalBookData(BaseModel):
# Configure flexible field mapping
model_config = ConfigDict(
alias_generator=lambda field_name: AliasChoices(
field_name, # Direct field name
AliasPath("data", field_name), # Nested under "data"
AliasPath("book", field_name) # Nested under "book"
)
)

title: str
author: str
isbn: str
publication_date: Optional[date] = None

# Example 1: Data nested under "data"
data1 = {
"data": {
"title": "1984",
"author": "George Orwell",
"isbn": "9780451524935"
}
}
book1 = ExternalBookData(**data1)
print(book1.title) # "1984"

# Example 2: Data nested under "book"
data2 = {
"book": {
"title": "The Hobbit",
"author": "J.R.R. Tolkien",
"isbn": "9780547928227",
"publication_date": "1937-09-21"
}
}
book2 = ExternalBookData(**data2)
print(book2.publication_date) # date(1937, 9, 21)

# Example 3: Flat structure
data3 = {
"title": "Dune",
"author": "Frank Herbert",
"isbn": "9780441172719"
}
book3 = ExternalBookData(**data3)
print(book3.author) # "Frank Herbert"

# Example 4: Invalid structure
try:
data4 = {
"wrong_key": {
"title": "Invalid Book",
"author": "Unknown",
"isbn": "1234567890"
}
}
book4 = ExternalBookData(**data4)
except ValidationError as e:
print(e) # Missing required fields

This model handles three different API response formats:

  1. Fields directly in root
  2. Fields nested under “data”
  3. Fields nested under “book”

The AliasChoices and AliasPath combination allows flexible data parsing while maintaining consistent internal field names.

Error Handling and Validation

from pydantic import ValidationError

def process_book_data(data: dict[str, Any]) -> Book:
try:
book = Book(**data)
return book
except ValidationError as e:
print("Validation errors:")
for error in e.errors():
print(f"- Field: {error['loc'][0]}, Error: {error['msg']}")
raise
# Examples:

# Valid data
valid_data = {
"isbn": "978-0-13-235088-4",
"title": "Python Programming",
"author": "John Smith",
"categories": ["science"],
"price": "29.99"
}

try:
book = process_book_data(valid_data)
print(book.title) # "Python Programming"
except ValidationError:
print("Unexpected error")

# Missing required field
invalid_data1 = {
"title": "Python Programming",
"author": "John Smith",
"price": "29.99"
}

try:
book = process_book_data(invalid_data1)
except ValidationError as e:
# Output:
# Validation errors:
# - Field: isbn, Error: Field required
# - Field: categories, Error: Field required

# Invalid price format
invalid_data2 = {
"isbn": "978-0-13-235088-4",
"title": "Python Programming",
"author": "John Smith",
"categories": ["science"],
"price": "not_a_number"
}

try:
book = process_book_data(invalid_data2)
except ValidationError as e:
# Output:
# Validation errors:
# - Field: price, Error: Value error, invalid decimal value

This function:

  1. Attempts to create a Book model from dict data
  2. Returns valid Book instance
  3. Prints field-specific validation errors
  4. Re-raises ValidationError for error handling

Conclusion

Throughout this guide, we’ve built a complete Library Management System while exploring Pydantic 2’s key features:

  1. Basic model definition and validation
  2. Custom validators and transformers
  3. Model inheritance and nesting
  4. Complex validation rules
  5. Configuration and serialization
  6. Error handling
  7. Integration with external systems

Pydantic 2 provides a robust foundation for building type-safe, validated data models in Python applications. By using it in real-world projects like this Library Management System, you can ensure data integrity while maintaining clean, maintainable code.

References

  • Pydantic Official Documentation (2024)
  • Pydantic V2 Documentation
  • Migration Guide from V1 to V2

Technical Publications

Related Resources


Mastering Pydantic 2: Building a Real-World Library Management System was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.


This content originally appeared on Level Up Coding - Medium and was authored by Senthil E


Print Share Comment Cite Upload Translate Updates
APA

Senthil E | Sciencx (2024-10-31T13:48:19+00:00) Mastering Pydantic 2: Building a Real-World Library Management System. Retrieved from https://www.scien.cx/2024/10/31/mastering-pydantic-2-building-a-real-world-library-management-system/

MLA
" » Mastering Pydantic 2: Building a Real-World Library Management System." Senthil E | Sciencx - Thursday October 31, 2024, https://www.scien.cx/2024/10/31/mastering-pydantic-2-building-a-real-world-library-management-system/
HARVARD
Senthil E | Sciencx Thursday October 31, 2024 » Mastering Pydantic 2: Building a Real-World Library Management System., viewed ,<https://www.scien.cx/2024/10/31/mastering-pydantic-2-building-a-real-world-library-management-system/>
VANCOUVER
Senthil E | Sciencx - » Mastering Pydantic 2: Building a Real-World Library Management System. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2024/10/31/mastering-pydantic-2-building-a-real-world-library-management-system/
CHICAGO
" » Mastering Pydantic 2: Building a Real-World Library Management System." Senthil E | Sciencx - Accessed . https://www.scien.cx/2024/10/31/mastering-pydantic-2-building-a-real-world-library-management-system/
IEEE
" » Mastering Pydantic 2: Building a Real-World Library Management System." Senthil E | Sciencx [Online]. Available: https://www.scien.cx/2024/10/31/mastering-pydantic-2-building-a-real-world-library-management-system/. [Accessed: ]
rf:citation
» Mastering Pydantic 2: Building a Real-World Library Management System | Senthil E | Sciencx | https://www.scien.cx/2024/10/31/mastering-pydantic-2-building-a-real-world-library-management-system/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.