login
Holden Web
What you'll need to know tomorrow

Time-Based Computations

What time is it?

If it takes seven days for the check to arrive, on what day will it arrive?

How many days until my birthday?

While these might seem amusing, they are serious—and common—questions for software developers. Time-based functions are invariably complex because we track time by non-decimal methods. While we might have 10 fingers and a meter has 100 centimeters, when it comes to time an hour has 60 minutes, a week has 7 days, and a month can have from 28 to 31 days. Also, almost every four years you have to account for leap year. Many day-tracking calculations have to take into account the standard business days of Monday through Friday, and the weekend days of Saturday and Sunday. The list of "edge cases" in time calculations is almost infinite!

It is arguably for this reason that Python has three built-in libraries for handling time issues: datetime, time, and calendar, each of which has a lot of sophisticated functionality. Because of the volume of functionality provided by each library, this lesson will focus on the datetime library. In fact, this lesson will focus on the three questions asked at the start, since they provide an excellent introduction to many features of handling time from the perspective of a software developer.

This lesson includes these sections:

What Time is It?

A common way to find the current time is with the code in this interactive session:

Code and output
>>> import datetime
>>> print(datetime.datetime.now())
2010-09-26 20:21:50.813824

And there you have the time!

Modern Python The output of datetime.datetime.now() is date-dependent and will differ from the value shown here. To get a timezone-aware UTC datetime (recommended for unambiguous timestamps), use datetime.datetime.now(datetime.timezone.utc) or, more concisely after from datetime import datetime, timezone, datetime.now(timezone.utc). This requires no additional packages in Python 3.2+.
Time Representations

How a date is formatted depends on who is looking at it. For a software developer, engineer, system administrator, or scientist, the format shown in that last session is a good way to see time. Because the date is in YYYY-MM-DD format and the time uses the 24-hour clock, you can do easy sorting on the results either by hand or with computers, whereas the American (MM/DD/YYYY) and European (DD/MM/YYYY) date methods require more work for sorting, and the 12-hour clock repeats itself, so times after noon won't sort correctly.

In fact, often people working in these time-sensitive fields rely on alternate time measurement methods like counting seconds since the epoch or the Julian date (JD) system used by the astronomy community. Python supports these alternate methods extremely well, which is one minor reason why Python is so frequently used by the scientific community.

However, most people don't like, or even understand, this way of representing time. It isn't what they're used to seeing and forcing them to use a new time representation format is a good way to lose their interest in your projects. Let's do some formatting to make this a little more natural to the American eye. Continue your interactive session:

Code and output
>>> now = datetime.datetime.now()
>>> format_string = "%x %X"
>>> now.strftime(format_string)
09/26/10 20:35:04

From previous lessons, you know what a formatter string does. Now nearly every time object by Python supports the strftime() method, which accepts a format string with any number of predefined mapping keys. "%x %X" fetches the datetime setup you defined on your computer when you set it up.

These predefined mapping keys let you actually precisely map exactly what date and time setup you want your users to experience. The legal mapping keys are:

keyMeaning
%aLocale's abbreviated weekday name.
%ALocale's full weekday name.
%bLocale's abbreviated month name.
%BLocale's full month name.
%cLocale's appropriate date and time representation.
%dDay of the month as a decimal number [01,31].
%fMicrosecond as a decimal number [0,999999], zero-padded on the left
%HHour (24-hour clock) as a decimal number [00,23].
%IHour (12-hour clock) as a decimal number [01,12].
%jDay of the year as a decimal number [001,366].
%mMonth as a decimal number [01,12].
%MMinute as a decimal number [00,59].
%pLocale's equivalent of either AM or PM.
%SSecond as a decimal number [00,61].
%UWeek number of the year (Sunday as the first day of the week) as a decimal number [00,53]. All days in a new year preceding the first Sunday are considered to be in week 0.
%wWeekday as a decimal number [0(Sunday),6].
%WWeek number of the year (Monday as the first day of the week) as a decimal number [00,53]. All days in a new year preceding the first Monday are considered to be in week 0.
%xLocale's appropriate date representation.
%XLocale's appropriate time representation.
%yYear without century as a decimal number [00,99].
%YYear with century as a decimal number.
%zUTC offset in the form +HHMM or -HHMM (empty string if the object is naive).
%ZTime zone name (empty string if the object is naive).
%%A literal '%' character.

Armed with this table and the strftime() function, you can now provide a much more attractive date format customized for your target user. Continue your interactive session now to try it out:

