Introduction to Pydantic: Basics and Advanced Features

Introduction to Pydantic: Basics and Advanced Features

Картинка к публикации: Introduction to Pydantic: Basics and Advanced Features

Introduction

What Is Pydantic?

In modern programming, particularly in Python development, effective data handling is essential. The stability and security of your applications often hinge on how well you manage and validate the data flowing through them. This is where Pydantic comes into play—a library that provides a straightforward approach to data validation and serialization.

Pydantic was created to simplify working with data, making it more predictable and secure. Its primary goal is to offer strict type enforcement and validation with minimal effort from the developer. By leveraging Python’s type annotations to define data models, Pydantic automatically generates validators that ensure the provided data matches these type specifications.

Key Features of Pydantic:

  1. Data Models: Pydantic makes it convenient to describe data models using Python classes. Each model represents a collection of fields with specified data types and optional default values. This approach lets you explicitly define the structure of your data and the constraints on each field.

    from pydantic import BaseModel
    
    class User(BaseModel):
        id: int
        name: str
        age: int = 18
    
  2. Data Validation: One of Pydantic’s core strengths is automatic data validation. When you create a model instance, all incoming data is checked against the types defined in the annotations. If the data doesn’t meet the criteria, Pydantic returns informative error messages.

    user = User(id='first', name='Alice', age=25)
    
  3. Data Conversion: Along with validation, Pydantic automatically converts data to the expected types. For instance, if a field requires an int but receives a string representing a number, Pydantic will attempt a suitable conversion.

    user = User(id='1', name='Alice', age='25')
    print(user.id)  # 1
    print(user.age)  # 25
    
  4. Nested Models: Pydantic supports nested models, allowing you to construct complex data structures. This capability is particularly helpful when dealing with JSON or other data formats that often include nested objects.

    class Address(BaseModel):
        street: str
        city: str
    
    class UserWithAddress(BaseModel):
        id: int
        name: str
        address: Address
    
    address = Address(street='123 Main St', city='New York')
    user = UserWithAddress(id=1, name='Alice', address=address)
    
  5. Custom Data Validators: Pydantic lets you define and use custom validators for data checks, giving you greater flexibility and control.

    from pydantic import validator
    
    class UserWithValidation(BaseModel):
        id: int
        name: str
        age: int
    
        @validator('age')
        def check_age(cls, v):
            if v < 18:
                raise ValueError('Age must be at least 18')
            return v
    
    user = UserWithValidation(id=1, name='Alice', age=25)
    
  6. Integration with Other Frameworks: Pydantic integrates seamlessly with popular frameworks like FastAPI. In FastAPI, Pydantic models are used to validate both incoming requests and outgoing responses, streamlining development and improving code quality.

A Brief History of Pydantic

Pydantic was born out of a desire to simplify and enhance data validation in Python projects. Samuel Colvin, the library’s creator and lead developer, released the initial version in 2018. It rapidly gained traction within the developer community thanks to its simplicity and efficiency.

The inspiration behind Pydantic arose from the need for more rigorous and reliable data validation in projects where poor data handling could lead to critical failures. Against the backdrop of increasing adoption of type annotations in Python, Pydantic introduced a convenient way to harness these annotations for data modeling and automatic validation.

Since its inception, Pydantic has undergone numerous changes and improvements. Some noteworthy milestones include:

Version 1.0 (2018)

  • The first stable release, featuring fundamental data validation and serialization.
  • Support for basic data types and type annotations.
  • The ability to create simple, validated data models.

Version 1.4 (2019)

  • Introduced nested model support and more complex data structures.
  • Improved performance and expanded support for custom validators.
  • Integrated with FastAPI, enabling automatic validation for APIs.

Version 1.7 (2020)

  • Added new data types like UUID, Decimal, and IPvAnyAddress.
  • Enhanced validation error messages to be more informative and user-friendly.
  • Optimized performance for faster data processing.

Version 1.8 (2021)

  • Expanded configuration options for models and settings files.
  • Improved handling of datetime objects and support for various date/time formats.
  • Introduced create_model, allowing dynamic model generation.

Version 2.2 (2023)

  • Rewrote the pydantic-core module in Rust, significantly boosting performance.
  • Supported Python 3.8 and above.
  • Introduced a new interface for configuring models.
  • Further improved error messaging for clearer validation feedback.

Version 2.7 (2024)

  • Introduced serialize_as_any to handle serialization of objects with arbitrary fields.
  • Enabled passing context during serialization for more flexible data handling.
  • Added performance enhancements and new features like expanded type annotation capabilities and validation optimizations.

