Pydantic Integration
The Pydantic integration enables seamless use of valid8r parsers with Pydantic models, allowing you to leverage valid8r’s powerful parsing and validation capabilities in FastAPI applications, data validation workflows, and anywhere Pydantic is used.
Why Integrate valid8r with Pydantic?
Reusable Validation Logic: Use the same valid8r parsers across CLI prompts, API validation, configuration parsing, and data processing without duplicating validation logic.
Type-Safe Parsing: Valid8r’s Maybe[T] pattern ensures type safety and explicit error handling, which integrates naturally with Pydantic’s validation framework.
Rich Error Messages: valid8r provides user-friendly, detailed error messages that flow through to Pydantic’s ValidationError, helping users understand what went wrong.
Complex Nested Structures: Full support for nested models, lists of models, dict values, and deeply nested hierarchies with complete field path reporting.
Installation
valid8r’s Pydantic integration is included in the main package:
pip install valid8r pydantic
Or with uv:
uv add valid8r pydantic
Quick Start
Basic Field Validation
from pydantic import BaseModel, field_validator
from valid8r.core import parsers, validators
from valid8r.integrations.pydantic import validator_from_parser
class User(BaseModel):
age: int
email: str
@field_validator('age', mode='before')
@classmethod
def validate_age(cls, v):
return validator_from_parser(
parsers.parse_int & validators.between(0, 120)
)(v)
@field_validator('email', mode='before')
@classmethod
def validate_email(cls, v):
return validator_from_parser(parsers.parse_email)(v)
# Valid user
user = User(age='25', email='alice@example.com')
print(user.age) # 25 (parsed from string)
print(user.email) # EmailAddress(local='alice', domain='example.com')
# Invalid age (out of range)
try:
User(age='200', email='alice@example.com')
except ValidationError as e:
print(e) # Error message includes "between 0 and 120"
Core API
validator_from_parser()
Convert a valid8r parser into a Pydantic field validator.
def validator_from_parser(
parser: Callable[[Any], Maybe[T]],
*,
error_prefix: str | None = None,
) -> Callable[[Any], T]:
"""Convert a valid8r parser into a Pydantic field validator."""
Parameters:
parser: Any valid8r parser function that returnsMaybe[T]error_prefix(optional): Custom prefix to prepend to error messages
Returns: A validator function suitable for use with @field_validator
Example:
from pydantic import BaseModel, field_validator
from valid8r.core import parsers
from valid8r.integrations.pydantic import validator_from_parser
class Config(BaseModel):
port: int
@field_validator('port', mode='before')
@classmethod
def validate_port(cls, v):
return validator_from_parser(
parsers.parse_int,
error_prefix='Server port'
)(v)
# Custom error prefix appears in validation errors
try:
Config(port='invalid')
except ValidationError as e:
print(e) # "Server port: ..."
Nested Model Validation
Two-Level Nesting
Valid8r parsers work seamlessly with nested Pydantic models. Pydantic automatically includes the full field path in validation errors.
from pydantic import BaseModel, field_validator
from valid8r.core import parsers
from valid8r.core.parsers import PhoneNumber
from valid8r.integrations.pydantic import validator_from_parser
class Address(BaseModel):
street: str
phone: PhoneNumber
@field_validator('phone', mode='before')
@classmethod
def validate_phone(cls, v):
return validator_from_parser(parsers.parse_phone)(v)
class User(BaseModel):
name: str
address: Address
# Valid nested model
user = User(
name='Alice',
address={'street': '123 Main St', 'phone': '(206) 234-5678'}
)
print(user.address.phone.area_code) # '206'
# Invalid phone number - error includes field path
try:
User(name='Bob', address={'street': '456 Elm St', 'phone': 'invalid'})
except ValidationError as e:
errors = e.errors()
print(errors[0]['loc']) # ('address', 'phone')
print(errors[0]['msg']) # Contains valid8r error message
Three-Level+ Nesting
Deep nesting works without modification. Field paths are preserved through all levels.
from pydantic import BaseModel, field_validator
from valid8r.core import parsers
from valid8r.core.parsers import EmailAddress
from valid8r.integrations.pydantic import validator_from_parser
class Employee(BaseModel):
email: EmailAddress
@field_validator('email', mode='before')
@classmethod
def validate_email(cls, v):
return validator_from_parser(parsers.parse_email)(v)
class Department(BaseModel):
lead: Employee
class Company(BaseModel):
engineering: Department
# Valid deeply nested model
company = Company(
engineering={'lead': {'email': 'cto@example.com'}}
)
print(company.engineering.lead.email.local) # 'cto'
# Invalid email - field path includes all levels
try:
Company(engineering={'lead': {'email': 'not-an-email'}})
except ValidationError as e:
errors = e.errors()
print(errors[0]['loc']) # ('engineering', 'lead', 'email')
Optional Nested Models
Use Optional[Model] or Model | None for optional nested fields.
from pydantic import BaseModel, field_validator
from valid8r.core import parsers
from valid8r.core.parsers import PhoneNumber
from valid8r.integrations.pydantic import validator_from_parser
class Address(BaseModel):
phone: PhoneNumber
@field_validator('phone', mode='before')
@classmethod
def validate_phone(cls, v):
return validator_from_parser(parsers.parse_phone)(v)
class User(BaseModel):
name: str
address: Address | None = None
# User with address
user1 = User(name='Alice', address={'phone': '(206) 234-5678'})
print(user1.address.phone) # PhoneNumber object
# User without address
user2 = User(name='Bob', address=None)
print(user2.address) # None
# User with missing address (uses default)
user3 = User(name='Charlie')
print(user3.address) # None
Collection Validation
List of Models
Validate lists of models with per-item validation. Pydantic reports errors with list indices.
from pydantic import BaseModel, field_validator
from valid8r.core import parsers, validators
from valid8r.integrations.pydantic import validator_from_parser
class LineItem(BaseModel):
product_name: str
quantity: int
@field_validator('quantity', mode='before')
@classmethod
def validate_quantity(cls, v):
parser = lambda value: parsers.parse_int(value).bind(validators.minimum(1))
return validator_from_parser(parser)(v)
class Order(BaseModel):
items: list[LineItem]
# Valid order
order = Order(items=[
{'product_name': 'Widget', 'quantity': '5'},
{'product_name': 'Gadget', 'quantity': '10'}
])
print(order.items[0].quantity) # 5
# Invalid: quantity below minimum
try:
Order(items=[
{'product_name': 'Widget', 'quantity': '5'},
{'product_name': 'Gadget', 'quantity': '0'} # Invalid
])
except ValidationError as e:
errors = e.errors()
print(errors[0]['loc']) # ('items', 1, 'quantity')
print(errors[0]['msg']) # Contains "minimum" or "at least"
Dict Value Validation
Validate dictionary values using valid8r parsers.
from pydantic import BaseModel, field_validator
from valid8r.core import parsers
from valid8r.integrations.pydantic import validator_from_parser
class Config(BaseModel):
ports: dict[str, int]
@field_validator('ports', mode='before')
@classmethod
def validate_ports(cls, v):
if not isinstance(v, dict):
raise TypeError('ports must be a dict')
return {k: validator_from_parser(parsers.parse_int)(val) for k, val in v.items()}
# Valid config
config = Config(ports={'http': '80', 'https': '443', 'ssh': '22'})
print(config.ports) # {'http': 80, 'https': 443, 'ssh': 22}
# Invalid: non-integer value
try:
Config(ports={'http': '80', 'https': 'invalid'})
except ValidationError as e:
print(e) # Error mentions parsing failure
AfterValidator and WrapValidator Patterns
Pydantic v2 introduced AfterValidator and WrapValidator for more flexible validation approaches. Valid8r provides helper functions to create these validators from parsers.
AfterValidator
AfterValidator runs validation after Pydantic’s type conversion. Use this when you want to validate already-typed values.
from typing_extensions import Annotated
from pydantic import BaseModel, AfterValidator
from valid8r.integrations.pydantic import make_after_validator
from valid8r.core import parsers, validators
# AfterValidator with a parser (field type: str)
class User(BaseModel):
email: Annotated[str, AfterValidator(make_after_validator(parsers.parse_email))]
user = User(email='alice@example.com')
print(user.email) # EmailAddress(local='alice', domain='example.com')
# AfterValidator with a validator (field type: int)
class Config(BaseModel):
port: Annotated[int, AfterValidator(make_after_validator(
validators.minimum(1) & validators.maximum(65535)
))]
config = Config(port=8080) # Valid
# Config(port=70000) # Raises ValidationError
Key Points:
AfterValidator receives already-typed values (e.g.,
int,str, etc.)For typed fields like
Annotated[int, ...], use validators (not parsers)For string fields with complex parsing, use parsers
Automatically handles
Nonefor optional fields
WrapValidator
WrapValidator runs before Pydantic’s type conversion, giving you full control over validation and pre-processing.
from pydantic import WrapValidator
from valid8r.integrations.pydantic import make_wrap_validator
class Data(BaseModel):
port: Annotated[int, WrapValidator(make_wrap_validator(
parsers.parse_int & validators.minimum(1) & validators.maximum(65535)
))]
# Receives raw string input, parses and validates
data = Data(port='8080') # Valid
print(data.port) # 8080 (parsed from string)
Key Points:
WrapValidator receives raw input (before type conversion)
Ideal for custom parsing + validation pipelines
Use with chained parsers and validators
Optional Fields
Both AfterValidator and WrapValidator handle optional fields automatically by passing through None values:
class Contact(BaseModel):
# Optional field with AfterValidator
email: Annotated[str | None, AfterValidator(make_after_validator(parsers.parse_email))] = None
contact1 = Contact(email='alice@example.com') # Valid
print(contact1.email) # EmailAddress object
contact2 = Contact(email=None) # Valid - None passed through
print(contact2.email) # None
contact3 = Contact() # Valid - uses default
print(contact3.email) # None
When to Use Each Pattern
Pattern |
Runs |
Input Type |
Use Case |
|---|---|---|---|
|
Decorator on class |
Depends on |
Complex validation with access to other fields |
|
After type conversion |
Already typed |
Validating typed values, composable annotations |
|
Before type conversion |
Raw input |
Custom parsing + validation, pre-processing |
Choosing Between Patterns
Use AfterValidator when:
You want inline validation in type annotations
Working with already-typed fields (e.g.,
int,bool)Need composable validators without decorators
Validating parsed structured types
Use WrapValidator when:
You need to parse raw string input
Want full control over the validation flow
Combining parsing and validation in one step
Pre-processing input before Pydantic’s type conversion
Use field_validator when:
Need access to other field values
Complex validation logic
Cross-field validation
Backward compatibility with Pydantic v1
Migration from field_validator
# Before: field_validator
class User(BaseModel):
email: str
@field_validator('email', mode='before')
@classmethod
def validate_email(cls, v):
return validator_from_parser(parsers.parse_email)(v)
# After: AfterValidator (cleaner, more composable)
class User(BaseModel):
email: Annotated[str, AfterValidator(make_after_validator(parsers.parse_email))]
API Reference
make_after_validator()
Convert a valid8r parser/validator into a Pydantic AfterValidator.
def make_after_validator(
parser: Callable[[Any], Maybe[T]],
) -> Callable[[Any], T | None]:
"""Create a Pydantic AfterValidator from a valid8r parser."""
Parameters:
parser: Any valid8r parser or validator function that returnsMaybe[T]
Returns: A validator function suitable for use with AfterValidator
Behavior:
Passes through
Nonefor optional fieldsConverts
Success(value)tovalueConverts
Failure(error)toValueError(error)
make_wrap_validator()
Convert a valid8r parser/validator into a Pydantic WrapValidator.
def make_wrap_validator(
parser: Callable[[Any], Maybe[T]],
) -> Callable[[Any, Any], T]:
"""Create a Pydantic WrapValidator from a valid8r parser."""
Parameters:
parser: Any valid8r parser or validator function that returnsMaybe[T]
Returns: A wrap validator function suitable for use with WrapValidator
Behavior:
Receives raw input before Pydantic’s type conversion
Ignores the
handlerparameter (parser handles all validation)Converts
Success(value)tovalueConverts
Failure(error)toValueError(error)
Advanced Patterns
Chained Validators in Nested Models
Combine multiple valid8r validators in deeply nested structures.
from pydantic import BaseModel, field_validator
from valid8r.core import parsers, validators
from valid8r.integrations.pydantic import validator_from_parser
class Product(BaseModel):
name: str
price: int
@field_validator('price', mode='before')
@classmethod
def validate_price(cls, v):
# Parse as int and validate range (price in cents: $0.01 - $100.00)
parser = lambda value: parsers.parse_int(value).bind(validators.between(1, 10000))
return validator_from_parser(parser)(v)
class CartItem(BaseModel):
product: Product
quantity: int
@field_validator('quantity', mode='before')
@classmethod
def validate_quantity(cls, v):
parser = lambda value: parsers.parse_int(value).bind(validators.minimum(1))
return validator_from_parser(parser)(v)
class Cart(BaseModel):
items: list[CartItem]
# Valid cart with nested validation
cart = Cart(items=[
{'product': {'name': 'Widget', 'price': '999'}, 'quantity': '2'},
{'product': {'name': 'Gadget', 'price': '499'}, 'quantity': '1'}
])
# Invalid: price out of range
try:
Cart(items=[
{'product': {'name': 'Expensive', 'price': '999999'}, 'quantity': '1'}
])
except ValidationError as e:
errors = e.errors()
print(errors[0]['loc']) # ('items', 0, 'product', 'price')
print(errors[0]['msg']) # Contains "between 1 and 10000"
Custom Error Messages
Use the error_prefix parameter to customize error messages for better user feedback.
from pydantic import BaseModel, field_validator
from valid8r.core import parsers
from valid8r.integrations.pydantic import validator_from_parser
class ServerConfig(BaseModel):
host: str
port: int
max_connections: int
@field_validator('port', mode='before')
@classmethod
def validate_port(cls, v):
return validator_from_parser(
parsers.parse_int,
error_prefix='Port number'
)(v)
@field_validator('max_connections', mode='before')
@classmethod
def validate_max_connections(cls, v):
return validator_from_parser(
parsers.parse_int,
error_prefix='Max connections'
)(v)
# Errors include custom prefixes
try:
ServerConfig(host='localhost', port='invalid', max_connections='100')
except ValidationError as e:
print(e) # "Port number: ..." appears in error
FastAPI Integration
Use valid8r parsers in FastAPI request validation for consistent validation across your API.
from fastapi import FastAPI
from pydantic import BaseModel, field_validator
from valid8r.core import parsers, validators
from valid8r.core.parsers import EmailAddress, PhoneNumber
from valid8r.integrations.pydantic import validator_from_parser
app = FastAPI()
class Address(BaseModel):
street: str
city: str
phone: PhoneNumber
@field_validator('phone', mode='before')
@classmethod
def validate_phone(cls, v):
return validator_from_parser(parsers.parse_phone)(v)
class UserCreate(BaseModel):
name: str
email: EmailAddress
age: int
address: Address
@field_validator('email', mode='before')
@classmethod
def validate_email(cls, v):
return validator_from_parser(parsers.parse_email)(v)
@field_validator('age', mode='before')
@classmethod
def validate_age(cls, v):
parser = lambda value: parsers.parse_int(value).bind(validators.between(0, 120))
return validator_from_parser(parser)(v)
@app.post('/users/')
async def create_user(user: UserCreate):
return {
'name': user.name,
'email': str(user.email),
'age': user.age,
'phone': str(user.address.phone)
}
# FastAPI automatically validates requests:
# POST /users/
# {
# "name": "Alice",
# "email": "alice@example.com",
# "age": "25",
# "address": {
# "street": "123 Main St",
# "city": "Seattle",
# "phone": "(206) 234-5678"
# }
# }
# → Success
# Invalid phone number returns 422 with field path:
# POST /users/
# {
# "name": "Bob",
# "email": "bob@example.com",
# "age": "30",
# "address": {
# "street": "456 Elm St",
# "city": "Portland",
# "phone": "invalid"
# }
# }
# → 422 Unprocessable Entity
# {
# "detail": [{
# "loc": ["body", "address", "phone"],
# "msg": "...",
# "type": "value_error"
# }]
# }
Environment Configuration with pydantic-settings
For environment variable configuration, combine valid8r with pydantic-settings instead of building custom env var parsing. This provides a more robust solution with additional features like .env file support, nested configuration, and type safety.
Installation
pip install valid8r pydantic pydantic-settings
Or with uv:
uv add valid8r pydantic pydantic-settings
Basic Environment Configuration
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import field_validator
from valid8r.integrations.pydantic import validator_from_parser
from valid8r.core import parsers, validators
class AppSettings(BaseSettings):
"""Application configuration from environment variables."""
model_config = SettingsConfigDict(
env_prefix='APP_', # All env vars start with APP_
env_file='.env', # Load from .env file if present
env_file_encoding='utf-8',
extra='ignore' # Ignore extra env vars
)
# Environment variables: APP_HOST, APP_PORT, APP_DEBUG
host: str = 'localhost'
port: int = 8080
debug: bool = False
@field_validator('port', mode='before')
@classmethod
def validate_port(cls, v):
return validator_from_parser(
parsers.parse_int & validators.between(1, 65535)
)(v)
# Load from environment (or .env file)
settings = AppSettings()
print(f"Server: {settings.host}:{settings.port}")
Environment variables:
export APP_HOST=0.0.0.0
export APP_PORT=3000
export APP_DEBUG=true
Or .env file:
APP_HOST=0.0.0.0
APP_PORT=3000
APP_DEBUG=true
Complex Validation with valid8r
Leverage valid8r’s parsers for structured types and chained validation:
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import field_validator
from valid8r.integrations.pydantic import validator_from_parser
from valid8r.core import parsers, validators
from valid8r.core.parsers import EmailAddress, UrlParts
class DatabaseConfig(BaseSettings):
"""Database configuration from environment."""
model_config = SettingsConfigDict(env_prefix='DB_')
host: str
port: int
name: str
url: UrlParts
@field_validator('port', mode='before')
@classmethod
def validate_port(cls, v):
return validator_from_parser(
parsers.parse_int & validators.between(1, 65535)
)(v)
@field_validator('url', mode='before')
@classmethod
def validate_url(cls, v):
return validator_from_parser(parsers.parse_url)(v)
class EmailConfig(BaseSettings):
"""Email configuration from environment."""
model_config = SettingsConfigDict(env_prefix='EMAIL_')
from_address: EmailAddress
smtp_host: str
smtp_port: int
@field_validator('from_address', mode='before')
@classmethod
def validate_from_address(cls, v):
return validator_from_parser(parsers.parse_email)(v)
@field_validator('smtp_port', mode='before')
@classmethod
def validate_smtp_port(cls, v):
return validator_from_parser(
parsers.parse_int & validators.between(1, 65535)
)(v)
# Load configuration
db_config = DatabaseConfig()
email_config = EmailConfig()
Environment variables:
export DB_HOST=localhost
export DB_PORT=5432
export DB_NAME=myapp
export DB_URL=postgresql://user:pass@localhost:5432/myapp
export EMAIL_FROM_ADDRESS=noreply@example.com
export EMAIL_SMTP_HOST=smtp.example.com
export EMAIL_SMTP_PORT=587
Nested Configuration
Use nested Pydantic models for hierarchical configuration:
from pydantic import BaseModel, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from valid8r.integrations.pydantic import validator_from_parser
from valid8r.core import parsers, validators
class DatabaseSettings(BaseModel):
"""Database settings (nested config)."""
host: str = 'localhost'
port: int = 5432
name: str
@field_validator('port', mode='before')
@classmethod
def validate_port(cls, v):
return validator_from_parser(
parsers.parse_int & validators.between(1, 65535)
)(v)
class RedisSettings(BaseModel):
"""Redis settings (nested config)."""
host: str = 'localhost'
port: int = 6379
@field_validator('port', mode='before')
@classmethod
def validate_port(cls, v):
return validator_from_parser(
parsers.parse_int & validators.between(1, 65535)
)(v)
class AppConfig(BaseSettings):
"""Application configuration with nested settings."""
model_config = SettingsConfigDict(
env_nested_delimiter='__', # APP_DB__HOST
env_prefix='APP_'
)
name: str = 'MyApp'
debug: bool = False
database: DatabaseSettings
redis: RedisSettings
# Load nested configuration
config = AppConfig()
print(f"Database: {config.database.host}:{config.database.port}")
print(f"Redis: {config.redis.host}:{config.redis.port}")
Environment variables (nested with __ delimiter):
export APP_NAME=MyApp
export APP_DEBUG=true
export APP_DB__HOST=postgres.example.com
export APP_DB__PORT=5432
export APP_DB__NAME=production
export APP_REDIS__HOST=redis.example.com
export APP_REDIS__PORT=6379
FastAPI + pydantic-settings Integration
Share configuration between FastAPI and CLI tools using pydantic-settings:
from functools import lru_cache
from fastapi import FastAPI, Depends
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import field_validator
from valid8r.integrations.pydantic import validator_from_parser
from valid8r.core import parsers, validators
class Settings(BaseSettings):
"""Shared application settings."""
model_config = SettingsConfigDict(
env_prefix='APP_',
env_file='.env'
)
api_host: str = '0.0.0.0'
api_port: int = 8000
database_url: str
max_connections: int = 100
@field_validator('api_port', mode='before')
@classmethod
def validate_api_port(cls, v):
return validator_from_parser(
parsers.parse_int & validators.between(1, 65535)
)(v)
@field_validator('max_connections', mode='before')
@classmethod
def validate_max_connections(cls, v):
return validator_from_parser(
parsers.parse_int & validators.minimum(1)
)(v)
@lru_cache
def get_settings() -> Settings:
"""Cached settings instance."""
return Settings()
# FastAPI application
app = FastAPI()
@app.get('/config')
async def get_config(settings: Settings = Depends(get_settings)):
"""Get current configuration."""
return {
'host': settings.api_host,
'port': settings.api_port,
'max_connections': settings.max_connections
}
# CLI tool can use the same settings
if __name__ == '__main__':
import uvicorn
settings = get_settings()
uvicorn.run(
app,
host=settings.api_host,
port=settings.api_port
)
Why Use pydantic-settings Instead of Custom Env Parsing?
pydantic-settings advantages:
✅ .env file support: Automatic loading from
.envfiles✅ Nested configuration: Use
env_nested_delimiterfor hierarchical config✅ Type coercion: Automatic string-to-type conversion
✅ Default values: Built-in support with fallback values
✅ Validation: Full Pydantic validation with valid8r integration
✅ Case sensitivity: Configure case-sensitive or case-insensitive env vars
✅ Extra handling: Control behavior for unknown env vars
✅ Secrets management: Integration with secrets files and services
✅ Industry standard: De facto solution for Python configuration
✅ Zero maintenance: Maintained by the Pydantic team
When to use each approach:
Approach |
Use When |
|---|---|
pydantic-settings |
Application configuration, 12-factor apps, FastAPI services, complex config |
Custom env parsing |
Simple scripts, single env var, non-Pydantic projects |
Recommendation: Always prefer pydantic-settings for application configuration. It provides more features, better error handling, and integrates seamlessly with valid8r parsers through the Pydantic integration.
Error Handling
Field Path Reporting
Pydantic automatically includes the full field path in validation errors, making it easy to identify which field failed validation in nested structures.
from pydantic import ValidationError
# Field paths for different nesting levels:
# 'phone' → Top-level field
# ('address', 'phone') → Nested one level
# ('items', 0, 'quantity') → List item field
# ('engineering', 'lead', 'email') → Three levels deep
try:
Company(engineering={'lead': {'email': 'invalid'}})
except ValidationError as e:
for error in e.errors():
print(f"Field: {'.'.join(str(p) for p in error['loc'])}")
print(f"Error: {error['msg']}")
print(f"Type: {error['type']}")
Extracting Error Messages
from pydantic import ValidationError
try:
user = User(age='999', email='invalid')
except ValidationError as e:
# Get all errors
errors = e.errors()
# Pretty-print for debugging
print(e)
# JSON format for API responses
print(e.json())
# Dict format for programmatic access
for error in errors:
field = '.'.join(str(part) for part in error['loc'])
message = error['msg']
print(f'{field}: {message}')
Best Practices
1. Use mode='before' for Parsers
Always use mode='before' with @field_validator when using valid8r parsers. This ensures the parser receives the raw input (usually a string) before Pydantic’s type coercion.
class User(BaseModel):
age: int
@field_validator('age', mode='before') # ← Important!
@classmethod
def validate_age(cls, v):
return validator_from_parser(parsers.parse_int)(v)
2. Return Parsed Values, Not Maybe
validator_from_parser() handles the Maybe unwrapping for you. The validator function returns the parsed value directly or raises ValueError on failure.
# Good - validator_from_parser handles Maybe
@field_validator('age', mode='before')
@classmethod
def validate_age(cls, v):
return validator_from_parser(parsers.parse_int)(v)
# Bad - don't manually unwrap Maybe
@field_validator('age', mode='before')
@classmethod
def validate_age(cls, v):
result = parsers.parse_int(v)
if result.is_success():
return result.value_or(None)
raise ValueError(result.error_or('Unknown error'))
3. Use Type Hints for Structured Types
Use valid8r’s structured types (EmailAddress, PhoneNumber, UrlParts) as type hints to make the structure explicit.
from valid8r.core.parsers import EmailAddress, PhoneNumber
class Contact(BaseModel):
email: EmailAddress # ← Clear type hint
phone: PhoneNumber # ← Clear type hint
@field_validator('email', mode='before')
@classmethod
def validate_email(cls, v):
return validator_from_parser(parsers.parse_email)(v)
@field_validator('phone', mode='before')
@classmethod
def validate_phone(cls, v):
return validator_from_parser(parsers.parse_phone)(v)
4. Combine Validators Using Bind
Use valid8r’s monadic bind for chaining parsers and validators.
@field_validator('age', mode='before')
@classmethod
def validate_age(cls, v):
# Parse and validate in one pipeline
parser = lambda value: parsers.parse_int(value).bind(validators.between(0, 120))
return validator_from_parser(parser)(v)
5. Provide Meaningful Error Prefixes
Use error_prefix to make error messages more user-friendly.
class Config(BaseModel):
db_port: int
redis_port: int
@field_validator('db_port', mode='before')
@classmethod
def validate_db_port(cls, v):
return validator_from_parser(
parsers.parse_int,
error_prefix='Database port'
)(v)
@field_validator('redis_port', mode='before')
@classmethod
def validate_redis_port(cls, v):
return validator_from_parser(
parsers.parse_int,
error_prefix='Redis port'
)(v)
Comparison with Native Pydantic Validators
valid8r Advantages
Reusable Parsers: Write once, use everywhere (CLI, API, config files)
Explicit Error Handling:
Maybepattern makes success/failure explicitFunctional Composition: Chain validators using monadic operations
Structured Types: Rich result types (
EmailAddress,PhoneNumber, etc.)Comprehensive Parsers: Phone numbers, URLs, UUIDs with version validation, etc.
When to Use Native Pydantic
Simple type coercion (string to int, etc.)
Pydantic-specific features (computed fields, serialization)
When you don’t need valid8r’s additional validation logic
Hybrid Approach
Mix native Pydantic validators with valid8r for best results:
class User(BaseModel):
username: str # Native Pydantic (simple string)
email: EmailAddress # valid8r parser (complex validation)
# Native Pydantic validator
@field_validator('username')
@classmethod
def validate_username(cls, v):
if len(v) < 3:
raise ValueError('Username must be at least 3 characters')
return v
# valid8r parser
@field_validator('email', mode='before')
@classmethod
def validate_email(cls, v):
return validator_from_parser(parsers.parse_email)(v)
Troubleshooting
“Expected X, got Y” Type Errors
Make sure to use mode='before' so the parser receives raw input:
# Problem: mode='after' causes type errors
@field_validator('age', mode='after') # ← Wrong
@classmethod
def validate_age(cls, v):
return validator_from_parser(parsers.parse_int)(v)
# Solution: mode='before' to parse raw input
@field_validator('age', mode='before') # ← Correct
@classmethod
def validate_age(cls, v):
return validator_from_parser(parsers.parse_int)(v)
ValidationError Not Raised
Ensure the validator function returns a value (success) or raises ValueError (failure). validator_from_parser() handles this automatically.
# validator_from_parser handles error raising
@field_validator('age', mode='before')
@classmethod
def validate_age(cls, v):
return validator_from_parser(parsers.parse_int)(v) # ← Correct
Field Path Missing in Errors
Pydantic automatically includes field paths. If paths seem wrong, check your model structure and validator placement.
API Reference
See the API documentation for detailed information about validator_from_parser() and all parameters.