Skip to main content
Back to Articles

Python Type Hints & Pydantic: Runtime Validation (Complete Guide)

Master Python type hints, Pydantic validation, catch runtime errors at input, prevent 70% of production bugs.

March 7, 20267 min readBy Mathematicon

Python Type Hints & Pydantic: Runtime Validation (Complete Guide)

The Problem You're Solving

Your function gets unexpected data:

# āŒ No validation - crashes at runtime
def create_user(name, age, email):
    # What if email is an int? age is a string?
    db.insert(name, age, email)

create_user('Alice', 25, 'alice@example.com')  # āœ… Works
create_user('Bob', '30', 'bob@example.com')    # āŒ Crashes later!
create_user('Charlie', 35, 123)                # āŒ Invalid email!

# āœ… With type hints + Pydantic - validation at entry point
from pydantic import BaseModel, EmailStr

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

User(name='Alice', age=25, email='alice@example.com')  # āœ… Valid
User(name='Bob', age='30', email='bob@example.com')    # āŒ Rejected immediately
User(name='Charlie', age=35, email='invalid')          # āŒ Rejected

That difference = hours debugging production bugs vs catching errors at input.

Python type hints appear in 24% of backend interviews and prevent 70% of runtime bugs.

Type Hints Basics

Simple Types

def greet(name: str) -> str:
    return f'Hello, {name}!'

greet('Alice')  # āœ… OK
greet(123)      # āŒ IDE warning (but still runs in Python)

Collections

from typing import List, Dict, Tuple, Set

def process_items(items: List[int]) -> Dict[str, int]:
    return {'count': len(items), 'sum': sum(items)}

process_items([1, 2, 3])     # āœ… OK
process_items(['a', 'b'])    # āŒ Warning - not int
process_items('123')         # āŒ Warning - not List

Optional Types

from typing import Optional

def find_user(user_id: int) -> Optional[dict]:
    # May return None
    return db.query(f'SELECT * FROM users WHERE id = {user_id}') or None

user = find_user(1)
if user:  # Need to check before accessing
    print(user['name'])

Union Types (Multiple Options)

from typing import Union

def process(value: Union[int, str]) -> str:
    return str(value)

process(42)       # āœ… OK
process('hello')  # āœ… OK
process([1, 2])   # āŒ Warning

Pydantic: Runtime Validation

Basic Model

from pydantic import BaseModel

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

# āœ… Valid - Creates User object
user = User(name='Alice', age=25, email='alice@example.com')
print(user.name)  # 'Alice'

# āŒ Invalid - Raises ValidationError
try:
    User(name='Bob', age='thirty', email='bob@example.com')
except ValidationError as e:
    print(e)  # age: value is not a valid integer

Advanced Validation

from pydantic import BaseModel, EmailStr, Field, validator

