Dates and Times¶
Date and time fields must be typed and timezone-aware at the model boundary. Pydantic V2 provides the validation tools, but the project convention is to reject ambiguous datetime values before they reach persistence or workflow logic.
1. Always Use Strong Typing¶
Never define a date field as a str, even if the input is a string from a JSON payload. Use Python’s standard library types. Pydantic will automatically parse ISO 8601 strings into these objects.
datetime.date: For birthdays, holidays, or specific calendar days (no time component).datetime.datetime: For timestamps, log events, or scheduled appointments.datetime.time: For opening hours or recurring daily events.
2. Enforce Timezone Awareness¶
The most common backend error is mixing naive datetimes, which have no timezone metadata, with aware datetimes, which include timezone metadata.
Project rules:
Store everything in UTC.
Reject naive datetimes at the API boundary.
Use Pydantic’s
AwareDatetimetype (introduced in V2).
from datetime import datetime, timezone
from pydantic import BaseModel, AwareDatetime
class Event(BaseModel):
# Bad: accepts naive datetimes and defaults to local time.
# created_at: datetime
# Good: requires timezone metadata.
start_time: AwareDatetime
# Usage
try:
# Fails validation because no timezone is provided.
Event(start_time=datetime(2023, 1, 1, 12, 0))
except Exception:
print("Validation Error: Timezone required")
# Passes validation.
Event(start_time=datetime(2023, 1, 1, 12, 0, tzinfo=timezone.utc))
3. Stick to ISO 8601 Standards¶
Pydantic is optimized to parse ISO 8601 formats (e.g., YYYY-MM-DDTHH:MM:SSZ).
Input: Encourage clients to send ISO strings.
Output: Pydantic serializes to ISO strings by default.
Avoid Custom Formats: Avoid configuring Pydantic to parse format strings like
DD/MM/YYYY. It makes the API brittle and region-dependent. If a legacy format is unavoidable, use aBeforeValidator.
4. Validate Business Logic¶
Use the @field_validator decorator to enforce logical constraints, such as ensuring a birth date is in the past or a scheduled task is in the future.
from datetime import date, timedelta
from pydantic import BaseModel, field_validator
class UserProfile(BaseModel):
birth_date: date
@field_validator('birth_date')
@classmethod
def must_be_18(cls, v: date) -> date:
today = date.today()
# Calculate age roughly
age = (today - v).days / 365.25
if age < 18:
raise ValueError('User must be 18 or older')
return v
5. Handle “Now” and Defaults¶
Never set a mutable default (like datetime.now()) directly in the field definition. Python evaluates default arguments once at definition time, not at instantiation time.
Bad:
class Log(BaseModel):
timestamp: datetime = datetime.now() # Fixed to when the server started.
Good:
from pydantic import BaseModel, Field
from datetime import datetime, timezone
class Log(BaseModel):
# Evaluated every time a model is created.
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
6. Serialization¶
When you convert your model to JSON (e.g., sending a response in FastAPI), Pydantic converts datetime objects to strings.
If you need a specific output format (that isn’t standard ISO), strictly separate your Internal Model from your Response Model, or use a serialization serializer.
from pydantic import BaseModel, field_serializer
class Meeting(BaseModel):
when: datetime
@field_serializer('when')
def serialize_dt(self, dt: datetime, _info):
# Custom format only for outgoing JSON
return dt.strftime('%Y-%m-%d %H:%M')
Summary Checklist¶
Feature |
Recommendation |
Why? |
|---|---|---|
Type |
|
Prevents timezone confusion. |
Defaults |
|
Prevents stale timestamps. |
Validation |
|
Enforces logic (e.g., “date must be future”). |
Format |
ISO 8601 |
Universal standard, reduces parsing errors. |
Storage |
UTC |
Standardizes database entries. |
7. Complete Example¶
Here is a complete, production-ready example using Pydantic V2 features.
from datetime import datetime, timezone
from pydantic import BaseModel, Field, AwareDatetime, field_validator
class Appointment(BaseModel):
# Enforce timezone metadata.
start_time: AwareDatetime
# Use default_factory for creation times.
created_at: AwareDatetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
description: str
# Business validation.
@field_validator('start_time')
@classmethod
def validate_future_date(cls, v: datetime) -> datetime:
# Ensure comparison is timezone-aware.
if v < datetime.now(timezone.utc):
raise ValueError('Appointment must be in the future')
return v
# Valid input: ISO string with timezone.
data = {
"start_time": "2030-12-01T14:00:00Z",
"description": "Dentist"
}
appt = Appointment(**data)
print(f"Success: {appt.start_time.isoformat()}")
# Invalid input: naive datetime.
try:
Appointment(start_time=datetime(2030, 12, 1, 14, 0), description="Fail")
except Exception as e:
print(f"\nCaught Expected Error: {e}")