Python Pydantic Datetime and Custom Type Validation
Learn how to validate datetime fields in Pydantic v2: parse Unix timestamps, require timezone-aware values, and build reusable custom types.
When you define a datetime field in Pydantic, the default parser handles ISO 8601 strings and returns a timezone-aware value when the input includes an offset. That covers many APIs, but production data often arrives in other shapes: Unix timestamps, timezone-naive strings, or values that must satisfy business rules. This article shows how to control datetime parsing in Pydantic v2, enforce constraints, and build reusable custom types.
Default Datetime Parsing and Its Limits
Pydantic's built-in datetime handling is convenient but not universal. In Pydantic v2, a field declared as datetime accepts datetime instances and ISO 8601 strings such as '2025-05-01T10:00:00Z' or '2025-05-01T10:00:00+02:00'. It returns a timezone-aware datetime when the input includes an offset.
The default parser is not designed for every external format. For example, a bare Unix timestamp such as 1714560000 needs an explicit conversion step, and the default field has no built-in rule for requiring a future date or a timezone. Those constraints require custom validation.
from datetime import datetime from pydantic import BaseModel class Event(BaseModel): starts_at: datetime
Using field_validator for Datetime Checks
The most direct way to add datetime-specific validation is the @field_validator decorator. In Pydantic v2, use mode='before' when the validator must see the raw input before the default parser runs. A before-validator can turn a Unix timestamp into a datetime; an after-validator can then enforce business rules once the value is a real datetime.
from datetime import datetime, timezone from pydantic import BaseModel, field_validator class Event(BaseModel): starts_at: datetime @field_validator('starts_at', mode='before') @classmethod def parse_timestamp(cls, v): if isinstance(v, (int, float)): return datetime.fromtimestamp(v, tz=timezone.utc) return v @field_validator('starts_at') @classmethod def check_future_and_timezone(cls, v): if v.tzinfo is None: raise ValueError('timezone-aware datetime required') if v <= datetime.now(timezone.utc): raise ValueError('starts_at must be in the future') return v
With mode='before', the value received by parse_timestamp can be a datetime, string, integer, or float. The after-validator runs after Pydantic's normal conversion, so v is always a datetime. Without mode='before', a Unix timestamp can be rejected by the default parser before your validator has a chance to convert it.
Building a Custom Datetime Type with Annotated
When the same validation logic applies to many fields, a reusable custom type is cleaner than repeating validators. You can combine Annotated and BeforeValidator to create a type alias that carries parsing rules.
from datetime import datetime, timezone from typing import Annotated from pydantic import BaseModel, BeforeValidator def parse_timestamp(v): if isinstance(v, (int, float)): return datetime.fromtimestamp(v, tz=timezone.utc) return v UtcTimestamp = Annotated[datetime, BeforeValidator(parse_timestamp)] class Event(BaseModel): starts_at: UtcTimestamp
This type can be reused across models. You can also chain additional validators in the same Annotated type, for example by adding an AfterValidator for timezone or range checks.
Implementing a Full Custom Type with get_pydantic_core_schema
For more control, implement a custom class that defines its own Pydantic core schema. This is useful when the type needs to behave like a native Pydantic type and participate in JSON Schema generation.
from pydantic import GetCoreSchemaHandler from pydantic_core import core_schema class StrictDateTime: @classmethod def __get_pydantic_core_schema__(cls, source_type, handler): return core_schema.no_info_after_validator_function( cls.validate, core_schema.datetime_schema(), ) @classmethod def validate(cls, v): if v.tzinfo is None: raise ValueError('timezone-aware datetime required') return v
You can then use StrictDateTime as a field type. This approach is more verbose, but it gives complete control over the validation pipeline.
Handling Timezone-Aware and Naive Datetimes
A common requirement is to enforce timezone-aware datetimes or to normalize values to UTC. Check v.tzinfo and use datetime.astimezone(timezone.utc) to convert.
from datetime import timezone def ensure_utc(v): if v.tzinfo is None: v = v.replace(tzinfo=timezone.utc) return v.astimezone(timezone.utc)
replace(tzinfo=timezone.utc) assumes the naive value is already UTC. If the naive datetime represents local time, convert it first; that requires knowing the source timezone. In most APIs, the safest policy is to reject naive datetimes unless the contract explicitly defines them.
Common Pitfalls and Error Handling
Pydantic converts ValueError and AssertionError raised in validators into validation errors, so raise those exception types with clear messages. Avoid raising TypeError because it may not be converted into a validation error.
Validators run during model validation; if you set validate_assignment=True, they also run when a field is assigned after creation. Use datetime.now(timezone.utc) when comparing against timezone-aware values; datetime.now() returns a naive value and can cause incorrect comparisons.
With mode='before', handle all input types your API can send: strings, datetime objects, and numeric timestamps if you support them.
Performance and Maintainability Considerations
Keep validators lightweight and avoid expensive operations such as network calls or file I/O in hot paths. For maintainability, custom types defined with Annotated centralize validation rules and make field intent clear. A field declared as UtcTimestamp is easier to understand and update than the same validator copied across many models.
Pydantic v1 used @validator and a different custom type API. The examples in this article target Pydantic v2, the current major version. If you need to support both versions, use conditional imports or a compatibility layer.