The latest versions of Pydantic offer an array of new features and improvements, making the library even more powerful and versatile. Some standout enhancements include:

  • Refined Validation Mechanisms: New types of validators and improved validation logic let you validate data more accurately and flexibly.
  • Performance Optimization: Significant speed improvements, especially for large and complex models, make Pydantic suitable for high-load applications and services.
  • Enhanced Configuration Options: More ways to customize model and validator behavior, giving developers greater control over validation and serialization processes.
  • Revamped Documentation and Examples: Updated docs with detailed examples make it easier than ever for developers to get started and leverage new capabilities.

The story of Pydantic’s growth and development is one of continued success and refinement. From its early days, the library has steadily evolved to meet developers’ needs, all while maintaining its core promise of simplicity and effectiveness. Today, Pydantic stands as one of the most popular and widely used solutions for data validation in Python, offering a rich feature set and robust performance that developers rely on daily.

Fundamentals of Working

Basic Data Types

As a tool for data validation and serialization, Pydantic supports a wide range of basic data types, enabling developers to define and validate incoming data with precision. These data types cover the most commonly used structures, giving you the flexibility and reliability you need to handle various forms of data.

1. Numeric Types

  • int — Represents integer values.
  • float — Represents floating-point numbers, useful for fractional values.
  • Decimal — High-precision decimal numbers, often used in financial applications where exactness is crucial.
from pydantic import BaseModel, Decimal

class Product(BaseModel):
    id: int
    price: float
    tax: Decimal

2. String Types

  • str — Represents textual data.
class User(BaseModel):
    username: str
    first_name: str
    last_name: str

3. Boolean Types

  • bool — Represents logical values (True or False).
class FeatureFlag(BaseModel):
    is_enabled: bool

4. Lists and Tuples

  • list — A collection (list) of elements of a single type.
  • tuple — A tuple that can hold elements of one or multiple types.
class Order(BaseModel):
    items: list[str]
    quantities: tuple[int, int, int]

5. Dictionaries

  • dict — Represents key-value pairs.
class Config(BaseModel):
    settings: dict[str, str]

6. Dates and Times

  • datetime — Represents both date and time.
  • date — Represents a date.
  • time — Represents a time of day.
  • timedelta — Represents the difference between two points in time.
from datetime import datetime, timedelta

class Event(BaseModel):
    start_datetime: datetime
    end_datetime: datetime
    duration: timedelta

7. UUID

  • UUID — A universally unique identifier.
from uuid import UUID

class Item(BaseModel):
    id: UUID
    name: str

8. EmailStr and AnyUrl

  • EmailStr — Validates that a given string is a valid email address.
  • AnyUrl — Validates that a given string is a well-formed URL.
from pydantic import EmailStr, AnyUrl

class Contact(BaseModel):
    email: EmailStr
    website: AnyUrl

Data Models

Data models are at the core of Pydantic’s validation and serialization capabilities. They allow developers to describe data structures using Python classes, with type annotations defining each field and its attributes. This approach ensures strong typing, automatic validation, and data conversion, significantly simplifying application development.

To create a data model in Pydantic, you inherit from BaseModel and define the model’s fields with type annotations. Each attribute of the class represents a data field, including its type and, optionally, a default value.

from pydantic import BaseModel, EmailStr
from datetime import datetime
from uuid import UUID

class User(BaseModel):
    id: UUID
    username: str
    email: EmailStr
    is_active: bool = True
    created_at: datetime = datetime.now()

In this example, the User model includes several fields: id, username, email, is_active, and created_at. The fields is_active and created_at have default values.

You create a model instance by passing the appropriate values to the model’s constructor. Pydantic automatically validates and converts the data based on the specified types.

from uuid import uuid4

user = User(
    id=uuid4(),
    username="alice",
    email="alice@example.com"
)
print(user)

One of Pydantic’s key features is automatic data validation upon instance creation. Pydantic checks each provided value against the expected type and raises a validation error if the data does not meet the requirements.

try:
    user = User(
        id="not-a-uuid",
        username="alice",
        email="alice@example.com"
    )
except ValueError as e:
    print(e)

In this example, Pydantic raises an error because the id field does not receive a valid UUID.

Pydantic also makes it easy to serialize model data into formats like JSON. This is especially useful in web applications and APIs, where data is frequently transmitted as JSON.

user = User(
    id=uuid4(),
    username="alice",
    email="alice@example.com"
)
user_json = user.model_dump_json()
print(user_json)

The model_dump_json() method converts the model’s data into a JSON-formatted string, ready for transmission or storage.

Pydantic supports nested models, allowing you to create complex data structures. Nested models can serve as field types, enabling hierarchies of objects.

class Address(BaseModel):
    street: str
    city: str
    country: str

