Create a Timer in Python: Elapsed Time, Decorators, and more
LearnDataSci is reader-supported. When you purchase through links on our site, earned commissions help support our team of writers, researchers, and designers at no extra cost to you.
In this short guide, we will explore different timer implementations in Python.
In short, we can make a simple timer with Python's time
builtin library like so:
Although very straightforward, this is not the best option for timing the performance of your code.
For the rest of this article, we will expand on this concept by calculating and reporting a function's execution time.
Marking a Point in Time: Python's time Module
The time
module is equipped with many methods to report the GMT or the local time nicely formatted in years, months, days, hours, minutes, and seconds.
For our timing purposes, it's beneficial to have time represented as a single number. We achieved this in the intro code, but here's what the time()
function actually outputs:
The number printed on the screen represents the time in mere seconds passed since some system-dependent epoch (most systems use January 1st, 1970), stripped from any other construct.
Since we have time reduced to a floating-point number, we can just subtract one point in time from the other.
Let's bring our example back from the intro:
Here, we have used time.sleep(10)
to suspend the execution for ten seconds. We have marked the time before and after time.sleep()
, calculated the elapsed time, and printed it out.
The result is a little over ten seconds and is a slightly different number at each execution. This is because timing depends on other system activities—the system runs random operations in between, driving us over ten seconds.
Better alternatives to time()
Since we have roughly established how to calculate the elapsed time, we can refine our code a little. Python's time module offers us alternatives to the time()
function, all reporting the time in seconds but in different ways.
The time()
we have employed up until now reports the system's wall-clock time. We also have a function named monotonic()
at our disposal. This function uses a monotonic clock that cannot go backward or be affected by system clock updates.
If we were planning on measuring a long-running process, we would opt for monotonic()
for its integrity, but we will be measuring simple procedures here.
Python provides us access to a performance counter for short time measurements, called perf_counter()
, which uses the system clock with the highest resolution. Moving on, we will employ perf_counter()
in our calculations.
Timers using context managers
Context managers let us wrap logic with additional setup and teardown code, allowing us to manage resources efficiently. Since we intend to surround code with timing logic, context managers are a perfect fit.
Let's first look at a simple example of a context manager to better understand its structure and use.
Here, demonstrate_cm
is implemented as a context manager using @contextmanager
decorator. The yield
keyword divides the function body into two parts: 1) The expressions above yield
are executed right before the code that is managed 2) The expressions below yield
are executed right after.
Once defined, we attach demonstrate_cm
to a code block using a with
statement.
In the next section, we will use the same structure to create a timer.
Defining a timer context manager
The timer context manager below is very similar to the context manager above, except now we are surrounding the yield
with our timing logic.
Let's quickly review this code.
First, notice that we have placed yield
within a try-finally
block to ensure the framed code block won't affect the context manager's job. If the managed code creates an error, the timer will still report the elapsed time until the error occurred.
Above the try-finally
, we have marked the starting point: the t0
. Within finally
, we have marked the endpoint and calculated the elapsed time. In the end, we have restricted the representation of elapsed
to four decimal points via an f-string and printed it out.
Let's put it to use.
One advantage of using the @contextmanager
decorator to define our timer is that we can use the timer both using the with
statement and as a function decorator, like in the last example. If we wanted to time a certain operation once, we could call in timer()
using a with
statement. However, if we wanted to track a function's run time at every execution, we can decorate the function using @timer()
.
What if we want to pass arguments to our context manager? We'll explore this idea in the next section.
Parameterizing the Timer
We have managed to program a timer that can be applied to any function. It is neat and portable. Yet, we can still go one step further and add some flexibility to it.
In the following code block, we've created a parameterized version of our timer, called timer2
. We have removed the hard-coded methods of accessing and reporting the time and allowed a message
parameter to format the message string.
Inside the with
statement, we passed two arguments: (1) the timer function to use and a custom message for the function to print. Since we're using process_time()
as our timing function, we'll measure the CPU time, excluding the time spent sleeping.
In the next section, we'll work through converting our timer into a decorator, making it easier to apply timing capabilities to our functions.
Defining a Timer Decorator
We could also define our timer as a run-off-the-mill decorator. We'll design this implementation of our timer for recording an average run time for a given function.
In the code below, we're creating a standard decorator that takes the function to be decorated as its parameter. The nested timer is essentially like the previous timers we've created.
The timer code is almost the same as previous timers, but now we've added two variables for tracking time: total_time
and runs
. These values will accumulate and hold the values needed to calculate the average.
Now, we'll apply this decorator to a function we wish to time:
When we decorate random_fn
with @timer_wrapper
, it is shorthand for writing random_fn = timer_wrapper(random_fn)
, which masks the original definition of random_fn
to provide the additional functionality from the decorator. Note that timer_wrapper()
runs only once—it replaces random_fn()
, and its job is done.
The timer()
that is returned from timer_wrapper()
:
- Takes in
*args, **kwargs
—these represent the argumentsfunc
may or may not have. - Records the time as
t0
. - Executes
func
with*args, **kwargs
, and saves the result. - Measures the elapsed time since
t0
. - Reaches out to the parent scope with
nonlocal
to grab the variablestotal_time
andruns
. The keywordnonlocal
announces thattimer()
is not creating new values here, but rather is reaching out to grab non-local ones. - Adds one to
runs
, adds the elapsed time tototal_time
. - Prints the elapsed time -
elapsed
. - Prints the average run time -
(total_time/runs)
. - Returns the result from
func
back to the caller.
Overall, this is a helpful pattern for timing various functions in your code; add the decorator whenever your curious about performance and remove it when no longer needed.
Summary
Measuring the elapsed time inside a program can be accomplished by calling the time.perf_counter()
twice, once at the beginning and once at the end. The difference in the return values would then reveal the execution time.
This functionality can be programmed into a context manager, allowing us to measure the execution time of any code block using a with
statement. We can also implement it using a decorator and inject time measurement calls to the function that is decorated