relaxdiego (Mark Maglana's Technical Blog)

Dealing with ISO 8601 in Python

Feb 14, 2022
Est. read: 9 minutes

ISO 8601 datetime strings are great but they come with surprising special conditions that you may need to consider when the datetime strings come from an external client. There are third-party libraries out there that can ease your burden but, for times when you don’t have the luxury of using anything but stock Python, read on.

NOTE: All code snippets below are available on Github.

Let’s start with the simplest case.

1
2
3
4
5
6
7
8
9
from datetime import datetime


def datetime_from_iso8601(iso8601_str):
    return datetime.fromisoformat(iso8601_str)


assert datetime_from_iso8601("2022-01-01") == datetime(2022, 1, 1)
assert datetime_from_iso8601("1979-01-01") == datetime(1979, 1, 1)

I told you it would be simple!

OK but does this datetime actually have time zone information? Let’s check:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from datetime import datetime


def datetime_from_iso8601(iso8601_str):
    return datetime.fromisoformat(iso8601_str)


assert datetime_from_iso8601("2022-01-01") == datetime(2022, 1, 1)
assert datetime_from_iso8601("1979-01-01") == datetime(1979, 1, 1)

#
# AssertionError
#
assert datetime_from_iso8601("1979-01-01").tzinfo is not None

Hmmm. That’s interesting. What’s going on here? As it turns out, the datetime.fromisoformat() method is very limited in its support for ISO 8601 as its documentation states. So if we want to ensure that it has time zone information, we could ask the user to supply it in the string:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from datetime import datetime


def datetime_from_iso8601(iso8601_str):
    return datetime.fromisoformat(iso8601_str)


assert datetime_from_iso8601("2022-01-01") == datetime(2022, 1, 1)
assert datetime_from_iso8601("1979-01-01") == datetime(1979, 1, 1)

#
# ValueError: Invalid isoformat string: '1979-01-01T00:00:00Z'
#
assert datetime_from_iso8601("1979-01-01T00:00:00Z").tzinfo is not None

Huh, that was unexpected. I guess we should’ve listened to this part of the method’s documentation:

Caution This does not support parsing arbitrary ISO 8601 strings - it is only intended as the inverse operation of datetime.isoformat(). A more full-featured ISO 8601 parser, dateutil.parser.isoparse is available in the third-party package dateutil.

OK, but what if, as I mentioned earlier, we don’t have the luxury of installing a third-party library???

Well I guess we could just translate the ‘Z’ character…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from datetime import datetime


def datetime_from_iso8601(iso8601_str):
    #
    # I mean, it's just one silly character anyway...
    #
    if iso8601_str[-1].upper() == "Z":
        iso8601_str = f"{iso8601_str[:-1]}+00:00"

    return datetime.fromisoformat(iso8601_str)


assert datetime_from_iso8601("2022-01-01") == datetime(2022, 1, 1)
assert datetime_from_iso8601("1979-01-01") == datetime(1979, 1, 1)

assert datetime_from_iso8601("1979-01-01T00:00:00Z").tzinfo is not None

That’s what I’m talking about!

But wait, what if I want time zone information to be automatically added to the datetime object? We can totally do that!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from datetime import datetime


def datetime_from_iso8601(iso8601_str):
    if iso8601_str[-1].upper() == "Z":
        iso8601_str = f"{iso8601_str[:-1]}+00:00"

    dt = datetime.fromisoformat(iso8601_str)

    #
    # Let's add that time zone if it's missing
    #
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=get_timezone())

    return dt


def get_timezone():
    raise Exception("Wait...which timezone should I use???")


assert datetime_from_iso8601("2022-01-01") == datetime(2022, 1, 1)
assert datetime_from_iso8601("1979-01-01") == datetime(1979, 1, 1)

assert datetime_from_iso8601("1979-01-01T00:00:00Z").tzinfo is not None
assert datetime_from_iso8601("1979-01-01").tzinfo is not None

Hmmm, that’s a good question. I guess we should first ask: what is the expectation of the client that provided that string. If the client is a person using a browser, then they probably are thinking about their current timezone. Is it possible for the browser to send that time zone information within its request? If so, then use that!

But what if this code is part of a software that recognizes individual user preferences and those preferences include the time zone? Maybe you should use that?

Now what if that string was provided via a configuration file? I guess we could use the time zone of the server that’s running this code? Maybe?

Look the point is that the answer to the question of which time zone to use will depend on the expectation of the client that provided the string as well as any conventions that are used in the larger codebase that contains this code snippet.

For this example, I’ll just assume Zulu time:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from datetime import datetime, timezone


def datetime_from_iso8601(iso8601_str):
    if iso8601_str[-1].upper() == "Z":
        iso8601_str = f"{iso8601_str[:-1]}+00:00"

    dt = datetime.fromisoformat(iso8601_str)

    if dt.tzinfo is None:
        #
        # There is only Zulu!
        #
        dt = dt.replace(tzinfo=timezone.utc)

    return dt


assert datetime_from_iso8601("2022-01-01") == datetime(2022, 1, 1, tzinfo=timezone.utc)
assert datetime_from_iso8601("1979-01-01") == datetime(1979, 1, 1, tzinfo=timezone.utc)
assert datetime_from_iso8601("1979-01-01T11:11") == datetime(
    1979, 1, 1, 11, 11, tzinfo=timezone.utc
)

assert datetime_from_iso8601("1979-01-01T00:00:00Z").tzinfo is not None
assert datetime_from_iso8601("1979-01-01").tzinfo is not None

Great! But wait one more thing: can we manually override the time zone?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from datetime import datetime, timezone, timedelta


def datetime_from_iso8601(iso8601_str):
    if iso8601_str[-1].upper() == "Z":
        iso8601_str = f"{iso8601_str[:-1]}+00:00"

    dt = datetime.fromisoformat(iso8601_str)

    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)

    return dt


assert datetime_from_iso8601("2022-01-01") == datetime(2022, 1, 1, tzinfo=timezone.utc)
assert datetime_from_iso8601("1979-01-01") == datetime(1979, 1, 1, tzinfo=timezone.utc)
assert datetime_from_iso8601("1979-01-01T11:11") == datetime(
    1979, 1, 1, 11, 11, tzinfo=timezone.utc
)

assert datetime_from_iso8601("1979-01-01T00:00:00Z").tzinfo is not None
assert datetime_from_iso8601("1979-01-01").tzinfo is not None

#
# Yeah, it works. Not that I need it right now.
#
assert datetime_from_iso8601("1979-01-01T11:11+12:00") == datetime(
    1979, 1, 1, 11, 11, tzinfo=timezone(timedelta(hours=12))
)

Sweet. Alright so we can provide a good enough support for ISO 8601 in our code without having to import third-party libraries. It’s not foolproof but for very simple situations, it’ll do. For more complex ones, there’s the dateutil library.