class User(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    age: int = Field(..., gt=0, le=150)
    email: EmailStr
    bio: Optional[str] = None  # Default None

    @validator('name')
    def name_not_reserved(cls, v):
        reserved = {'admin', 'root', 'system'}
        if v.lower() in reserved:
            raise ValueError(f'{v} is reserved')
        return v

    @validator('age')
    def age_reasonable(cls, v):
        if v < 13:
            raise ValueError('Must be 13+')
        return v

# āœ… Valid
user = User(name='Alice', age=25, email='alice@example.com')

# āŒ Multiple errors
try:
    User(name='admin', age=10, email='invalid')
except ValidationError as e:
    # Reports all errors at once
    print(e)

JSON Schema & Documentation

from pydantic import BaseModel

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

# Generate OpenAPI/JSON schema automatically
schema = User.schema()
print(schema)
# {
#   "title": "User",
#   "type": "object",
#   "properties": {
#     "name": {"type": "string"},
#     "age": {"type": "integer"}
#   },
#   "required": ["name", "age"]
# }

Real-World Example: API Endpoint

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()

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

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

@app.post('/users', response_model=UserResponse)
async def create_user(user: UserCreate):
    # Pydantic validates automatically
    # If invalid data sent, FastAPI returns 422 error
    # If valid, user.name, user.email are guaranteed correct types

    db_user = db.insert(
        name=user.name,
        email=user.email,
        age=user.age
    )

    return UserResponse(id=db_user.id, name=db_user.name, email=db_user.email)

# Usage
# POST /users
# {
#   "name": "Alice",
#   "email": "alice@example.com",
#   "age": 25
# }
# Response: 200 OK
# {
#   "id": 1,
#   "name": "Alice",
#   "email": "alice@example.com"
# }

# Invalid request
# POST /users
# {
#   "name": "Bob",
#   "email": "invalid-email",
#   "age": "thirty"
# }
# Response: 422 Unprocessable Entity
# {"detail": [{"email": "invalid email format", "age": "not a valid integer"}]}

Type Checking Tools

MyPy: Static Type Checker

# type_check.py
def greet(name: str) -> str:
    return f'Hello, {name}!'

greet('Alice')  # āœ… OK
greet(123)      # āŒ Error

greet(123).upper()  # āŒ Error: int has no method upper
# Run mypy
mypy type_check.py
# type_check.py:7: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
# type_check.py:9: error: Argument 1 to "greet" has incompatible type "int"; expected "str"

Pyright: Microsoft's Type Checker (Faster)

npm install -g pyright
pyright check.py

Using in IDE

Modern IDEs (VSCode, PyCharm) warn about type errors while you code:

def process(items: List[int]):
    return sum(items)

result = process(['a', 'b'])  # āš ļø Red squiggly underline in IDE

Common Mistakes

āŒ Mistake 1: Type Hints Without Validation

# WRONG - Type hints don't validate at runtime
def create_user(name: str, age: int):
    db.insert(name, age)

create_user('Bob', 'thirty')  # No error! Runs anyway

# CORRECT - Use Pydantic for validation
from pydantic import BaseModel

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

user = User(name='Bob', age='thirty')  # ValidationError!

āŒ Mistake 2: Too Loose Type Hints

# WRONG - Any is too loose
def process(data: Any) -> Any:
    return data

# CORRECT - Be specific
def process(data: List[Dict[str, str]]) -> int:
    return len(data)

āŒ Mistake 3: Ignoring Type Errors

# WRONG - Suppress type errors
def greet(name):  # type: ignore
    return 42  # Should return str

# CORRECT - Fix the issue
def greet(name: str) -> str:
    return f'Hello, {name}!'

FAQ: Type Hints & Validation

Q1: Should I use type hints for everything?

A: Yes for APIs and critical code. Optional for scripts.

# āœ… YES - Public API
def create_order(user_id: int, items: List[int]) -> Order:
    pass

# Optional - Internal script
def process_file(path):  # Type hints helpful but optional
    with open(path) as f:
        pass

Q2: How do I validate nested objects?

A: Use nested Pydantic models.

from pydantic import BaseModel

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

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

# āœ… Valid
user = User(
    name='Alice',
    address={'street': '123 Main St', 'city': 'NYC', 'zip_code': '10001'}
)

# āŒ Invalid - zip_code missing
try:
    User(
        name='Bob',
        address={'street': '456 Elm', 'city': 'LA'}
    )
except ValidationError as e:
    print(e)  # address: zip_code: field required

Q3: Interview Question: Type-safe data pipeline.

A: Here's production approach:

from pydantic import BaseModel, validator
from typing import List
import csv

class Record(BaseModel):
    user_id: int
    action: str
    timestamp: float

    @validator('action')
    def action_valid(cls, v):
        valid = {'login', 'logout', 'click', 'purchase'}
        if v not in valid:
            raise ValueError(f'{v} not in {valid}')
        return v

def process_log_file(path: str) -> List[Record]:
    records = []
    with open(path) as f:
        reader = csv.DictReader(f)
        for row in reader:
            try:
                record = Record(**row)  # Auto-validates and converts
                records.append(record)
            except ValidationError as e:
                print(f'Invalid record: {e}')
                continue
    return records

# Usage
records = process_log_file('logs.csv')
# Only valid Record objects in list
# Types are guaranteed correct

Q4: What about forward references?

A: Use string quotes for forward references.

from __future__ import annotations

class Node:
    value: int
    children: List[Node]  # Reference to self

# OR without __future__
class Node:
    value: int
    children: List['Node']  # String reference

Conclusion

Type hints + Pydantic = bulletproof Python:

  1. Type hints - Document expected types
  2. Mypy - Catch errors before runtime
  3. Pydantic - Validate at entry points
  4. Combined: 95% fewer type-related bugs

Master this pattern and your code will be fast, maintainable, and reliable.


Learn More

Share this article

Related Articles