class UserWithAddress(BaseModel):
    id: UUID
    username: str
    email: str
    address: Address

address = Address(street="123 Main St", city="New York", country="USA")

user = UserWithAddress(
    id=uuid4(),
    username="alice",
    email="alice@example.com",
    address=address
)

print(user)

By using type annotations and automatic data validation, your code becomes more reliable and readable. Nested models make it possible to work with complex data structures—an essential capability when developing modern web applications and APIs. Pydantic simplifies data handling while maintaining high performance and flexibility.

Model Fields and Annotations

In Pydantic, model fields are key elements that describe the structure and types of the data within the model. Each field is defined as a class attribute, using type annotations to specify the data type.

Type annotations in Python indicate the expected data types for model fields. Pydantic leverages these annotations to automatically validate and transform input data. These annotations can range from simple (e.g., int, str) to more complex types (List[int], Dict[str, Any]).

Example: Defining Fields with Type Annotations

from pydantic import BaseModel
from typing import List, Dict
from uuid import UUID

class Product(BaseModel):
    id: UUID
    name: str
    price: float
    tags: List[str] = []
    metadata: Dict[str, str] = {}

In this example, the Product model includes multiple fields (id, name, price, tags, and metadata). The fields tags and metadata have default values, making them optional when creating an instance.

Common Field Attributes

  1. Default Values
    Default values let you specify standard values for fields not provided at model instantiation.

    class User(BaseModel):
        username: str
        is_active: bool = True
    
  2. Aliases (Alternative Field Names)
    Aliases allow you to use different field names, which can be helpful when interacting with external APIs or systems that use different naming conventions.

    from pydantic import BaseModel, Field
    
    class User(BaseModel):
        username: str
        email: str = Field(alias='user_email')
    
  3. Validators
    Validators enable custom validation rules for fields. Defined using decorators, they let you check field values before they’re saved in the model.

    from pydantic import field_validator
    
    class User(BaseModel):
        username: str
        age: int
    
        @field_validator('age')
        def check_age(cls, v):
            if v < 18:
                raise ValueError('Age must be at least 18')
            return v
    
  4. Configuration Parameters
    Configuration parameters let you customize the behavior of the entire model or specific fields. They are defined in the model_config attribute and passed as dictionary settings.

    from pydantic import ConfigDict
    
    class User(BaseModel):
        username: str
        email: str
    
        model_config = ConfigDict(
            str_strip_whitespace=True,
            str_min_length=1
        )
    

Examples of Using Type Annotations and Field Attributes

Example 1: A Model with Various Field Types and Attributes

from pydantic import BaseModel, Field
from uuid import UUID
from typing import List

class Item(BaseModel):
    id: UUID
    name: str
    description: str = None
    price: float
    tags: List[str] = Field(default_factory=list)

item = Item(
    id="123e4567-e89b-12d3-a456-426614174000",
    name="Laptop",
    price=999.99
)
print(item)

Example 2: A Model with Aliases and Validators

from pydantic import BaseModel, Field, field_validator

class User(BaseModel):
    username: str
    email: str = Field(alias='user_email')
    age: int

    @field_validator('age')
    def check_age(cls, v):
        if v < 18:
            raise ValueError('Age must be at least 18')
        return v

user = User(
    username="alice",
    user_email="alice@example.com",
    age=25
)
print(user)

By using model fields and type annotations, Pydantic provides a flexible mechanism for defining data structures and validating their contents. Type annotations precisely define the expected data types, while field attributes let you set additional parameters and validation rules. This combination ensures both robustness and clarity in your code, streamlining the development process and improving overall quality.

Validation and Data Handling

In this section, we’ll deepen and expand your understanding of how Pydantic validates data and the principles behind these validation mechanisms.

Validating Input Data

Data validation is one of Pydantic’s core functions, ensuring a high level of reliability and security when working with data. Pydantic’s validation principles rely heavily on Python’s type annotations and built-in validation mechanisms, enabling automatic checks to ensure that input data meets specified requirements.

