Chapter 17 · The utility layer
17.10 Dates, durations and addresses: parse_datetime, parse_duration and clean_ipv6_address
django/ reads a date, a time, a datetime or a duration from text, with parse_date, parse_time, parse_datetime and parse_duration; django/ writes a duration back as text; and django/ checks an IPv6 address and gives it one spelling, with clean_ipv6_address.
Values reach Django as text: SQLite keeps dates and times as text, and fixtures and posted forms are text. A parser of dates and times answers in one of three ways: the value; None, when the text is in no form it reads; or ValueError, when the form is right but the value cannot exist, the thirtieth of February. A model’s date, time or datetime field turns None into its "invalid" error and ValueError into "invalid_date", "invalid_time" or "invalid_datetime", so that its message says whether the format or the value was wrong. normalize_json makes a task’s arguments and return value the types of JSON (Background tasks: Task, TaskResult and the task backends).
Dates and times
parse_date, parse_time and parse_datetime each try their type’s own fromisoformat first, datetime for a datetime, and the module’s pattern only when that raises ValueError, passing the pattern’s groups as integers to the type’s constructor. The pattern adds forms ISO 8601 does not allow, such as a one-digit month, day or hour. Text that neither reads gives None. Text the pattern matches but the constructor refuses raises the constructor’s ValueError; the comment at the top of the module counts the constructors’ friendlier messages among its reasons for regular expressions rather than time.strptime, beside validating as they parse and flexibility. The fraction of a second that the pattern reads is padded on the right, so that ".5" is half a second, not five microseconds.
parse_datetime returns an aware datetime when the text has an offset and a naive one when it has none. On the pattern’s path "Z" becomes datetime.UTC, and another offset is counted in minutes and given to get_fixed_timezone, which returns a datetime named after it. An ISO 8601 date alone is read as its midnight.
parse_time never returns an aware time. Where the standard library reads an offset, parse_time removes it, its comment’s reason being that an aware datetime.time makes sense in no circumstance: the offset is dropped, not applied. The pattern has no place for an offset, so text the standard library refuses gives None if it carries one, the case the docstring describes. A voyage’s sailing, read from text, and last through the Voyage model’s field sailed:
parse_date("2026-3-14") -> datetime.date(2026, 3, 14)
parse_date("2026-02-30") -> raises ValueError: day 30 must be in range 1..28 for month 2 in year 2026
parse_datetime("2026-03-14 7:30+0100") -> datetime.datetime(2026, 3, 14, 7, 30, tzinfo=datetime.timezone(datetime.timedelta(seconds=3600), '+0100'))
parse_time("17:30+01:00") -> datetime.time(17, 30)
parse_time("7:30+01:00") -> None
sailed = Voyage._meta.get_field("sailed")
sailed.to_python("14 March 2026") -> raises ValidationError, code 'invalid'
sailed.to_python("2026-02-30") -> raises ValidationError, code 'invalid_date'
The parsers’ main callers are SQLite’s converters, which the driver runs on each date, time and datetime column it fetches (Values in and out: adapters, converters and time zones); a model field’s to_python, given a string (The field classes); and the form field DateTimeField, before its input formats (The built-in form fields).
Durations: parse_duration
parse_duration tries three patterns in turn and uses the first that matches. Django’s own is the docstring’s "%d %H:%M:%S.%f", its days, hours and minutes optional; Python’s str of a datetime, "2 days, 6:, matches it too. ISO 8601’s is read without years and months, which a datetime cannot hold, as the comment above its pattern says. The third is PostgreSQL’s day-time interval, "2 days 06:, its year-month and mixed intervals refused for the same reason.
In Django’s form and PostgreSQL’s the days carry their own sign, and a sign before the time applies to the time alone: "-2 06:30:00" is two days back and six and a half hours forward. In ISO 8601 a sign before the P applies to the whole: the code multiplies the days by the sign only for that pattern. Django’s pattern and ISO 8601’s take a comma for the decimal point; PostgreSQL’s takes only a point. Text in no form gives None; a duration too long for a datetime raises OverflowError, which the form field DurationField reports as "overflow" and the model field lets through.
django/ writes two of the forms. duration_string writes Django’s own, with days and microseconds only where there are some, and a negative duration as Python holds it, negative days and a positive time; a model’s DurationField gives it to a serializer. duration_iso_string writes ISO 8601, for DjangoJSONEncoder and for Oracle’s schema editor, which quotes a duration with it. duration_microseconds makes the integer that BaseDatabaseOperations hands the driver, which PostgreSQL’s and Oracle’s override to pass the datetime on. The voyage to Piraeus took two days and six and a half hours:
length = datetime.timedelta(days=2, hours=6, minutes=30)
parse_duration("2 06:30:00") -> datetime.timedelta(days=2, seconds=23400)
parse_duration("PT54,5H") -> datetime.timedelta(days=2, seconds=23400)
parse_duration("-2 06:30:00") -> datetime.timedelta(days=-2, seconds=23400)
parse_duration("-P2DT6H30M") -> datetime.timedelta(days=-3, seconds=63000)
parse_duration("P1Y") -> None
duration_string(-length) -> '-3 17:30:00'
duration_iso_string(-length) -> '-P2DT06H30M00S'
IPv6 addresses: clean_ipv6_address
An IPv6 address can be spelt many ways, and clean_ipv6_address gives it the one ipaddress writes, compressed and in lower case. It refuses text longer than its max_length argument, by default MAX_IPV6_ADDRESS_LENGTH, 39, eight full groups, a bound added against a denial of service, CVE-2024-56374; parses the text; and makes the address an integer and an address again, which drops a scope such as "%eth0". An IPv4-mapped address is written with its last part dotted, or with unpack_ipv4=True as the IPv4 address alone. A failure raises ValidationError with the message given, code "invalid" and the parameter "protocol", "IPv6", which the model field’s message puts in.
is_valid_ipv6_address asks the same without raising: an ipaddress is valid at once, and anything else is parsed under the default limit, TypeError and ValueError both meaning False; validate_ipv6_address is built on it. The model’s and the form’s GenericIPAddressField strip a value and call clean_ipv6_address if it has a colon, the form’s with its own max_length (The field classes, The built-in form fields).
clean_ipv6_address("2001:0DB8:0000:0000:0000:0000:0000:0001") -> '2001:db8::1'
clean_ipv6_address("::ffff:0a0a:0a0a", unpack_ipv4=True) -> '10.10.10.10'
clean_ipv6_address("fe80::1%eth0") -> 'fe80::1'
clean_ipv6_address("0:0:0:0:0:ffff:192.168.100.228") -> '::ffff:192.168.100.228'
clean_ipv6_address("0000:0000:0000:0000:0000:ffff:192.168.100.228") -> raises ValidationError, code 'invalid'