Code and output
>>> format_string = "%A, %B %d, %Y at %I:%M %p."
>>> now.strftime(format_string)
Sunday, September 26, 2010 at 8:35 PM.
Modern Python The %z and %Z keys produce empty strings for naive datetimes (those with no timezone information). To get meaningful timezone output, use a timezone-aware datetime. In Python 3.9+ you can use the standard library zoneinfo module: from zoneinfo import ZoneInfo followed by datetime.now(ZoneInfo("Europe/London")). For earlier Python 3 versions, the third-party pytz package provides the same functionality, but zoneinfo is now the recommended approach.
If it Takes Thirty-One Days...?

At a glance this should be easy—you just take the date of the month as fetched by datetime.datetime.now() and add 31, right? Lets give it a try. Create count_thirtyone_days.py as shown:

Code
import datetime
now = datetime.datetime.now()
date = now.strftime("%d")
delivery = int(date) + 31
print("Today: %s" % date)
print("Delivery: %s" % delivery)

At a glance, this looks like it should work, but in fact you'll get a response like this:

OBSERVE: Running count_thirtyone_days.py on November 29th
Today: 29
Delivery: 60

In theory, you could write a bit of code that would handle month rollovers and the leap year. This is not a small undertaking and will probably take more time than you really want to dedicate to the problem of adding thirty-one days to the current date. Also, your result would lack the ability to reformat the results via the strftime() method because it would be a simple integer, not a time object.

There Must be a Better Way to Add Days to a Date!

Yes, there is a way. The Python datetime library has an object named timedelta, which represents the difference between two dates or times. This difference is called a duration. You can add a timedelta to the current date, and it will account for month rollovers and the leap year. Modify count_thirtyone_days.py as shown:

Code
from datetime import datetime, timedelta # more attractive import
now = datetime.now()
delta = timedelta(31) # create a timedelta of 31 days
delivery = now + delta # add the timedelta to the current datetime.
print("Today: %s" % now.strftime("%d"))
print("Delivery: %s" % delivery.strftime("%d"))

Save and run it. You'll see that it works correctly:

Running count_thirtyone_days.py on November 29th
Today: 29
Delivery: 30

You may have noticed that what was printed was string values returned from the strftime() methods on the now() and delivery objects. This means that you can execute further calculations as needed on these objects—they have not been changed at all. This becomes really useful when you want to skip over weekends. Thanks to the datetime object's isoweekday() method which returns a numeric value as shown below, we can write code that skips over weekends with some ease.

Value returned from isoweekday()Weekday name
1Monday
2Tuesday
3Wednesday
4Thursday
5Friday
6Saturday
7Sunday

The next example shows how to skip over weekends. It doesn't take into account national or bank holidays, but it is similar to what organizations use to determine when they can expect payments and other letters to arrive.

Code
from datetime import datetime, timedelta

delivery = datetime.now()
delta = timedelta(1)
count = 0
while count < 31:
    delivery = delivery + delta
    if delivery.isoweekday() in (6, 7):
        continue
    count += 1

now = datetime.now()
print(now)
print(delivery)
print("Today: %s" % now.strftime("%d"))
print("Delivery: %s" % delivery.strftime("%d"))

Save and run it. It counts only working days.

OBSERVE: Running skip_weekdays.py on November 29th
2010-11-29 10:40:26.439000
2011-01-11 10:40:26.439000
Today: 29
Delivery: 11
timedeltas for Weeks, Hours, Minutes, and Seconds

The timedelta object can be instantiated with other values than days. Some of the ones you'll use frequently are weeks, hours, minutes, and seconds. All you need to do is add one or more of these items as arguments and the timedelta is constructed accordingly. Create more_deltas.py as shown to see what you get:

Code
from datetime import datetime, timedelta

weeks = timedelta(weeks=2)
hours = timedelta(hours=1)
minutes = timedelta(minutes=100)
seconds = timedelta(seconds=1000)
composite = timedelta(hours=1, minutes=30)

now = datetime.now()
print(now)
print(now + weeks)
print(now + hours)
print(now + minutes)
print(now + seconds)
print(now + composite)

Save and run it (your results will vary unless you traveled back in time to November 29, 2010):

OBSERVE: Running more_deltas.py
2010-11-29 10:41:06.312000
2010-12-13 10:41:06.312000
2010-11-29 11:41:06.312000
2010-11-29 12:21:06.312000
2010-11-29 10:57:46.312000
2010-11-29 12:11:06.312000
timedeltas for Years and Months

Years and months are not constants, thanks to the leap year issue and the general inconsistency of month durations. Therefore, the timedelta does not accept them as arguments. However, because the other arguments (weeks, hours, minutes, etc.) are constants, timedeltas can handle the leapyear and month durations, which works well for years and not so well for months.

This means you can provide an almost exact year timedelta by simply doing this:

Code and output
>>> from datetime import timedelta
>>> timedelta(365)
datetime.timedelta(365)

On the other hand, this obviously fails because months range in duration from 28 to 31 days:

Code and output
>>> timedelta(30)
datetime.timedelta(30)

The general indeterminate duration of a month is exactly why bankers use 30 days as their standard value and why scientists prefer other date formats.

Modern Python If you need to add calendar months or years to a date, the standard library does not provide a direct method. The datetime.replace() method can be used to construct a new date with an adjusted year or month, taking care to handle month-end edge cases manually. Alternatively, the third-party dateutil library provides relativedelta for this purpose: from dateutil.relativedelta import relativedelta.
How Many Days Until my Birthday?

Remember when you were a kid and carefully counted the days until your next birthday? As a programmer you can skip marking off each day and simply write a program to do the work for you. You can write a simple program that:

  1. Takes your birthday
  2. Converts your birthday to a datetime object
  3. Subtracts the current date from your birthday object
  4. Publishes the results

Ready? Let's do this thing!

When is Your Birthday?

The first step is to accept a date as your birthday. Let's use optparse to accept a string to be converted into a datetime object. Then we'll use a new method, datetime.strptime() to not only convert the string to a date, but confirm that it is a valid date. The datetime.strptime() method works like datetime.strftime(), but in reverse, converting strings to date objects. You use the same date-formatting keys as described for datetime.strftime(), which means you can create common formatting strings used across your application for both creating and rendering time objects.

Code and output
>>> formatter_string = "%m-%d-%Y" # format for MM-DD-YYYY
>>> from datetime import datetime
>>> datetime.strptime("07-24-1967", formatter_string) # The conversion code
datetime.datetime(1967, 7, 24, 0, 0)

But what if someone enters a date such as "1967-07-24" or something like "Python ROCKS" or even "15-35-2010"? Since those does not match the format specified by the formatter string and are not valid dates, datetime.strptime() throws a ValueError exception. This makes it trivial to create datetime validators without having to lean on string methods or even regular expressions, which could handle the rough formatting issue of numbers, but can't as easily handle the confirmation that a date is real.

With what we've learned so far, let's write some birthday.py code:

Code
import logging
from datetime import datetime
from optparse import OptionParser

logging.basicConfig(filename='birthday.log',level=logging.DEBUG)

class InvalidDateFormat(Exception):
    pass

def string_to_date(date):
    """
    Converts 'MM-DD-YYYY' to a date/time object
        or throws an InvalidDateFormat exception
    """
    try:
        # create a datetime object from the date value
        formatter_string = "%m-%d-%Y"
        birthday = datetime.strptime(date, formatter_string)
    except ValueError as e:
        # log the format error then raise it again so it can be handled gracefully
        logging.error(e)
        raise InvalidDateFormat(e)
    return birthday

def birthday_counter(birthday):
    """
    Returns the number of days until your birthday.
        (not yet fully implemented)
    """
    return 100

if __name__ == '__main__':
    parser = OptionParser()
    parser.add_option('-b', '--birthday', dest="birthday", action="store",
    help="Your birthday in MM-DD-YYYY format")
    (options, args) = parser.parse_args()

    format_error_message = "birthday.py requires a date in MM-DD-YYYY format"
    if not options.birthday:
        parser.error(format_error_message)

    try:
        print(birthday_counter(options.birthday))
    except InvalidDateFormat:
        parser.error(format_error_message)

This looks pretty good, but how do you know it works? Time to write a unittest!

Code
from datetime import datetime
import unittest


from birthday import *

class TestBirthday(unittest.TestCase):

    def test_birthday_counter(self):
        self.assertEqual(birthday_counter("10-31-1948"), 100)

    def test_string_to_date(self):

        self.assertRaises(InvalidDateFormat, string_to_date, "10-32-1948")
        # create a new datetime object from scratch
        datetime_obj = datetime(2012, 10, 31)
        self.assertEqual(datetime_obj, string_to_date("10-31-2012"))

if __name__ == "__main__":
    unittest.main()

Save and run it; both tests pass. Take a careful look at the second test, which checks that the string_to_date() function works properly. To do that, its second assertion requires a datetime created from scratch. Hence this line of code:

Code and output
>>> from datetime import datetime
>>> datetime(2012, 10, 31)
datetime.datetime(2012, 10, 31, 0, 0)

Note that the self.assertEqual(datetime_obj, string_to_date("10-31-1948")) assertion is actually just doing datetime_obj == string_to_date("10-31-1948"). Just as you can add or subtract datetime objects to or from each other, you can also do comparisons against them. This means you can do any of these comparisons:

SignDescription
==equals
>greater than
>=greater than or equals
<less than
<=less than or equals
More Ways to Construct Dates

You can get a lot more specific than days. You can specify hours, minutes, seconds, and microseconds. This is good for constructing tests and setting up deadlines and other time-related points. Create making_time.py as shown:

Code
from datetime import datetime
print(datetime(2012, 10, 31))
print(datetime(2012, 10, 31, 12))
print(datetime(2012, 10, 31, 12, 30))
print(datetime(2012, 10, 31, 12, 30, 59))
print(datetime(2012, 10, 31, 12, 30, 59, 300))

Save and run it:

OBSERVE: Results from Running making_time.py
2012-10-31 00:00:00
2012-10-31 12:00:00
2012-10-31 12:30:00
2012-10-31 12:30:59
2012-10-31 12:30:59.000300
Fetching Years, Months, Hours, etc. from a Datetime Object

The datetime object has integer attributes that are specific year, month, day, hour, minute, second, and microsecond representations for that object. Create time_attributes.py as shown below to demonstrate your options:

Code
from datetime import datetime
dt = datetime(2012, 10, 31, 12, 30, 59, 300)
print(dt.year)
print(dt.month)
print(dt.day)
print(dt.hour)
print(dt.minute)
print(dt.second)
print(dt.microsecond)

Save and run it:

OBSERVE: Results from Running time_attributes.py
2012
10
31
12
30
59
300
Finishing the birthday counter

We now have enough information to finish the birthday.py program and test it adequately. Let's expand the unittest to properly test the birthday_counter() function.

Code
from datetime import datetime
import unittest


from birthday import *

class TestBirthday(unittest.TestCase):

    def test_birthday_counter(self):
        
        # will fail on October 31
        self.assertTrue(birthday_counter("10-31-1948") > 0)

        # will fail on February 1
        self.assertTrue(birthday_counter("02-01-1999") > 0)

    def test_string_to_date(self):

        self.assertRaises(InvalidDateFormat, string_to_date, "10-32-1948")
        # create a new datetime object from scratch
        datetime_obj = datetime(2012, 10, 31)
        self.assertEqual(datetime_obj, string_to_date("10-31-2012"))

if __name__ == "__main__":
    unittest.main()

Now, we'll finish the birthday_counter() itself. Because datetime handling can get tricky, we'll include lots of comments and logging.debug statements. Once we confirm that the provided birthday is valid, we can construct an upcoming birthday using attributes from your own birthday and the current year. Give it a try:

Code
import logging
from datetime import datetime, timedelta
from optparse import OptionParser

logging.basicConfig(filename='birthday.log',level=logging.DEBUG)

class InvalidDateFormat(Exception):
    pass

def string_to_date(date):
    """
    Converts 'MM-DD-YYYY' to a date/time object
        or throws an InvalidDateFormat exception
    """
    try:
        # create a datetime object from the date value
        formatter_string = "%m-%d-%Y"
        birthday = datetime.strptime(date, formatter_string)
    except ValueError as e:
        # log the format error then raise it again so it can be handled gracefully
        logging.error(e)
        raise InvalidDateFormat(e)
    return birthday

def birthday_counter(birthday):
    """
    Returns the number of days until your birthday.
    
    """
    
    now = datetime.now()
    birthday = string_to_date(birthday)
    logging.debug("birthday: %s" % birthday)

    # construct the upcoming birthday from this year, your birthday month, and birthday day
    upcoming = datetime(now.year, birthday.month, birthday.day)
    logging.debug("upcoming: %s" % upcoming)

    # Make sure that upcoming is in the future, not the past
    if upcoming < now:
        upcoming = upcoming + timedelta(365)
        logging.debug("fixed upcoming: %s" % upcoming)

    # create a timedelta (duration) between the now and your birthday
    duration = upcoming - now
    logging.debug("duration: %s" % duration)

    # return only the days
    return duration.days

if __name__ == '__main__':
    parser = OptionParser()
    parser.add_option('-b', '--birthday', dest="birthday", action="store",
    help="Your birthday in MM-DD-YYYY format")
    (options, args) = parser.parse_args()

    format_error_message = "birthday.py requires a date in MM-DD-YYYY format"
    if not options.birthday:
        parser.error(format_error_message)

    try:
        print(birthday_counter(options.birthday))
    except InvalidDateFormat:
        parser.error(format_error_message)

Save both programs and run the test:

OBSERVE: Results from Running test_birthday.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.172s

OK

Once the tests pass, run the program:

birthday.py -b 11-01-1957 (as done on 11-29-2010)
336

So how many days is it until your birthday?

Summary

Handling basic dates and times seems easy for us humans to do in our head because we've been taught from a very young age how to read clocks. However, as soon as you need to calculate adding 156 minutes to the current time or 65 days to the current day, things get very challenging. We often need to stop and think about things because the math is not clear—we are converting from decimal into a chaotic mix of base 60, base 24 and other counting systems. Because of this lack of clarity, we need to take extra special care when writing any kind of date/time code.