Key Principles of Data Validation:

  1. Type Annotations:
    Type annotations define the expected data types for each model field. When a model instance is created, Pydantic automatically checks the provided data against these annotations. If the data doesn’t match the expected types, Pydantic raises validation errors.

    from pydantic import BaseModel
    
    class User(BaseModel):
        id: int
        name: str
        age: int
    
    # Correct data
    user = User(id=1, name="Alice", age=30)
    
    # Incorrect data
    try:
        user = User(id="one", name="Alice", age="thirty")
    except ValueError as e:
        print(e)
    
  2. Built-In Validators:
    Pydantic includes a range of built-in validators to automatically verify that data matches the expected types and formats. For instance, numeric fields must be numbers, and string fields may be checked for length or invalid characters.

    from pydantic import BaseModel, EmailStr
    
    class User(BaseModel):
        id: int
        email: EmailStr
    
    # Correct email
    user = User(id=1, email="user@example.com")
    
    # Incorrect email
    try:
        user = User(id=2, email="not-an-email")
    except ValueError as e:
        print(e)
    
  3. Custom Validators:
    You can create custom validation rules to handle unique or complex data requirements. This is achieved using the @field_validator decorator, which can be applied to model fields.

    from pydantic import BaseModel, field_validator
    
    class User(BaseModel):
        id: int
        name: str
        age: int
    
        @field_validator('age')
        def check_age(cls, v):
            if v < 0:
                raise ValueError('Age must be a positive integer')
            return v
    
    try:
        user = User(id=1, name="Alice", age=-1)
    except ValueError as e:
        print(e)
    

Data Validation Mechanisms:

  1. Validation at Model Creation:
    The primary validation mechanism in Pydantic occurs when you instantiate a model. All data passed to the model’s constructor is checked against the specified type annotations and validation rules. If the data fails validation, Pydantic raises detailed errors indicating which fields failed and why.
  2. Validating Nested Models:
    If your model includes nested objects, these are also validated according to their own type annotations and rules.

    from pydantic import BaseModel
    
    class Address(BaseModel):
        street: str
        city: str
        zip_code: str
    
    class User(BaseModel):
        id: int
        name: str
        address: Address
    
    address = Address(street="Main St", city="New York", zip_code="10001")
    user = User(id=1, name="Alice", address=address)
    
  3. Validating Complex Data Types:
    Pydantic supports validating complex data types like lists, dictionaries, and other collections. Each element in the collection is validated based on the given type annotations.

    from typing import List
    from pydantic import BaseModel
    
    class User(BaseModel):
        id: int
        name: str
        friends: List[int]
    
    user = User(id=1, name="Alice", friends=[2, 3, 4])
    
    try:
        user = User(id=1, name="Alice", friends=["two", "three"])
    except ValueError as e:
        print(e)
    

Custom Validators

Create custom validators in Pydantic using the @field_validator decorator. This decorator is applied to a method that performs validation on a specific field. The method receives the field’s value and may modify it if necessary. If the value fails validation, the validator should raise a ValueError with a descriptive message.

from pydantic import BaseModel, field_validator

class User(BaseModel):
    username: str
    age: int

    @field_validator('age')
    def check_age(cls, value):
        if value < 18:
            raise ValueError('Age must be at least 18')
        return value

Validators can also access the values of other fields in the model using the values argument, which allows for context-dependent validation rules.

from pydantic import BaseModel, field_validator

class User(BaseModel):
    name: str
    age: int
    email: str

    @field_validator('email')
    def validate_email(cls, value, values):
        age = values.data.get('age')
        if age and age < 18:
            if not value.endswith('@example.com'):
                raise ValueError('Users under 18 must have an @example.com email')
        return value

try:
    user = User(name='Jose', age='15', email='jose@not_example.com')
except ValueError as e:
    print(e)

Custom validators enable you to define complex validation logic—such as regex checks, database lookups, or external API requests—far beyond what built-in validators offer.

import re
from pydantic import BaseModel, field_validator

class User(BaseModel):
    username: str

    @field_validator('username')
    def username_alphanumeric(cls, value):
        if not re.match(r'^[a-zA-Z0-9_]+$', value):
            raise ValueError('Username must be alphanumeric')
        return value

try:
    user = User(username='Jose@')
except ValueError as e:
    print(e)

Custom validators can also apply to nested models, allowing you to validate complex data structures in detail.

from pydantic import BaseModel, field_validator

class Address(BaseModel):
    street: str
    city: str

class User(BaseModel):
    name: str
    address: Address

    @field_validator('address')
    def validate_address(cls, value):
        if not value.street or not value.city:
            raise ValueError('Both street and city must be provided')
        return value

try:
    user = User(name='Jose', address=Address(street='123 Main St', city=''))
except ValueError as e:
    print(e)

Custom validators in Pydantic provide developers with a powerful tool to implement complex data validation rules, offering a high degree of flexibility and accuracy.

Error Handling

Data validation errors are inevitable in any application that deals with external input or user-supplied data. Pydantic offers convenient mechanisms for handling these errors, allowing developers to efficiently respond to invalid input and ensure proper data handling.

The primary exception type for signaling validation errors is ValidationError. This exception is raised when input data does not match the model’s type annotations or validation rules.

from pydantic import BaseModel, ValidationError

class User(BaseModel):
    id: int
    name: str
    age: int

