Skip to main content

Python Performance Tips

Python is known for its readability and ease of use, but sometimes it can be slower than other programming languages. This guide will help you understand how to write more efficient Python code without sacrificing readability or maintainability.

Introduction

Python's flexibility and simplicity make it a popular choice for beginners and professionals alike. However, this convenience sometimes comes at the cost of performance. The good news is that with some simple techniques and best practices, you can significantly improve your Python code's efficiency.

In this guide, we'll explore common performance bottlenecks in Python and learn practical techniques to optimize your code. These tips are especially valuable when working with large datasets or when your application needs to be as responsive as possible.

Why Performance Matters

Even in today's world of powerful computers, efficient code has many benefits:

  • Faster execution times
  • Lower resource usage
  • Better user experience
  • Reduced cloud computing costs
  • Environmental benefits (less energy consumption)

Let's dive into specific techniques to make your Python code faster.

1. Use Built-in Functions and Libraries

Python's built-in functions and standard libraries are often implemented in C and are highly optimized.

Example: Finding the Sum of a List

python
# Slower approach
total = 0
for num in range(1000000):
total += num

# Faster approach using built-in sum()
total = sum(range(1000000))

Output:

# Time comparison (measured using timeit)
Slower approach: 0.0923 seconds
Faster approach: 0.0136 seconds

The built-in sum() function is about 7 times faster than the manual loop approach.

2. Avoid Creating Unnecessary Objects

Object creation and garbage collection in Python can be expensive operations.

Example: String Concatenation

python
# Slower approach (creates many intermediate strings)
def build_string_slow(n):
result = ""
for i in range(n):
result += str(i)
return result

# Faster approach (collects strings and joins once)
def build_string_fast(n):
result = []
for i in range(n):
result.append(str(i))
return "".join(result)

Output:

# Time for n = 100000
build_string_slow: 0.5842 seconds
build_string_fast: 0.0217 seconds

The second approach is significantly faster because it doesn't create a new string object for each concatenation.

3. Use List Comprehensions

List comprehensions are not only more readable but often faster than traditional loops.

python
numbers = list(range(1000))

# Traditional approach
squares_traditional = []
for num in numbers:
squares_traditional.append(num ** 2)

# List comprehension approach
squares_comprehension = [num ** 2 for num in numbers]

Output:

# Time comparison
Traditional approach: 0.0003261 seconds
List comprehension: 0.0001912 seconds

List comprehensions are typically more efficient because they're optimized at the C level in Python's implementation.

4. Use Generator Expressions for Large Datasets

When working with large sequences, generators can save memory by creating values on-demand instead of storing them all at once.

python
# List comprehension (stores all values in memory)
sum_squares_list = sum([x**2 for x in range(1000000)])

# Generator expression (generates values on-demand)
sum_squares_gen = sum(x**2 for x in range(1000000))

Output:

# Memory usage comparison
List comprehension peak memory: ~38 MB
Generator expression peak memory: ~8 MB

While both approaches calculate the same result, the generator expression uses significantly less memory.

5. Use Appropriate Data Structures

Choosing the right data structure can dramatically impact performance.

Example: Membership Testing

python
import time

# Setup
n = 10000
elements = list(range(n))
lookup_element = n - 1 # Worst-case scenario for a list

# Using a list for membership testing
start_time = time.time()
result = lookup_element in elements
list_time = time.time() - start_time

# Using a set for membership testing
elements_set = set(elements)
start_time = time.time()
result = lookup_element in elements_set
set_time = time.time() - start_time

print(f"List lookup time: {list_time:.8f} seconds")
print(f"Set lookup time: {set_time:.8f} seconds")

Output:

List lookup time: 0.00037456 seconds
Set lookup time: 0.00000119 seconds

Sets have O(1) average-case complexity for membership testing, while lists have O(n).

6. Reduce Function Calls with Local Variables

Function calls in Python have overhead. When accessing attributes or methods repeatedly, consider using local variables.

python
import math

# Slower approach
def calculate_distances_slow(points):
distances = []
for x, y in points:
distances.append(math.sqrt(x**2 + y**2))
return distances

# Faster approach
def calculate_distances_fast(points):
distances = []
sqrt = math.sqrt # Local reference to function
for x, y in points:
distances.append(sqrt(x**2 + y**2))
return distances

Output:

# Time for 1,000,000 points
Slower approach: 0.6174 seconds
Faster approach: 0.5321 seconds

By creating a local reference to math.sqrt, we avoid repeated attribute lookups.

7. Use NumPy for Numerical Operations

When working with numerical data, NumPy provides significant performance improvements over native Python operations.

python
import numpy as np
import time

# Setup
n = 1000000
py_list = list(range(n))
np_array = np.array(range(n))

# Native Python multiplication
start_time = time.time()
py_result = [x * 2 for x in py_list]
py_time = time.time() - start_time

# NumPy multiplication
start_time = time.time()
np_result = np_array * 2
np_time = time.time() - start_time

print(f"Python list operation: {py_time:.6f} seconds")
print(f"NumPy array operation: {np_time:.6f} seconds")

Output:

Python list operation: 0.083274 seconds
NumPy array operation: 0.001953 seconds

NumPy operations are much faster because they're implemented in C and operate on entire arrays at once.

8. Profile Your Code

