6 Ways to Improve Performance in Python
In this article we describe a series of recommendations and best practices for programming in Python (version 2.7) so your code runs faster and cleaner.
You can download the project for this post by clicking HERE.
In this article we are going to describe a series of recommendations and best practices for programming in Python (version 2.7). Some of them apply to any programming language, and others consist of programming in a more “Pythonic” style whenever possible.
These recommendations apply to:
- List comprehensions
- Generators
- Membership checks with data structures that hash their data.
- Strings
- Intermediate outputs
- Caches
List comprehensions
Script: 01 – comprehensions.py
List comprehensions are a way of creating lists in Python from iterables (other lists, dictionaries, sets, etc.). As we are going to see, they don’t solve a new problem (the bad example will look familiar), but they give us a new syntax that makes Python run them faster.
| Good way | Bad way |
def good_list():
my_list = [value for value in range(elements)]
|
def bad_list():
my_list = []
for value in range(elements):
my_list.append(value)
|
As we can see, in both cases we are filling a list with the numbers from 0 to elements-1. The second one is the classic way of solving the problem: we define a list and fill it with the elements one by one. The first one is how you are supposed to do it in Python. If we run this n times, measure the execution times and take the average, we get the following results (for n = 3):
| Good (sec) | 0.0160256226858 | 1.68 times faster |
| Bad (sec) | 0.0268766085307 |
As we can see, it is faster if we do it the way Python gives us. Some of you may be thinking that this is just a way of turning for loops into this Python form, and partly that’s true, with the caveat that every list comprehension can be converted into a for loop, but not every for loop can be turned into this kind of list. List comprehensions let us operate on the values, for example squaring the elements:
def square_good_list():
my_list = [value * value for value in range(elements)]
Or also add only the elements that meet a certain condition, such as the even ones:
def good_list_validation():
my_list = [value for value in range(elements)
if value % 2 == 0]
Generators
Script: 02 – generator.py
Generators, as the name suggests, are used to generate sequences of values. The sequences are generated little by little. Here performance has two dimensions: computation time and memory usage.
Generators become indispensable when working with very large series: if we wanted to generate all the prime numbers and return them, it would be impossible, since we would run out of memory. With a generator, however, we can produce them each time we need one and we won’t run out of memory.
range is a generator in Python 3, but not in Python 2, where we have xrange. To see the difference more clearly:
- If in Python 2 we call range(3), the function generates all the elements [0,1,2] and returns them, whereas xrange(3) returns one element whenever we ask for it (each time we iterate)
- Xrange always uses the same amount of memory.
As we said, the good way to define a generator in Python 2 is with xrange
| Good way | Bad way |
def good_generator():
return (x*2 for x in xrange(elements))
|
def bad_generator():
return (x*2 for x in range(elements))
|
We can also define our own generator like this:
def generator(element_number, start_value=0):
while start_value < element_number:
yield start_value
start_value += 1
With the @profile decorator we can see how much memory is being used. To do that we need memory_profiler installed, which we can do with:
pip install -U memory_profiler
Line # Mem usage Increment Line Contents
================================================
17 12.7 MiB 0.0 MiB @profile
18 def my_generator():
19 12.7 MiB 0.0 MiB return (x*2 for x in generator(elements))
Line # Mem usage Increment Line Contents
================================================
22 12.7 MiB 0.0 MiB @profile
23 def my_generator_testing():
24 12.7 MiB 0.0 MiB for loop_count in my_generator():
25 12.7 MiB 0.0 MiB foo = loop_count + 3
Line # Mem usage Increment Line Contents
================================================
27 12.7 MiB 0.0 MiB @profile
28 def good_generator():
29 12.7 MiB 0.0 MiB return (x*2 for x in xrange(elements))
Line # Mem usage Increment Line Contents
================================================
32 12.7 MiB 0.0 MiB @profile
33 def good_generator_testing():
34 12.7 MiB 0.0 MiB for loop_count in good_generator():
35 12.7 MiB 0.0 MiB foo = loop_count + 3
Line # Mem usage Increment Line Contents
================================================
37 12.7 MiB 0.0 MiB @profile
38 def bad_generator():
39 43.7 MiB 31.0 MiB return (x*2 for x in range(elements))
Line # Mem usage Increment Line Contents
================================================
42 12.7 MiB 0.0 MiB @profile
43 def bad_generator_testing():
44 43.7 MiB 31.0 MiB for loop_count in bad_generator():
45 43.7 MiB 0.0 MiB foo = loop_count + 3
| Good (sec) | 0.0160256226858 | 1.68 times faster |
| Bad (sec) | 0.0268766085307 | |
| Custom (sec) | 0.130264997482 | 1.74 times faster |
The time is slightly lower, but it isn’t really relevant. The most relevant thing in this example is the memory increase from 13MB to 31MB with just 1,000,000 integer elements. In fact, since these sizes fit in memory, we can see that the “bad” way is faster, because once it has everything in memory it can access it more quickly. The times were taken without the @profile decorator, so if you run it with the decorator on, you will get higher times.
More information about Generators in Python and examples in our dedicated article by clicking here.
Membership checks with data structures that hash their data
Script: 03 – membership_checks.py
Whenever we find ourselves using the word in, it will be much more efficient with a set than with a list. The reason is that sets compute the hash of the elements they contain, so to know whether an element is in the set, Python computes the element’s hash and checks whether it has it. This takes constant time. In a list, the interpreter has to go element by element checking whether each one is the element we want to know is in the list.
Let’s not forget that a set stores its elements uniquely and unordered, so it may not fit our problem. Let’s define a list and a set to check it:
my_set = {value for value in range(elements)}
my_list = [value for value in range(elements)]
start = time.time()
999999 in my_set
end = time.time()
good_time = end - start
start = time.time()
999999 in my_list
end = time.time()
bad_time = end - start
| Good (sec) | 0.0000040531158 | 4260 times faster |
| Bad (sec) | 0.0172669887543 |
As we can see, since it is the last element, the list time is greater than the set time:
start = time.time() 5 in my_set end = time.time() good_time = end - start start = time.time() 5 in my_list end = time.time() bad_time = end - start
| Good (sec) | 0.0000019073486 | 2 times slower |
| Bad (sec) | 0.0000009536743 |
On the other hand, if we search near the beginning of the list, searching the list is faster
Strings
Script: 04 – strings.py
This section applies to many programming languages, including Java. Strings are immutable objects, which means that if we concatenate with the ‘+’ operator we are creating new objects, so it is not a good idea to concatenate many of them, to avoid creating a new object with each concatenation. Strings should always be handled with functions designed for it, such as join.
| Good way | Bad way |
def good_string_joiner():
''.join(examples)
|
def bad_string_joiner():
final_string = ''
for c in examples:
final_string += c
|
The execution time results are:
| Good (sec) | 0.00900101661682 | 5.89 times faster |
| Bad (sec) | 0.0530691146851 |
If we want to insert strings in the middle of another string, the cleanest way is to use a placeholder and the % operator, for example:
print ("Ms. %s! My %s crawled in my %s and then I %s it."
"Can I have another one?" % ("Hoover", "worm", "mouth", "ate"))
Another option is the format function, which also lets us define the order of substitution.
print ("Ms. {}! My {} crawled in my {} and then I {} it."
"Can I have another one?".format("Hoover", "worm", "mouth", "ate"))
print ("Ms. {0}! My {2} crawled in my {1} and then I {3} it."
"Can I have another one?".format("Hoover", "worm", "mouth", "ate"))
As a result we get:
Ms. Hoover! My worm crawled in my mouth and then I ate it.Can I have another one?
Ms. Hoover! My worm crawled in my mouth and then I ate it.Can I have another one?
Ms. Hoover! My mouth crawled in my worm and then I ate it.Can I have another one?
Intermediate outputs
Script: 05 – intermediate.py
This is another problem that is not specific to Python. Every time we do a print, we are making a call to the operating system, and these calls are synchronous, so until the operating system finishes, we can’t continue. It is very common to use them for debugging, which is not a good practice. For debugging we have debuggers!
| Good way | Bad way |
def good_intermediate():
for loop_count in range(elements):
print ('Hi how are you')
|
def bad_intermediate():
for loop_count in range(elements):
print ('Hi')
print ('How')
print ('Are')
print ('You')
|
The execution time results are:
Caches
Script: 06 – caches.py
Caches are very useful when we have to do complex calculations that may be repeated. Or, for example, if we have to access a database a lot and want to avoid that latency. If the calculation is not complicated or the latency is small, it isn’t worth storing these results, because caches don’t have zero cost. You have to look at what eviction policy they have, you have to add elements, query those elements to see whether we already have them or not, etc. Python 3 has its own cache system built in; for Python 2 there are many alternatives, and here we will use repoze.lru, which we can install with:
pip install repoze.lru
In this case we will use the @lru_cache decorator, where we specify the cache size. This function has a sleep to simulate an expensive operation
@lru_cache(maxsize=16)
def cached_expensive_function(value):
time.sleep(0.1)
return value * 2
def non_cached_expensive_function(value):
time.sleep(0.1)
return value * 2
| Good (sec) | 0.802917957306 | 124 times faster |
| Bad (sec) | 100.161109209 |
If the function is not expensive (we remove the sleep), we see that the cache becomes counterproductive:
@lru_cache(maxsize=16)
def cached_inexpensive_function(value):
return value * 2
def non_cached_inexpensive_function(value):
return value * 2
| Good (sec) | 0.0012629032135 | 5.75 times slower |
| Bad (sec) | 0.0002191066741 |
References
To write this tutorial we used the following article as a reference: 7 Tips For Python Performance (Packt)
“6 Ways to Improve Performance in Python” by Dionisio Cortés at jarroba.com, licensed under Creative Commons Attribution-NonCommercial-ShareAlike 3.0. Registered at Safe Creative.

You may also like
Advanced Dockerfile: Multi-Stage Builds, Registries, and Tricks
Free Docker course. Expert Docker tricks: multi-stage builds, optimizing images, faster build times, and distributing images through Docker registries.
Dockerfile: Directives and Dockerizing Projects
Free Docker course. A Dockerfile is a file of directives for building and deploying custom applications with Docker. Learn how to Dockerize your projects.



Leave a comment