try:
    user = User(id='abc', name='Alice', age='twenty-five')
except ValidationError as e:
    print(e)

In this example, a ValidationError will be raised due to incorrect data types for id and age.

ValidationError includes detailed information about the errors, including a list of issues that occurred during validation. This makes it easy for developers to identify and correct data problems.

The output may show something like:

2 validation errors for User
id
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='abc', input_type=str]
    For further information visit https://errors.pydantic.dev/2.7/v/int_parsing
age
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='twenty-five', input_type=str]
    For further information visit https://errors.pydantic.dev/2.7/v/int_parsing

You can handle validation errors using standard Python exception handling constructs. This allows you to log errors, return informative messages to users, or perform other actions as needed.

import logging
from pydantic import BaseModel, ValidationError

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class User(BaseModel):
    id: int
    name: str
    age: int

try:
    user = User(id='abc', name='Alice', age='twenty-five')
except ValidationError as e:
    logger.error("Validation error: %s", e)
    # Additional error handling logic

In addition to using ValidationError, you can create custom exceptions for more specialized error handling. This can be useful if you need custom handling logic for different types of validation errors.

from pydantic import BaseModel, field_validator

class CustomValidationError(Exception):
    def __init__(self, message: str):
        super().__init__(message)

class User(BaseModel):
    id: int
    name: str
    age: int

    @field_validator('age')
    def check_age(cls, value):
        if value < 0:
            raise CustomValidationError('Age must be a positive integer')
        return value

try:
    user = User(id=1, name='Alice', age=-5)
except CustomValidationError as e:
    print(e)

# Output: Age must be a positive integer

Error handling in Pydantic is a critical aspect of application development that ensures data correctness and improves reliability. By leveraging ValidationError, custom validators, and logging, developers can effectively manage errors and maintain high data quality.

Advanced Features

Working with Nested Models

Pydantic enables developers to work with nested models, allowing you to create and validate complex data structures. Nested models are particularly useful when dealing with hierarchical data or data that contains multiple layers of nesting. By leveraging nested models, you improve both the readability and maintainability of your code while making data handling more straightforward.

You define nested models in Pydantic just like normal models, but use them as field types within other models. This approach lets you build rich, multi-layered data structures where each nested model can maintain its own validation rules and attributes.

from pydantic import BaseModel

class Address(BaseModel):
    street: str
    city: str
    zip_code: str

class User(BaseModel):
    id: int
    name: str
    address: Address

When creating model instances that include nested models, Pydantic automatically instantiates and validates these nested models. This eliminates the need to manually validate each level of the data structure.

address_data = {
    "street": "123 Main St",
    "city": "New York",
    "zip_code": "10001"
}

user_data = {
    "id": 1,
    "name": "Alice",
    "address": address_data
}

user = User(**user_data)
print(user)

Pydantic validates data at every level of nesting. If a nested model fails validation, these errors are included in a single ValidationError structure for easy debugging.

from pydantic import BaseModel, ValidationError

class Address(BaseModel):
    street: str
    city: str
    zip_code: str

class User(BaseModel):
    id: int
    name: str
    address: Address

invalid_user_data = {
    "id": 1,
    "name": "Alice",
    "address": {
        "street": "123 Main St",
        "city": "New York",
        "zip_code": 10001  # Should be a string, not int
    }
}

try:
    user = User(**invalid_user_data)
except ValidationError as e:
    print(e.json())

Benefits of Nested Models:

  • Improved Readability and Maintainability: Nested models break down complex data structures into logical components, making the code easier to read and maintain.
  • Automatic Validation at All Levels: Pydantic automatically validates data at every level of nesting, reducing the risk of errors and ensuring data integrity.
  • Model Reusability: Nested models can be reused in different parts of the application, cutting down on code duplication and simplifying updates.

Examples of Nested Models:

Example 1: An Order Model with Nested Models for Items and Shipping Address

from pydantic import BaseModel
from typing import List

class Item(BaseModel):
    name: str
    price: float

class Address(BaseModel):
    street: str
    city: str
    zip_code: str

class Order(BaseModel):
    order_id: int
    items: List[Item]
    shipping_address: Address

order_data = {
    "order_id": 123,
    "items": [
        {"name": "Laptop", "price": 999.99},
        {"name": "Mouse", "price": 25.75}
    ],
    "shipping_address": {
        "street": "456 Elm St",
        "city": "Los Angeles",
        "zip_code": "90001"
    }
}

order = Order(**order_data)
print(order)

Example 2: A Company Model with Nested Models for Employees and Office Address

from pydantic import BaseModel
from typing import List

class Address(BaseModel):
    street: str
    city: str
    zip_code: str