Before optimizing, identify the actual bottlenecks in your code using profiling tools.

python
import cProfile
import pstats

def my_function():
total = 0
for i in range(1000000):
total += i
return total

# Profile the function
cProfile.run('my_function()', 'profile_stats')

# Print the results
p = pstats.Stats('profile_stats')
p.strip_dirs().sort_stats('cumulative').print_stats(10)

Output:

         4 function calls in 0.044 seconds

Ordered by: cumulative time

ncalls tottime percall cumtime percall filename:lineno(function)
1 0.044 0.044 0.044 0.044 <string>:1(<module>)
1 0.000 0.000 0.044 0.044 {built-in method builtins.exec}
1 0.044 0.044 0.044 0.044 <stdin>:1(my_function)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}

9. Use multiprocessing for CPU-Bound Tasks

Python's Global Interpreter Lock (GIL) can limit threading performance. For CPU-bound tasks, use the multiprocessing module instead.

python
import multiprocessing
import time

def cpu_bound_task(number):
return sum(i * i for i in range(number))

def process_numbers_sequentially(numbers):
start = time.time()
results = [cpu_bound_task(number) for number in numbers]
end = time.time()
return results, end - start

def process_numbers_parallel(numbers):
start = time.time()
with multiprocessing.Pool(processes=multiprocessing.cpu_count()) as pool:
results = pool.map(cpu_bound_task, numbers)
end = time.time()
return results, end - start

if __name__ == '__main__':
numbers = [10000000, 10000000, 10000000, 10000000]

sequential_results, sequential_time = process_numbers_sequentially(numbers)
parallel_results, parallel_time = process_numbers_parallel(numbers)

print(f"Sequential processing time: {sequential_time:.2f} seconds")
print(f"Parallel processing time: {parallel_time:.2f} seconds")
print(f"Speedup: {sequential_time/parallel_time:.2f}x")

Output:

Sequential processing time: 9.84 seconds
Parallel processing time: 2.73 seconds
Speedup: 3.60x

On a machine with 4 cores, we achieve a significant speedup using multiprocessing.

10. Use Collections Module for Specialized Data Structures

Python's collections module provides specialized container datatypes with performance advantages.

python
from collections import Counter, defaultdict
import time

# Setup
text = "this is a sample text with repeated words this sample has many repeated words"
words = text.split()

# Using a regular dictionary
start_time = time.time()
word_counts = {}
for word in words:
if word in word_counts:
word_counts[word] += 1
else:
word_counts[word] = 1
regular_dict_time = time.time() - start_time

# Using Counter
start_time = time.time()
word_counts_counter = Counter(words)
counter_time = time.time() - start_time

print(f"Regular dictionary time: {regular_dict_time:.8f} seconds")
print(f"Counter time: {counter_time:.8f} seconds")

Output:

Regular dictionary time: 0.00001502 seconds
Counter time: 0.00000715 seconds

The specialized Counter class is more efficient for counting elements.

Real-World Application: Web Scraping Performance Optimization

Let's look at a practical example of optimizing a web scraping script:

python
import requests
from bs4 import BeautifulSoup
import time
import concurrent.futures

# Slower approach - sequential
def fetch_page_data_sequential(urls):
start_time = time.time()
results = []

for url in urls:
response = requests.get(url)
soup = BeautifulSoup(response.text, 'html.parser')
title = soup.title.string if soup.title else "No title"
results.append(title)

end_time = time.time()
return results, end_time - start_time

# Faster approach - parallel
def fetch_url(url):
response = requests.get(url)
soup = BeautifulSoup(response.text, 'html.parser')
return soup.title.string if soup.title else "No title"

def fetch_page_data_parallel(urls):
start_time = time.time()

with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(fetch_url, urls))

end_time = time.time()
return results, end_time - start_time

In a real application, with URLs like:

python
urls = [
"https://python.org",
"https://pypi.org",
"https://docs.python.org",
"https://stackoverflow.com",
"https://github.com"
]

You would see a significant speedup with the parallel approach, especially as the number of URLs increases.

Summary

We've explored several techniques to improve Python performance:

  1. Use built-in functions and libraries
  2. Avoid creating unnecessary objects
  3. Use list comprehensions
  4. Use generator expressions for large datasets
  5. Choose appropriate data structures
  6. Reduce function call overhead
  7. Use NumPy for numerical operations
  8. Profile your code
  9. Use multiprocessing for CPU-bound tasks
  10. Use specialized data structures from the collections module

Remember that premature optimization can lead to more complex, less maintainable code. Always profile first to identify true bottlenecks, then apply these techniques strategically.

Additional Resources

Exercises

  1. Benchmark Different Approaches: Write a script that compares the performance of list vs. set for different operations like addition, lookup, and removal.

  2. Memory Optimization: Create a function that processes a large text file line by line using generators instead of reading the whole file into memory.

  3. Parallel Processing: Implement a multithreaded and a multiprocessing solution for a CPU-bound task and compare their performance.

  4. Profiling Practice: Use cProfile and pstats to identify bottlenecks in an existing Python script and optimize it.

  5. Data Structure Selection: Implement a solution to find duplicate elements in a large dataset using at least three different approaches, and compare their performance.

By applying these techniques judiciously, you can write Python code that's both elegant and efficient.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)