Practical 10 continued: Measuring Execution Time, and the Calendar Module
Chapter Twenty
Syllabus topic Module 1, practical 10(b), "Write a program to measure program execution time", and 10(c), "Write a program using the calendar module to print the weekday of the first day of a given month and year"
Pages 119 to 125 of 297
Aim
To measure the execution time of a program, and to print the weekday of the first day of a given month and year using the calendar module.
Part one: measuring execution time
The three clocks, and which one to use
import time
print("time.time() ", type(time.time()).__name__,
"seconds since 1 January 1970")
print("time.perf_counter() ", type(time.perf_counter()).__name__,
"a counter for measuring, with no fixed zero")
print("time.process_time() ", type(time.process_time()).__name__,
"CPU time used by this process only")
print()
print("perf_counter resolution:", f'{time.get_clock_info("perf_counter").resolution:.0e}')
print("time resolution :", f'{time.get_clock_info("time").resolution:.0e}')
print("is perf_counter monotonic?", time.get_clock_info("perf_counter").monotonic)
print("is time monotonic? ", time.get_clock_info("time").monotonic)time.time() float seconds since 1 January 1970
time.perf_counter() float a counter for measuring, with no fixed zero
time.process_time() float CPU time used by this process only
perf_counter resolution: 4e-08
time resolution : 1e-06
is perf_counter monotonic? True
is time monotonic? False| Clock | Measures | Use it for |
|---|---|---|
time.time() | the wall clock, seconds since 1970 | what time is it |
time.perf_counter() | elapsed time, at the best resolution available | measuring how long something took |
time.process_time() | CPU time used, sleeping not counted | how much work was done |
The two resolutions above are this machine's and are printed in exponent form so they fit; yours may differ, and the smaller number is the finer clock.
perf_counter is the right clock for this exercise, and the last two lines of that output are why. It is monotonic, which means it never goes backwards. time.time() is not: it follows the system clock, so if the clock is corrected, or a time zone changes, or the machine synchronises with a time server in the middle of your measurement, the difference you compute can be wrong and can even be negative.
process_time answers a different question, and the difference shows when a program waits:
import time
start_perf = time.perf_counter()
start_cpu = time.process_time()
time.sleep(0.2)
print(f"perf_counter saw {time.perf_counter() - start_perf:.2f} seconds pass")
print(f"process_time saw {time.process_time() - start_cpu:.2f} seconds of CPU used")
print("because sleeping uses no CPU at all")perf_counter saw 0.21 seconds pass
process_time saw 0.00 seconds of CPU used
because sleeping uses no CPU at allTiming a whole program
import time
start = time.perf_counter()
total = 0
for n in range(1_000_000):
total += n
elapsed = time.perf_counter() - start
print(f"the sum is {total}")
print(f"it took {elapsed:.4f} seconds on this machine")
print(f"which is {elapsed * 1000:.1f} milliseconds")the sum is 499999500000
it took 0.0759 seconds on this machine
which is 75.9 millisecondsThe sum itself is exact and the same everywhere: the sum of 0 to 999999. The time is not, and the .4f is deliberate, because printing fifteen digits of a measurement implies a precision the measurement does not have.
1_000_000 with underscores is the same number as 1000000. Python ignores the underscores and they make a long number readable, which is worth knowing.
Practical 10 continued: Measuring Execution Time, and the Calendar Module
The wrong way to compare two pieces of code
import time
def with_loop(n):
total = 0
for i in range(n):
total += i
return total
def with_sum(n):
return sum(range(n))
for label, function in [("explicit loop", with_loop), ("built in sum", with_sum)]:
start = time.perf_counter()
answer = function(100_000)
elapsed = time.perf_counter() - start
print(f"{label:<14} answer {answer} in {elapsed * 1000:.3f} ms")explicit loop answer 4999950000 in 3.368 ms
built in sum answer 4999950000 in 0.856 msBoth answers are right and the two times are a single measurement each. That is not enough to conclude anything, and saying so is the difference between a measurement and a guess. The next section is the fix.
timeit, which is the right tool
import timeit
def with_loop(n):
total = 0
for i in range(n):
total += i
return total
def with_sum(n):
return sum(range(n))
runs = 200
loop_total = timeit.timeit(lambda: with_loop(100_000), number=runs)
sum_total = timeit.timeit(lambda: with_sum(100_000), number=runs)
print(f"over {runs} runs")
print(f" explicit loop {loop_total / runs * 1000:.3f} ms per run")
print(f" built in sum {sum_total / runs * 1000:.3f} ms per run")
print(f" sum was about {loop_total / sum_total:.1f} times faster on this machine")over 200 runs
explicit loop 3.458 ms per run
built in sum 0.973 ms per run
sum was about 3.6 times faster on this machinetimeit runs the code many times and gives the total, so dividing by the count gives a per run figure that is far steadier than one reading. It also turns off the garbage collector during the run, which removes another source of noise.
The ratio is the finding, not the milliseconds. Your own machine will print different times and a similar ratio, and the reason the built in sum wins is that its loop runs in compiled code rather than in the interpreter.
The way to report a measurement honestly
import statistics
import timeit
def with_loop(n):
total = 0
for i in range(n):
total += i
return total
samples = [timeit.timeit(lambda: with_loop(50_000), number=20) / 20 * 1000
for _ in range(7)]
print(f"seven samples, milliseconds each:")
print(" ", [round(s, 3) for s in samples])
print(f" best {min(samples):.3f} ms")
print(f" median {statistics.median(samples):.3f} ms")
print(f" worst {max(samples):.3f} ms")
print(f" spread {max(samples) - min(samples):.3f} ms")seven samples, milliseconds each:
[1.475, 1.554, 1.556, 1.492, 1.468, 1.439, 1.461]
best 1.439 ms
median 1.475 ms
worst 1.556 ms
spread 0.117 msReport the best or the median, never the mean, and never a single reading. The worst sample usually means something else on the machine took the processor for a moment, which says nothing about your program. Quoting the spread as well shows you know how much to trust the figure.
Practical 10 continued: Measuring Execution Time, and the Calendar Module
A decorator, which is the tidy way to time a function
import time
from functools import wraps
def timed(function):
"""Print how long the function took, every time it is called."""
@wraps(function)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = function(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f" {function.__name__}{args} took {elapsed * 1000:.3f} ms")
return result
return wrapper
@timed
def factorial(n):
result = 1
for i in range(2, n + 1):
result *= i
return result
digits = len(str(factorial(1000)))
print("1000! has", digits, "digits") factorial(1000,) took 0.273 ms
1000! has 2568 digits@timed above def factorial means "pass this function through timed and use what comes back". The wrapper times the call and passes the answer on, so nothing else in the program changes. @wraps copies the original name and docstring onto the wrapper, so function.__name__ still says factorial.
The digit count is exact and the same on every machine; the time is not.
Part two: the calendar module
MU's question
import calendar
year = 2026
month = 10
first_weekday, days_in_month = calendar.monthrange(year, month)
print(f"{calendar.month_name[month]} {year}")
print(f" monthrange gives two values, unpacked above")
print(f" the first day is weekday number {int(first_weekday)}")
print(f" which is {calendar.day_name[first_weekday]}")
print(f" and the month has {days_in_month} days")October 2026
monthrange gives two values, unpacked above
the first day is weekday number 3
which is Thursday
and the month has 31 dayscalendar.monthrange(year, month) returns a tuple of two things, which is [Practical 8: the Tuple Return, Area and Circumference] again: the weekday of the first day and the number of days in the month. That one call answers MU's row.
The int() around the weekday is not decoration. From Python 3.12 the weekday comes back as a member of an enumeration called calendar.Day, so printing the raw tuple shows (calendar.THURSDAY, 31) on a new Python and (3, 31) on an older one. It behaves as the number 3 in every way that matters, including indexing day_name, and int() makes the printed output the same on every version.
Monday is 0 and Sunday is 6 in the calendar module, and calendar.day_name is the list that turns the number into a name.
There is also a direct call:
import calendar
print("weekday(2026, 10, 5) =", calendar.weekday(2026, 10, 5),
"which is", calendar.day_name[calendar.weekday(2026, 10, 5)])
print("weekday(2026, 10, 1) =", calendar.weekday(2026, 10, 1),
"which is", calendar.day_name[calendar.weekday(2026, 10, 1)])
print()
print("the seven day names:", list(calendar.day_name))
print("abbreviated :", list(calendar.day_abbr))
print("the month names :", list(calendar.month_name)[1:])weekday(2026, 10, 5) = 0 which is Monday
weekday(2026, 10, 1) = 3 which is Thursday
the seven day names: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
abbreviated : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
the month names : ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']Practical 10 continued: Measuring Execution Time, and the Calendar Module
calendar.month_name has an empty string at index 0, so that month_name[1] is January and the index matches the month number. That is why the last line above slices from 1. It is a small thing and it produces a blank first entry in anybody's output who forgets it.
The program
import calendar
def first_day_of(year, month):
"""The name of the weekday on which the given month starts."""
if not 1 <= month <= 12:
raise ValueError("a month is 1 to 12")
weekday_number, _ = calendar.monthrange(year, month)
return calendar.day_name[weekday_number]
year = int(input("Enter the year (for example 2026): "))
month = int(input("Enter the month (1 to 12) : "))
print(f"{calendar.month_name[month]} {year} begins on a {first_day_of(year, month)}")
print(f"and has {calendar.monthrange(year, month)[1]} days")2026
10Enter the year (for example 2026): Enter the month (1 to 12) : October 2026 begins on a Thursday
and has 31 daysPrinting the month, which is what makes it a journal entry
import calendar
print(calendar.month(2026, 10)) October 2026
Mo Tu We Th Fr Sa Su
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 31calendar.month(year, month) returns the whole month as a string, laid out as a calendar. Read the first row against the program above: the 1st sits under the column the program named.
import calendar
print("as a list of weeks, with 0 for a day in another month:")
for week in calendar.monthcalendar(2026, 10):
print(" ", week)
print()
print("only the real days of the first week:",
[day for day in calendar.monthcalendar(2026, 10)[0] if day != 0])as a list of weeks, with 0 for a day in another month:
[0, 0, 0, 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, 31, 0]
only the real days of the first week: [1, 2, 3, 4]monthcalendar gives the month as a list of weeks, each a list of seven day numbers. A day belonging to another month is printed as 0. That is the shape to use when a program needs to work with the weeks rather than print them.
The rest of the module, briefly
import calendar
print("is 2024 a leap year? ", calendar.isleap(2024))
print("is 1900 a leap year? ", calendar.isleap(1900))
print("leap years 2000 to 2030 :", calendar.leapdays(2000, 2030))
print("days in February 2024 :", calendar.monthrange(2024, 2)[1])
print("days in February 2023 :", calendar.monthrange(2023, 2)[1])
print("the first day of the week:", calendar.firstweekday(), "which is Monday")
print()
print("every month of 2026 and the day it starts on:")
for month in range(1, 13):
starts, days = calendar.monthrange(2026, month)
print(f" {calendar.month_name[month]:<10} starts {calendar.day_name[starts]:<10} "
f"{days} days")Practical 10 continued: Measuring Execution Time, and the Calendar Module
is 2024 a leap year? True
is 1900 a leap year? False
leap years 2000 to 2030 : 8
days in February 2024 : 29
days in February 2023 : 28
the first day of the week: 0 which is Monday
every month of 2026 and the day it starts on:
January starts Thursday 31 days
February starts Sunday 28 days
March starts Sunday 31 days
April starts Wednesday 30 days
May starts Friday 31 days
June starts Monday 30 days
July starts Wednesday 31 days
August starts Saturday 31 days
September starts Tuesday 30 days
October starts Thursday 31 days
November starts Sunday 30 days
December starts Tuesday 31 dayscalendar.leapdays(a, b) counts the leap years in a range, with the start included and the end excluded, which is worth noticing before you quote the number.
The February rows are the leap year rule showing up as a number of days, which is a neater demonstration than isleap on its own.
Procedure
- Save as
practical10b.py. Print the resolution and the monotonic flag ofperf_counterand of
time, and say which you will use and why.
- Time a loop that sums a million numbers with
perf_counter, printing the elapsed time to four
decimal places.
- Time two ways of doing the same work once each, and say why one reading each proves nothing.
- Use
timeitwithnumber=200and report the per run figure and the ratio. - Take seven samples and report the best, the median, the worst and the spread.
- Save as
practical10c.py.import calendar. - Read a year and a month with
int(input()). - Call
calendar.monthrange(year, month), unpack the two values, and print
calendar.day_name[first].
- Print the month with
calendar.month(year, month)and check that the 1st is in the column you
named.
- Print every month of the year with the day it starts on, and the days in February for a leap
year and a common year.
Result
perf_counter reported itself monotonic and time did not, which is why the measurements use perf_counter. Sleeping for two tenths of a second was seen by perf_counter and not by process_time, confirming that sleeping uses no CPU. The built in sum was faster than the explicit loop over 200 runs of timeit, and the ratio rather than the milliseconds is the finding. Seven samples of the same work differed, which is why the median and the spread are reported and a single reading is not. On the calendar half, monthrange returned the weekday of the first day and the number of days, and the printed calendar put the 1st in the column the program named. monthrange(2024, 2)[1] was 29 and monthrange(2023, 2)[1] was 28.
Practical 10 continued: Measuring Execution Time, and the Calendar Module
Where marks are lost
- Using
time.time()to measure. It is not monotonic;perf_counteris. - One reading. A single measurement proves nothing; use
timeitor take several. - Reporting the mean of several samples. Use the best or the median: a slow sample is usually
another program on the machine.
- Printing fifteen digits of a measurement, which claims a precision the clock does not have.
- Quoting a time from a book as if it were yours. Say "on this machine".
- Forgetting that Monday is 0 in the
calendarmodule. - Forgetting the blank at index 0 of
month_name, which prints an empty first entry. - Using
monthrange(year, month)[0]as the number of days. It is the weekday; the days are
[1].
- Reading the month as a name and not converting it.
int(input())gives a number.
For the journal
Two entries under practical 10. For the timing: the aim, the three clock table with one sentence on monotonic, the timed loop with the elapsed time to four decimal places, and then the timeit comparison with the ratio. The seven samples with the best, median, worst and spread. Write "on this machine" beside every time you record, and one sentence saying that the ratio is the result and the milliseconds are the machine's.
For the calendar: the aim in MU's words, the monthrange call with its tuple printed, the weekday name, and calendar.month(year, month) printed underneath so the answer can be checked by eye against the calendar. One sentence: Monday is 0 in the calendar module, and monthrange returns the weekday of the first day and the number of days as a tuple.
Quick revision
- Three clocks:
time.time()the wall clock,time.perf_counter()for measuring,
time.process_time() for CPU used.
perf_counteris monotonic;time.time()is not. A clock correction can make atime.time()
difference wrong or negative.
- Sleeping is seen by
perf_counterand not byprocess_time. - Pattern:
start = perf_counter(), do the work,elapsed = perf_counter() - start. 1_000_000is1000000. The underscores are ignored.timeit.timeit(callable, number=n)gives the total for n runs; divide by n. It also disables
the garbage collector.
- Report the best or the median of several samples, plus the spread. Never one reading, never
the mean.
- The ratio is what transfers to another machine; the milliseconds do not.
- A timing decorator wraps a function;
@wrapskeeps its name and docstring. calendar.monthrange(year, month)returns (weekday of the 1st, number of days).- Monday is 0 in
calendar.calendar.day_name[n]is the name. calendar.month_namehas a blank at index 0, so index 1 is January.calendar.month(y, m)prints the month;calendar.monthcalendar(y, m)gives weeks as lists with
0 for a day in another month.
Practical 10 continued: Measuring Execution Time, and the Calendar Module
calendar.isleap,calendar.leapdays(a, b)with the end excluded.
Questions you should be able to answer
1. Which clock should you measure with, and why? time.perf_counter(), because it has the best resolution available and it is monotonic, so it never goes backwards.
2. What can go wrong with time.time() for a measurement? It follows the system clock, so a correction or a time server synchronisation during your measurement makes the difference wrong, and it can even come out negative.
3. What is the difference between perf_counter and process_time? perf_counter measures time passing; process_time measures CPU used by this process. A program that sleeps advances the first and not the second.
4. Why is timeit better than one perf_counter reading? It runs the code many times and disables the garbage collector, so the per run figure is far steadier than a single measurement.
5. Of several samples, which one should you report? The best or the median, with the spread. A slow sample usually means something else on the machine took the processor.
6. Why report a ratio rather than milliseconds? Because the milliseconds are your machine's and will not reproduce, while the ratio between two ways of doing the same work usually will.
7. What does calendar.monthrange(2026, 10) return? A tuple: the weekday number of the 1st and the number of days in the month.
8. What number is Monday in the calendar module? 0. Sunday is 6.
9. Write the two lines that print the day a month starts on. first, days = calendar.monthrange(year, month) then print(calendar.day_name[first]).
10. Why does calendar.month_name have an empty string at index 0? So that the index matches the month number, with January at 1.
11. How does monthcalendar show a day that belongs to the previous or next month? As a 0 in the week's list.
The rest of this subject
These notes are cut from the University's printed syllabus. Open the syllabus itself for the same subject.