class Employee(BaseModel):
    employee_id: int
    name: str
    position: str

class Company(BaseModel):
    name: str
    address: Address
    employees: List[Employee]

company_data = {
    "name": "Tech Corp",
    "address": {
        "street": "789 Maple Ave",
        "city": "San Francisco",
        "zip_code": "94107"
    },
    "employees": [
        {"employee_id": 1, "name": "John Doe", "position": "CEO"},
        {"employee_id": 2, "name": "Jane Smith", "position": "CTO"}
    ]
}

company = Company(**company_data)
print(company)

Dynamic Model Creation

Sometimes, you need to dynamically create data models based on parameters or configurations known only at runtime. Pydantic’s create_model function lets you define models on the fly, giving you flexibility in scenarios where the data structure isn’t known in advance or can change depending on application logic.

create_model allows you to dynamically create new Pydantic models by defining their fields and attributes at runtime. This is especially useful when working with evolving data schemas, loading configurations from external sources, or generating test models.

from pydantic import BaseModel, create_model

# Dynamically create a model
DynamicModel = create_model(
    'DynamicModel',
    name=(str, ...),  # A required string field
    age=(int, 30)      # An int field with a default value of 30
)

# Creating an instance of the dynamic model
dynamic_instance = DynamicModel(name='Alice')
print(dynamic_instance)

Features of create_model:

  • Field Definition: Fields are defined as keyword arguments to create_model where the key is the field name and the value is a tuple containing the field type and its default value. If no default is specified, the field is required.
  • Validators and Configuration: You can add validators using @field_validator and include custom configuration via a derived class.
  • Inheritance from Existing Models: create_model can base new models on existing ones, inheriting fields, validators, and configurations. This makes it easy to extend models dynamically without duplicating code.
from pydantic import BaseModel, create_model, ValidationError, field_validator

# Base model
class BaseUser(BaseModel):
    id: int

# Dynamic model creation
DynamicUser = create_model(
    'DynamicUser',
    __base__=BaseUser,
    name=(str, ...),
    age=(int, ...)
)

# Add a validator
class DynamicUserWithValidator(DynamicUser):
    @field_validator('age')
    def check_age(cls, v):
        if v < 18:
            raise ValueError('Age must be at least 18')
        return v

# Add configuration via inheritance
class ConfiguredUser(DynamicUserWithValidator):
    class Config:
        str_min_length = 1

# Creating an instance of the configured dynamic model
try:
    dynamic_user = ConfiguredUser(id=1, name='Bob', age=17)
except ValidationError as e:
    print(e)

Dynamic model creation is particularly handy when structures are derived from external configurations like JSON schemas, databases, or user input.

import json
from pydantic import create_model

# Example JSON schema
json_schema = '''
{
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "age": {"type": "integer"},
        "email": {"type": "string"}
    },
    "required": ["name", "email"]
}
'''

# Parse the JSON schema
schema = json.loads(json_schema)

# Create a model based on the JSON schema
fields = {
    key: (str if value['type'] == 'string' else int, ...)
    for key, value in schema['properties'].items()
}
DynamicModelFromJSON = create_model('DynamicModelFromJSON', **fields)

# Instantiate the dynamically created model
instance = DynamicModelFromJSON(name='Alice', email='alice@example.com', age=25)
print(instance)

Benefits of create_model:

  • Flexibility: Dynamic model creation lets you adapt to changes in data structures and configurations without defining every possible structure upfront.
  • Convenience: Perfect for temporary or ad-hoc data structures, making it ideal for prototyping, testing, or one-off scenarios.
  • Integration: Dynamically created models can inherit from existing models, reuse their validators, and integrate seamlessly into your existing codebase.

Validating Complex Data Types

Validating Lists:
Lists are a common data structure, and Pydantic makes list validation straightforward. You define a list by using Python type annotations, such as List[str].

class User(BaseModel):
    id: int
    name: str
    tags: list[str]

user = User(id=1, name='Alice', tags=['admin', 'user'])
print(user)

If the data doesn’t match the expected list type, Pydantic raises a validation error:

try:
    user = User(id=1, name='Alice', tags='not-a-list')
except ValidationError as e:
    print(e)

Validating Dictionaries:
Dictionaries store key-value pairs, and Pydantic supports dictionary validation using type annotations like dict[str, str].

class Config(BaseModel):
    settings: dict[str, str]

config = Config(settings={"theme": "dark", "language": "en"})
print(config)

If the data is not a valid dictionary matching the specified structure, Pydantic raises an error:

try:
    config = Config(settings="not-a-dict")
except ValidationError as e:
    print(e)

