Skip to content
Back to top
View this page

# 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 `AwareDatetime` type (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 a `BeforeValidator`.

## 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 | `AwareDatetime` | Prevents timezone confusion.
Defaults | `Field(default_factory=...)` | Prevents stale timestamps.
Validation | `@field_validator` | 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}")