Python performance is about making your programs run faster or use less memory without changing what they do. It is not about writing clever one-liners or using every trick in the language. It is about finding where your program spends the most time and applying the smallest change that gives the biggest improvement.
Performance optimization always starts with measurement. Guessing what is slow leads to wasted effort on code that may hardly contribute to runtime. The standard library has several measurement tools.
The article on measuring Python performance with timeit covers the first one in detail.
The difference between a snappy program and a slow one is often a single function or loop that could be rewritten to run in a fraction of the time. Finding that bottleneck is the central skill this section teaches.
What makes Python programs slow
Python is an interpreted language with dynamic typing. Every operation carries runtime overhead that compiled languages do not pay.
When you write a + b, Python checks the types of both objects, finds the appropriate add method, calls it, and constructs a new result object. A C program doing the same integer addition compiles to a single CPU instruction.
This overhead is real, but it is often not the thing that makes programs slow in practice. Most Python programs spend their time in one of three places.
First, I/O operations: waiting for network responses, disk reads, database queries, or user input. If your program waits 200 ms for an API response, shaving 2 ms off a Python loop saves only 1 percent. The right fix for I/O-bound code is concurrency, overlapping the waiting tasks so total time drops from the sum of all waits to the maximum single wait.
Second, algorithmic inefficiency: using an O(n squared) approach where O(n log n) or O(n) would work. A program that searches a list on every iteration when it could use a set or dictionary is not slow because Python is slow. It is slow because the approach is slow in any language.
Understanding Python collections and their performance characteristics matters more than micro-optimizing syntax.
Third, Python-level loops: doing iteration, function calls, and object creation in pure Python when the same work could happen inside a compiled library. Moving a loop into a built-in function, a list comprehension, or a library like NumPy can deliver 10x to 100x speedups because the work happens in C instead of Python.
The measurement-first workflow
Before changing any code, you need a number. The number tells you whether the change helped and which code is worth changing at all. The standard workflow has six steps.
Write correct working code first, then measure the current performance with a representative input. Profile to find the bottleneck, then change it.
Measure again to confirm the improvement. Repeat only if performance is still unacceptable.
Step 3 is the hardest part because intuition about what is slow is often wrong. A function that looks expensive might be called once, while a cheap-looking function might run inside a deeply nested loop millions of times. A profiler shows you the truth.
Measuring small code snippets
The timeit module runs a piece of code many times and reports the total or average duration. It disables garbage collection during measurement and picks an appropriate number of repetitions automatically.
import timeit
result = timeit.timeit(
"sum(range(1_000))",
number=10_000,
)
print(f"10k runs: {result:.4f}s")This runs the sum expression ten thousand times and prints the total duration. Divide by the repetition count to get the per-call cost.
For quick comparisons, the command-line interface is often faster than writing a script:
python -m timeit "'-'.join(str(n) for n in range(100))"
python -m timeit "'-'.join([str(n) for n in range(100)])"The second version, which uses a list comprehension instead of a generator expression, is typically faster because str.join needs to build an internal list anyway.
Profiling a whole program
The cProfile module records every function call your program makes and reports the total time spent in each function. It answers the question "where is my program actually spending time?"
import cProfile
import pstats
import io
def slow_function():
total = 0
for i in range(1_000_000):
total += i
return total
def fast_function():
return sum(range(1_000_000))
def main():
slow_function()
fast_function()The profiler tracks two metrics per function: tottime (time in the function's own code) and cumtime (time including all functions it called).
profiler = cProfile.Profile()
profiler.enable()
main()
profiler.disable()
stream = io.StringIO()
stats = pstats.Stats(profiler, stream=stream).sort_stats("cumtime")
stats.print_stats(10)
print(stream.getvalue())The output shows a table with columns for call count, total time, time per call, and cumulative time. The cumtime column is the most useful because it shows total time including everything a function called. A deeper walkthrough is in the article on profiling Python performance bottlenecks with cProfile.
Tracking memory usage
Speed is not the only performance concern. A program that runs fast but consumes gigabytes of memory may still fail under real workloads.
The sys.getsizeof function reports the shallow size of an object in bytes: the memory the object itself occupies, not the objects it references.
import sys
nums = list(range(1000))
print(sys.getsizeof(nums))For deeper analysis, the tracemalloc module tracks every allocation and shows which lines of code are responsible for the most memory use.
import tracemalloc
tracemalloc.start()
data = [i ** 2 for i in range(100_000)]
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:3]:
print(stat)The output identifies the file and line number where each allocation happened, along with the size. This is the memory equivalent of a profiler: it tells you where to look, not what to change.
Speed versus memory: the tradeoff
Optimization often involves trading memory for speed or vice versa. Caching a computed result saves CPU time at the cost of memory. Streaming a large file line by line saves memory at the cost of repeated disk access.
Understanding which resource is scarce in your situation guides the tradeoff. A web server handling thousands of requests per second may need to optimize for memory. A batch job that runs once overnight may prefer speed and can use as much memory as the machine has.
The functools.lru_cache decorator is a textbook example of this tradeoff. It stores function results so repeated calls with the same arguments return instantly.
from functools import lru_cache
@lru_cache(maxsize=128)
def expensive_computation(n):
result = 0
for i in range(n):
result += i ** 0.5
return resultThe first call with a given argument computes and stores the result. Subsequent calls with the same argument return the cached value without recomputing. The maxsize parameter caps memory usage by discarding the least recently used entry when the cache is full.
What to optimize first
Optimization is an investment. You spend your time to save the computer's time. The return is highest when you focus on the code that runs most often.
A useful heuristic is the 80/20 rule: roughly 80 percent of runtime comes from 20 percent of the code. A profiler tells you which 20 percent that is. Within that 20 percent, prioritize changes in this order.
First, use a better algorithm or data structure. Switching from a list to a set for membership tests often yields the biggest gains. The article on Python collections covers how each built-in container behaves.
Second, move work into built-in functions or libraries. Replacing a pure-Python loop with sum, map, filter, or a list comprehension runs the work in C instead of Python.
Third, reduce unnecessary work. Skip computations whose results are never used and cache repeated computations.
Fourth, use concurrency for I/O-bound work. If the profiler shows your program spends most of its time waiting, threading or asyncio can overlap the waits.
Fifth, micro-optimize Python syntax. This should come last because the gains are usually small and the code becomes harder to read.
A common mistake is jumping to step 5 without doing steps 1 through 4. Replacing range(len(seq)) with enumerate(seq) is good style, but if the loop body makes a slow API call, the style change saves microseconds on top of a 200 ms wait. Fix the wait first.
Algorithms matter more than syntax
A 100x speedup from a better algorithm dwarfs a 2x speedup from a clever syntax trick. Python's built-in data structures are implemented in C and highly optimized, so using the right structure for the job often eliminates the need for micro-optimization.
# O(n) membership test: scans the whole list
ids_list = [1, 2, 3, 4, 5]
found = 3 in ids_listThe list version scans elements one by one. With a million items, it might scan all million before finding a match.
# O(1) membership test: hash lookup
ids_set = {1, 2, 3, 4, 5}
found = 3 in ids_setThe set version does one hash computation and one lookup regardless of size. Both snippets find the element, but the performance gap grows with the data size.
When not to optimize
Not every program needs optimization. If a script runs in 0.3 seconds instead of 0.1 seconds and runs once a day, the difference does not matter. Your time as a developer is more expensive than the computer's time in that scenario.
Optimize when the program runs too slowly for its users, processes large amounts of data, or runs in a resource-constrained environment. Also optimize when you are learning and want to understand the performance characteristics of the language.
Do not optimize when the program is already fast enough, when you have not measured where the time goes, or when the optimization makes the code significantly harder to read without a proportional speedup. The most important skill is knowing when to stop optimizing and ship the code.
The performance toolbox at a glance
Python's standard library gives you everything you need to measure and analyze performance:
| Tool | Use case |
|---|---|
| timeit | Micro-benchmark small code snippets |
| cProfile + pstats | Profile whole programs |
| tracemalloc | Track memory allocations |
| sys.getsizeof | Check object memory size |
| time.perf_counter | Manual timing in scripts |
Third-party tools like line_profiler, memory_profiler, and py-spy offer additional detail for specific use cases. But the standard library tools are the right starting point because they are always available and well-documented.
Each tool answers a different question. timeit tells you which of two small snippets is faster, cProfile tells you which function is the bottleneck, and tracemalloc tells you which line allocates the most memory.
Use the tool that answers the question you actually have.
Rune AI
Key Insights
- Python performance optimization starts with measurement, never with guessing.
- I/O-bound programs benefit from concurrency; CPU-bound programs benefit from better algorithms or compiled extensions.
- Use timeit for small snippets, cProfile for program-level profiling, and tracemalloc for memory analysis.
- Focus on the slowest code first and always re-measure after each change.
Frequently Asked Questions
What is Python performance optimization?
When should I start optimizing Python code?
Conclusion
Python performance work starts with measurement, not intuition. Use timeit for micro-benchmarks, a profiler for program-level analysis, and memory tools when memory is the constraint. Focus on the slowest 20 percent of your code and always verify that each optimization actually helped before moving to the next one.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.