Validating Custom Types:
You can also define custom types and constraints. For instance, you might use a conint (constrained integer) to ensure a value is positive.

from pydantic import BaseModel, conint

class PositiveInt(BaseModel):
    value: conint(gt=0)

positive_value = PositiveInt(value=10)
print(positive_value)

try:
    negative_value = PositiveInt(value=-10)
except ValueError as e:
    print(e)

Complex Nested Types:
Pydantic supports composing complex data structures from lists, dictionaries, and custom types. This is particularly useful for validating and handling deeply nested and layered data.

from pydantic import BaseModel
from typing import List, Dict

class Address(BaseModel):
    street: str
    city: str

class User(BaseModel):
    id: int
    name: str
    addresses: List[Address]
    preferences: Dict[str, str]

user_data = {
    "id": 1,
    "name": "Alice",
    "addresses": [
        {"street": "123 Main St", "city": "New York"},
        {"street": "456 Elm St", "city": "Los Angeles"}
    ],
    "preferences": {"theme": "dark", "language": "en"}
}

user = User(**user_data)
print(user)

Integration with Other Libraries

FastAPI

FastAPI is a modern Python web framework for building APIs, notable for its speed, ease of use, and support for asynchronous operations. One of FastAPI’s standout features is its tight integration with Pydantic, which enables automatic validation and serialization of incoming API data. This greatly simplifies the development of reliable and secure web applications.

FastAPI uses Pydantic models to define request and response schemas, automating data validation and improving code quality through typed models.

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()

class User(BaseModel):
    id: int
    name: str
    email: EmailStr

@app.post("/users/")
async def create_user(user: User):
    return user

In this example, the API receives JSON user data, validates it with a Pydantic model, and returns the data. FastAPI automatically validates request data against Pydantic models and returns clear error messages if validation fails.

{
    "id": "not-an-integer",
    "name": "Alice",
    "email": "alice@example.com"
}

FastAPI will automatically return a detailed error message:

{
    "detail": [
        {
            "type": "int_parsing",
            "loc": [
                "body",
                "id"
            ],
            "msg": "Input should be a valid integer, unable to parse string as an integer",
            "input": "not-an-integer"
        }
    ]
}

FastAPI and Pydantic also support nested models and complex data structures, enabling you to build APIs that handle intricate data relationships.

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
from typing import List

app = FastAPI()

class Address(BaseModel):
    street: str
    city: str

class User(BaseModel):
    id: int
    name: str
    email: EmailStr
    addresses: List[Address]

@app.post("/users/")
async def create_user(user: User):
    return user

FastAPI automatically generates interactive API documentation, including request and response schemas derived from Pydantic models, making your API self-documenting and easier to integrate with other systems.

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class User(BaseModel):
    id: int
    name: str
    email: str

users = {}

@app.post("/users/", response_model=User)
async def create_user(user: User):
    if user.id in users:
        raise HTTPException(status_code=400, detail="User already exists")
    users[user.id] = user
    return user

@app.get("/users/{user_id}", response_model=User)
async def get_user(user_id: int):
    if user_id not in users:
        raise HTTPException(status_code=404, detail="User not found")
    return users[user_id]

Because FastAPI supports asynchronous operations, you can build high-performance, scalable APIs. Integration with Pydantic remains simple and effective, even when using async code.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float

@app.post("/items/")
async def create_item(item: Item):
    # Perform async data processing
    await some_async_function(item)
    return item

FastAPI and Pydantic are used in various real-world projects to create reliable, scalable web services—such as RESTful APIs for e-commerce platforms, user management systems, and integration gateways for microservices.

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List

app = FastAPI()

class Item(BaseModel):
    id: int
    name: str
    price: float

items = {}

@app.post("/items/", response_model=Item)
async def create_item(item: Item):
    if item.id in items:
        raise HTTPException(status_code=400, detail="Item already exists")
    items[item.id] = item
    return item

@app.get("/items/", response_model=List[Item])
async def list_items():
    return list(items.values())

@app.get("/items/{item_id}", response_model=Item)
async def get_item(item_id: int):
    if item_id not in items:
        raise HTTPException(status_code=404, detail="Item not found")
    return items[item_id]

SQLAlchemy

Integrating Pydantic with SQLAlchemy involves using Pydantic models to validate data stored in or retrieved from the database. This approach combines the strengths of both libraries: SQLAlchemy for data management and Pydantic for strict data validation.

First, define your SQLAlchemy models that represent database tables. For example, a User model:

from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, primary_key=True, index=True)
    name = Column(String, index=True)
    email = Column(String, unique=True, index=True)

DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base.metadata.create_all(bind=engine)

Next, create corresponding Pydantic models for validating incoming and outgoing data:

