Back to Blog
Python

Python Pydantic Datetime and Custom Type Validation

python pydantic datetime and custom type validation: Learn how to validate datetime fields and build custom types in Pydantic, including parsing, timezone handling, an...

PydanticDatetimeCustom TypesData ValidationPython
A visual representation of a datetime object being validated by a custom Pydantic validator, with a clock and a shield.

When you define a datetime field in Pydantic, the default parser accepts ISO 8601 strings and a few common formats. That works for many APIs, but production data often arrives in other shapes: Unix timestamps, timezone-naive strings, or values that must satisfy business rules. This article covers python pydantic datetime and custom type validation, showing how to control parsing, enforce constraints, and build reusable types.

Default Datetime Parsing and Its Limits

Pydantic's built-in datetime handling is convenient but not universal. By default, it accepts ISO 8601 strings and a few variants, and it returns a timezone-aware datetime when the input includes an offset. For example:

from pydantic import BaseModel class Event(BaseModel): starts_at: datetime

This model accepts "2025-05-01T10:00:00Z" and "2025-05-01T10:00:00+02:00", but it rejects "2025-05-01 10:00" (space instead of T) and "1714560000" (Unix timestamp). It also has no way to enforce that a date is in the future or that a timezone is present. Those constraints require custom validation.

Using field_validator for Datetime Checks

The most direct way to add datetime-specific validation is the @field_validator decorator. In Pydantic v2, a validator can transform the input, raise errors, or both. Here is a validator that parses a Unix timestamp and enforces a future date:

from pydantic import BaseModel, field_validator from datetime import datetime, timezone class Event(BaseModel): starts_at: datetime @field_validator('starts_at') @classmethod def parse_timestamp(cls, v): if isinstance(v, (int, float)): v = datetime.fromtimestamp(v, tz=timezone.utc) 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

The validator runs after Pydantic's default parsing, so v is already a datetime when the input was a string. If you want to handle raw strings yourself, you can use mode='before' to intercept the input before any built-in conversion.

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. Pydantic v2 lets you combine Annotated with BeforeValidator or AfterValidator to create a type alias that carries validation rules.

from typing import Annotated from pydantic import BeforeValidator, BaseModel 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 multiple validators, for example to parse first and then check the timezone.

Implementing a Full Custom Type with get_pydantic_core_schema

For more control, you can implement a custom class that defines its own Pydantic schema. This is useful when the type must behave like a native Pydantic type, including JSON serialization and schema generation.

from pydantic_core import core_schema from pydantic import GetCoreSchemaHandler 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

This approach is more verbose but gives you complete control over the validation pipeline. It also integrates with Pydantic's schema generation, which matters if you export JSON Schema for API documentation.

Handling Timezone-Aware and Naive Datetimes

A common requirement is to enforce that a datetime is timezone-aware, or to convert naive datetimes to UTC. You can do this in a validator or inside a custom type. The key is to check v.tzinfo and use datetime.astimezone(timezone.utc) to normalize.

from datetime import timezone def ensure_utc(v): if v.tzinfo is None: v = v.replace(tzinfo=timezone.utc) return v.astimezone(timezone.utc)

Be careful: replace(tzinfo=timezone.utc) assumes the naive datetime is already in UTC. If it is a local time, you should convert it first, but that requires knowing the source timezone. In most APIs, the safest rule is to reject naive datetimes unless the contract explicitly allows them.

Common Pitfalls and Error Handling

Validators run on every assignment, so exceptions inside them are caught by Pydantic and converted to validation errors. However, you should raise ValueError or AssertionError with a clear message. Avoid raising TypeError because Pydantic may treat it as an internal error rather than a validation failure.

Another pitfall is using datetime.now() without a timezone. This returns a naive datetime, which can cause incorrect comparisons. Always use datetime.now(timezone.utc) when comparing with timezone-aware values.

Finally, remember that field_validator with mode='before' receives the raw input, which may be a string, integer, or float. Write your parsing logic to handle all types that your API can receive.

Performance and Maintainability Considerations

Validators add a small overhead per field validation. For high-throughput endpoints, keep validators lightweight and avoid expensive operations like network calls or file I/O. If you need to parse many timestamps, consider using datetime.fromtimestamp directly instead of a regex-based string parser.

From a maintainability perspective, custom types defined with Annotated are easier to reuse than scattered validators. They also make the model's intent clearer: a field declared as UtcTimestamp immediately tells the reader that it must be a UTC datetime.

When you need to change validation rules, a custom type centralizes the change. If you instead copy the same validator across many models, you risk missing one. This tradeoff matters more as your schema grows.

For compatibility, note that Pydantic v1 used @validator and a different custom type API. If you maintain code that must support both v1 and v2, you may need conditional imports or a compatibility layer. The examples in this article target Pydantic v2, which is the current major version.

python pydantic datetime and custom type validation: Practic | RYUSLOG DEV