Skip to main content

Pydantic Models & Naming Conventions

Quick reference for Pydantic model patterns, naming conventions and common usage in the starter app. This is just a suggestion.

tip

For comprehensive documentation, see the Pydantic documentation.

Naming convention

Standard naming pattern used in the starter app:

# 1. Base Model (shared fields to avoid duplications between DB models and API models aka DTO)
class TaskBase(ModelWithIdAndTimestamps):
"""Base model with core fields - no table=True"""
title: str = Field(max_length=200, description="Task title")
description: str | None = Field(default=None, max_length=1000)
completed: bool = Field(default=False)
priority: str = Field(default="medium")

# 2. Database Table Model (name without suffix)
class Task(TaskBase, table=True):
"""Database table model - inherits from Base + adds relationships"""
# Foreign keys
user_id: UUID = Field(foreign_key="user.id", index=True)
category_id: UUID | None = Field(foreign_key="category.id", default=None)

# Relationships
user: User = Relationship(back_populates="tasks")
category: Category | None = Relationship(back_populates="tasks")

# 3. API Models: auto-generated from Base with utility functions (see generated docs on `http://localhost:8000/docs`)
CreateTaskDTO = create_create_model(TaskBase, "CreateTask")
UpdateTaskDTO = create_update_model(TaskBase, "UpdateTask")

# 4. API Models: custom
class TaskDTO(BaseDTO, TaskBase):
"""API response model - includes id, timestamps + fields from TaskBase"""
pass

# 5. Extended Response Models (with relationships)
class TaskWithUserDTO(TaskDTO):
"""Task with user information"""
user: UserDTO | None = None

class TaskWithDetailsDTO(TaskDTO):
"""Task with all related data"""
user: UserDTO | None = None
category: CategoryDTO | None = None
tags: List[TagDTO] = []

# Instead of `DTO`, you can also use `Request`/`Response`

Field validation

Common validation patterns:

from pydantic import Field, validator, root_validator
from typing import Optional
import re

class UserBase(ModelWithIdAndTimestamps):
# Basic validation
email: str = Field(
...,
regex=r'^[^@]+@[^@]+\.[^@]+$',
description="Valid email address"
)

# String constraints
first_name: str = Field(min_length=1, max_length=50)
last_name: str = Field(min_length=1, max_length=50)

# Numeric constraints
age: int = Field(ge=0, le=150, description="Age in years")

# Enum-like validation
role: str = Field(regex="^(user|admin|moderator)$", default="user")

# Optional with default
bio: Optional[str] = Field(default=None, max_length=500)

# Custom validation
phone: Optional[str] = Field(default=None, regex=r'^\+?1?\d{9,15}$')

@validator('email')
def email_must_be_lowercase(cls, v):
"""Custom email validation"""
return v.lower()

@validator('first_name', 'last_name')
def names_must_not_be_empty(cls, v):
"""Ensure names aren't just whitespace"""
if not v or not v.strip():
raise ValueError('Name cannot be empty')
return v.strip().title()

@root_validator
def validate_user_data(cls, values):
"""Cross-field validation"""
first_name = values.get('first_name')
last_name = values.get('last_name')
email = values.get('email')

if first_name and last_name and email:
# Ensure email matches name pattern for corporate users
if email.endswith('@company.com'):
expected = f"{first_name.lower()}.{last_name.lower()}@company.com"
if email != expected:
raise ValueError(f'Corporate email must be {expected}')

return values