from pydantic import BaseModel, EmailStr

class UserCreate(BaseModel):
    name: str
    email: EmailStr

class UserRead(BaseModel):
    id: int
    name: str
    email: EmailStr

    class Config:
        from_attributes = True

UserCreate is used to validate incoming data when creating a new user, and UserRead is used to serialize data retrieved from the database.

Here’s how to use Pydantic and SQLAlchemy together in a FastAPI endpoint:

from fastapi import FastAPI, HTTPException, Depends
from sqlalchemy.orm import Session

app = FastAPI()

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.post("/users/", response_model=UserRead)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
    db_user = db.query(User).filter(User.email == user.email).first()
    if db_user:
        raise HTTPException(status_code=400, detail="Email already registered")
    new_user = User(name=user.name, email=user.email)
    db.add(new_user)
    db.commit()
    db.refresh(new_user)
    return new_user

@app.get("/users/{user_id}", response_model=UserRead)
def read_user(user_id: int, db: Session = Depends(get_db)):
    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    return user

Update and delete operations can also be validated with Pydantic:

class UserUpdate(BaseModel):
    name: str
    email: EmailStr

@app.put("/users/{user_id}", response_model=UserRead)
def update_user(user_id: int, user_update: UserUpdate, db: Session = Depends(get_db)):
    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    user.name = user_update.name
    user.email = user_update.email
    db.commit()
    db.refresh(user)
    return user

@app.delete("/users/{user_id}", response_model=UserRead)
def delete_user(user_id: int, db: Session = Depends(get_db)):
    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    db.delete(user)
    db.commit()
    return user

Benefits of Integrating Pydantic and SQLAlchemy:

  • Strict Data Validation: Pydantic ensures that data is validated before being saved to the database, improving data integrity.
  • Easy Data Handling: Pydantic models make it simple to serialize and deserialize data, facilitating easy JSON handling.
  • Improved Readability and Maintainability: Separating Pydantic validation models and SQLAlchemy ORM models keeps the codebase clean, well-structured, and easier to maintain.

Django

Integrating Pydantic with Django involves using Pydantic models for data validation and serialization, enhancing code reliability and readability. The main steps are to define Pydantic models and use them to validate data in Django views and other components.

First, create Pydantic models for validation and serialization. For example:

from pydantic import BaseModel, EmailStr

class UserCreate(BaseModel):
    username: str
    email: EmailStr
    password: str

class UserRead(BaseModel):
    id: int
    username: str
    email: EmailStr

    class Config:
        from_attributes = True

You can use these Pydantic models in Django views to validate incoming request data:

from django.http import JsonResponse
from django.views import View
from pydantic import ValidationError
from .models import User as DjangoUser
from .pydantic_models import UserCreate, UserRead

class CreateUserView(View):
    def post(self, request, *args, **kwargs):
        try:
            data = UserCreate.model_validate_json(request.body)
        except ValidationError as e:
            return JsonResponse(e.errors(), status=400, safe=False)

        user = DjangoUser.objects.create(
            username=data.username,
            email=data.email,
            password=data.password  # Remember to hash passwords
        )
        user_data = UserRead.model_validate(user)
        return JsonResponse(user_data.model_dump())

While Django offers its own forms for validation, Pydantic can be used for extra checks and transformations:

from django import forms
from pydantic import BaseModel, EmailStr, ValidationError

class UserCreate(BaseModel):
    username: str
    email: EmailStr
    password: str

class UserForm(forms.Form):
    username = forms.CharField(max_length=100)
    email = forms.EmailField()
    password = forms.CharField(widget=forms.PasswordInput)

    def clean(self):
        cleaned_data = super().clean()
        try:
            UserCreate(**cleaned_data)
        except ValidationError as e:
            raise forms.ValidationError(e.errors())

Pydantic can also be used for serialization, for example when returning JSON responses from Django views:

from django.http import JsonResponse
from django.views import View
from .models import User as DjangoUser
from .pydantic_models import UserRead

class ListUsersView(View):
    def get(self, request, *args, **kwargs):
        users = DjangoUser.objects.all()
        users_data = [UserRead.model_validate(user) for user in users]
        return JsonResponse([user.model_dump() for user in users_data], safe=False)

Advantages of Using Pydantic with Django:

  • Strict Data Validation: Pydantic provides a high level of control over incoming data, reducing errors and increasing application reliability.
  • Convenience and Clarity: Pydantic models simplify defining and using data structures, improving code readability and maintainability.
  • Reusability: You can reuse Pydantic models across different parts of your application (views, forms, serializers), reducing duplication and simplifying updates.

Read also:

ChatGPT
Eva
💫 Eva assistant