My question is pretty simple and I'm surprised no one has asked it so far:
How can I validate dates in pydantic?
For example, I only want to accept dates in the range 1980.1.1-2000.1.1.
validator for datetime field is what you want. You can use it as following:
from pydantic import BaseModel, validator
from datetime import datetime
class DModel(BaseModel):
dt: datetime
@validator("dt")
def ensure_date_range(cls, v):
if not datetime(year=1980, month=1, day=1) <= v < datetime(year=2000, month=1, day=1):
raise ValueError("Must be in range")
return v
DModel.parse_obj({"dt": "1995-01-01T00:00"})
DModel.parse_obj({"dt": "2001-01-01T00:00"}) # validation error
@validator is deprecated in Pydantic V2One common use case, possibly hinted at by the OP's use of "dates" in the plural, is the validation of multiple dates in the same model. This is very lightly documented, and there are other problems that need to be dealt with you want to parse strings in other date formats.
This is not explicitly in the docs. Well, it's there, but the circumstances where it's required are not mentioned. If you need your validator to convert a non-ISO str format to date Pydantic will discover an invalid date format before your validator has run.
In these circumstances it's not sufficient to just apply multiple validators, or apply one validator to multiple fields, the pre=True argument also needs to be supplied, to pre-empt the default formatting from raising an exception, before your validator can convert it:
from datetime import datetime, date
from pydantic import BaseModel, validator
def validate_date(value: str) -> date:
return datetime.strptime(value, "%d/%m/%Y").date()
class CommonModel(BaseModel):
created: date
modified: date
confirmed: date
closed: date
_validate_dates = validator(
'created', 'modified', 'confirmed', 'closed', pre=True, allow_reuse=True,
)(validate_date)
data = {
'created': '25/12/2000',
'modified': '31/12/2000',
'confirmed': '01/01/2001',
'closed': '11/09/2001',
}
try:
model = CommonModel(**data)
except Exception as e:
print(e.json())
The docs on this are so light I had to discover this myself, and I haven't found anyone else pointing this out, so I thought a demonstration of this kind of code would be helpful here.
Pydantic 2 is better and is now, so in response to @Gibbs' I am updating with a Pydantic 2.3 solution that contains other non-date fields as well.
While Pydantic 2 documentation continues to be a little skimpy the migration to Pydantic 2 is managed, with specific migration documentation identifying some of the changes required and with the new Python 11 helpful error messages it wasn't too hard to discover.
Pydantic 2 also changes the approach a bit, with specific support for multiple fields, making cleaner code so I've used that support in this answer. If you want to use this validator with multiple models with different field names for the dates in those models, then you may have to revert the structure similar to my original answer.
import datetime
import pydantic
class CommonModel(pydantic.BaseModel):
name: str
created: datetime.date
modified: datetime.date
confirmed: datetime.date
closed: datetime.date
number: int
@pydantic.field_validator(
"created", "modified", "confirmed", "closed", mode="before"
)
@classmethod
def non_iso_date(
cls, value: str, info: pydantic.FieldValidationInfo
) -> datetime.date:
assert isinstance(value, str), info.field_name + " must be date as str 'dd/mm/yyyy'"
return datetime.datetime.strptime(value, "%d/%m/%Y").date()
data = {
"created": "25/12/2000",
"modified": "31/12/2000",
"confirmed": "01/01/2001",
"closed": "11/09/2001",
"number": 3,
"name": "rumplestiltskin",
}
try:
model = CommonModel(**data)
print(model)
except Exception as e:
print(e.json())
This will generate output as follows:
name='rumplestiltskin' created=datetime.date(2000, 12, 25) modified=datetime.date(2000, 12, 31) confirmed=datetime.date(2001, 1, 1) closed=datetime.date(2001, 9, 11) number=3
Pydantic 2 documentation on before and after validators and field validators is all on the same page.
_validate_dates call? I'm just checking on this but I've moved to Pydantic 2 now so some other things need to change. I'll update my answer if I can.