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:
- Type hints - Document expected types
- Mypy - Catch errors before runtime
- Pydantic - Validate at entry points
- Combined: 95% fewer type-related bugs
Master this pattern and your code will be fast, maintainable, and reliable.