Skip to content

Code Optimization

Python code performance depends not just on algorithms but on a set of practical micro-optimization techniques. This article summarizes common patterns — from avoiding global variables to choosing the right data structures — each illustrated with timed examples showing real speedups.

Python Code Optimization

Three Fundamental Principles

Three fundamental principles:
    1. Don't optimize too early
        Many developers focus on performance optimization from the very beginning. However,
        "it is much easier to make a correct program fast than to make a fast program correct."
        The prerequisite for optimization is working code. Premature optimization may cause you
        to miss the overall performance picture — don't get the priorities backwards before you
        have a global view.
        
    2. Weigh the cost of optimization
        Optimization has a cost. Eliminating every performance issue is nearly impossible.
        The typical trade-off is time vs. space, or space vs. time. Development cost must also
        be considered.
        
    3. Don't optimize code that doesn't matter
        If you optimize every part of your code, those modifications will make the code hard
        to read and understand. If your code is slow, first find where it is slow — usually
        the inner loop — and focus optimization efforts there. A small time loss elsewhere
        has no meaningful impact.

Avoid Global Variables


# Not recommended. Runtime: 26.8 seconds
    import math

    size = 10000
    for x in range(size):
    for y in range(size):
    z = math.sqrt(x) + math.sqrt(y)
            
    Many developers who start writing Python scripts habitually write code at the global level,
    as in the example above. Because global and local variables are implemented differently,
    code defined at the global scope runs noticeably slower than code inside a function.
    Placing script statements inside a function typically yields a 15-30% speed improvement.
    
# Recommended. Runtime: 20.6 seconds
    import math

    def main():  # Define inside a function to reduce global variable usage
        size = 10000
        for x in range(size):
            for y in range(size):
                z = math.sqrt(x) + math.sqrt(y)

    main()

Avoid Module and Function Attribute Lookups

# Not recommended. Runtime: 14.5 seconds
    import math

    def computeSqrt(size: int):
        result = []
        for i in range(size):
            result.append(math.sqrt(i))
        return result

    def main():
        size = 10000
        for _ in range(size):
            result = computeSqrt(size)

    main()
    
    Every use of . (the attribute access operator) triggers special methods like __getattribute__()
    and __getattr__(), which perform dictionary operations and add overhead. Using a "from import"
    statement eliminates this attribute lookup.
    
# First optimization. Runtime: 10.9 seconds
    from math import sqrt

    def computeSqrt(size: int):
        result = []
        for i in range(size):
            result.append(sqrt(i))  # Avoids math.sqrt lookup
        return result

    def main():
        size = 10000
        for _ in range(size):
            result = computeSqrt(size)

    main()
    
    As noted in the section above, local variable lookups are faster than global ones.
    For the frequently accessed variable sqrt, assigning it to a local variable speeds things up.
    
# Second optimization. Runtime: 9.9 seconds
    import math

    def computeSqrt(size: int):
        result = []
        sqrt = math.sqrt  # Assign to a local variable
        for i in range(size):
            result.append(sqrt(i))  # Avoids math.sqrt lookup
        return result

    def main():
        size = 10000
        for _ in range(size):
            result = computeSqrt(size)

    main()
    
    Besides math.sqrt, the computeSqrt function also uses . for list's append method.
    Assigning that method to a local variable eliminates all . usage inside the for loop.
    
# Recommended. Runtime: 7.9 seconds
    import math

    def computeSqrt(size: int):
        result = []
        append = result.append
        sqrt = math.sqrt    # Assign to a local variable
        for i in range(size):
            append(sqrt(i))  # Avoids both result.append and math.sqrt lookups
        return result

    def main():
        size = 10000
        for _ in range(size):
            result = computeSqrt(size)

    main()

Avoid Class Attribute Lookups

# Not recommended. Runtime: 10.4 seconds
    import math
    from typing import List

    class DemoClass:
        def __init__(self, value: int):
            self._value = value

        def computeSqrt(self, size: int) -> List[float]:
            result = []
            append = result.append
            sqrt = math.sqrt
            for _ in range(size):
                append(sqrt(self._value))
            return result

    def main():
        size = 10000
        for _ in range(size):
            demo_instance = DemoClass(size)
            result = demo_instance.computeSqrt(size)

    main()
    
    The same principle applies to class attributes. Accessing self._value is slower than
    accessing a local variable. Assigning a frequently accessed class attribute to a local
    variable improves speed.

# Recommended. Runtime: 8.0 seconds
    import math
    from typing import List

    class DemoClass:
        def __init__(self, value: int):
            self._value = value

        def computeSqrt(self, size: int) -> List[float]:
            result = []
            append = result.append
            sqrt = math.sqrt
            value = self._value
            for _ in range(size):
                append(sqrt(value))  # Avoids self._value lookup
            return result

    def main():
        size = 10000
        for _ in range(size):
            demo_instance = DemoClass(size)
            demo_instance.computeSqrt(size)

    main()

Avoid Unnecessary Abstraction

# Not recommended. Runtime: 0.55 seconds
    class DemoClass:
        def __init__(self, value: int):
            self.value = value

        @property
        def value(self) -> int:
            return self._value

        @value.setter
        def value(self, x: int):
            self._value = x

    def main():
        size = 1000000
        for i in range(size):
            demo_instance = DemoClass(size)
            value = demo_instance.value
            demo_instance.value = i

    main()
    
    Any time you wrap code with additional processing layers (decorators, property accessors,
    descriptors), you make it slower. In most cases, you should reconsider whether defining
    property accessors is necessary. Using getter/setter functions is often a habit carried
    over from C/C++ style. If it is truly unnecessary, use simple attributes instead.

# Recommended. Runtime: 0.33 seconds
    class DemoClass:
        def __init__(self, value: int):
            self.value = value  # Avoids unnecessary property accessors

    def main():
        size = 1000000
        for i in range(size):
            demo_instance = DemoClass(size)
            value = demo_instance.value
            demo_instance.value = i

    main()

Avoid Data Copying

# Not recommended. Runtime: 6.5 seconds
    def main():
        size = 10000
        for _ in range(size):
            value = range(size)
            value_list = [x for x in value]
            square_list = [x * x for x in value_list]

    main()
    value_list above is completely unnecessary and creates an unnecessary data structure or copy.
    
# Recommended. Runtime: 4.8 seconds
    def main():
        size = 10000
        for _ in range(size):
            value = range(size)
            square_list = [x * x for x in value]  # Avoids unnecessary copy

    main()
    Another situation is being overly paranoid about Python's data-sharing mechanism without
    truly understanding or trusting Python's memory model, and misusing functions like
    copy.deepcopy(). Usually such copy operations can be removed.
    
    Swapping values without a temporary variable:
    
# Not recommended. Runtime: 0.07 seconds
    def main():
        size = 1000000
        for _ in range(size):
            a = 3
            b = 5
            temp = a
            a = b
            b = temp

    main()
    The code above uses a temporary variable temp to swap values. Without an intermediate
    variable, the code is more concise and runs faster.
    
# Recommended. Runtime: 0.06 seconds
    def main():
        size = 1000000
        for _ in range(size):
            a = 3
            b = 5
            a, b = b, a  # No intermediate variable needed

    main()
    
    Use join instead of + for string concatenation:
    
# Not recommended. Runtime: 2.6 seconds
    import string
    from typing import List

    def concatString(string_list: List[str]) -> str:
        result = ''
        for str_i in string_list:
            result += str_i
        return result

    def main():
        string_list = list(string.ascii_letters * 100)
        for _ in range(10000):
            result = concatString(string_list)

    main()
    
    When using a + b for string concatenation, because Python strings are immutable objects,
    a new memory block is allocated and both a and b are copied into it. Concatenating N strings
    this way produces N-1 intermediate strings, each requiring a new memory allocation and copy.
    join(), by contrast, first calculates the total memory needed, allocates it once, then copies
    each string element in  far more efficient.

# Recommended. Runtime: 0.3 seconds
    import string
    from typing import List

    def concatString(string_list: List[str]) -> str:
        return ''.join(string_list)  # Use join instead of +

    def main():
        string_list = list(string.ascii_letters * 100)
        for _ in range(10000):
            result = concatString(string_list)

    main()

Use Short-Circuit Evaluation in if Conditions

# Not recommended. Runtime: 0.05 seconds
    from typing import List

    def concatString(string_list: List[str]) -> str:
        abbreviations = {'cf.', 'e.g.', 'ex.', 'etc.', 'flg.', 'i.e.', 'Mr.', 'vs.'}
        abbr_count = 0
        result = ''
        for str_i in string_list:
            if str_i in abbreviations:
                result += str_i
        return result

    def main():
        for _ in range(10000):
            string_list = ['Mr.', 'Hat', 'is', 'Chasing', 'the', 'black', 'cat', '.']
            result = concatString(string_list)

    main()
    
    Short-circuit evaluation means: for "if a and b", if a is False, the expression returns
    immediately without evaluating b. For "if a or b", if a is True, the expression returns
    immediately without evaluating b. To save time, for "or" statements, place the variable
    more likely to be True first; for "and" statements, put conditions more likely to be False
    first.

# Recommended. Runtime: 0.03 seconds
    from typing import List

    def concatString(string_list: List[str]) -> str:
        abbreviations = {'cf.', 'e.g.', 'ex.', 'etc.', 'flg.', 'i.e.', 'Mr.', 'vs.'}
        abbr_count = 0
        result = ''
        for str_i in string_list:
            if str_i[-1] == '.' and str_i in abbreviations:  # Exploit short-circuit evaluation
                result += str_i
        return result

    def main():
        for _ in range(10000):
            string_list = ['Mr.', 'Hat', 'is', 'Chasing', 'the', 'black', 'cat', '.']
            result = concatString(string_list)

    main()

Loop Optimization

Use for loops instead of while loops:
# Not recommended. Runtime: 6.7 seconds
    def computeSum(size: int) -> int:
        sum_ = 0
        i = 0
        while i < size:
            sum_ += i
            i += 1
        return sum_

    def main():
        size = 10000
        for _ in range(size):
            sum_ = computeSum(size)

    main()
   
    Python's for loop is noticeably faster than while loops.
    
# Recommended. Runtime: 4.3 seconds
    def computeSum(size: int) -> int:
        sum_ = 0
        for i in range(size):  # Use for loop instead of while
            sum_ += i
        return sum_

    def main():
        size = 10000
        for _ in range(size):
            sum_ = computeSum(size)

    main()
    
Use implicit for loops instead of explicit for loops:
Taking the above example further, an implicit for loop replaces the explicit one:
# Recommended. Runtime: 1.7 seconds
    def computeSum(size: int) -> int:
        return sum(range(size))  # Implicit for loop replaces explicit for loop

    def main():
        size = 10000
        for _ in range(size):
            sum = computeSum(size)

    main()
    
Reduce computation in the inner for loop:
# Not recommended. Runtime: 12.8 seconds
    import math

    def main():
        size = 10000
        sqrt = math.sqrt
        for x in range(size):
            for y in range(size):
                z = sqrt(x) + sqrt(y)

    main()
    In the code above, sqrt(x) is inside the inner loop and gets recomputed on every iteration,
    adding unnecessary overhead.
    
# Recommended. Runtime: 7.0 seconds
    import math

    def main():
        size = 10000
        sqrt = math.sqrt
        for x in range(size):
            sqrt_x = sqrt(x)  # Reduce computation inside the inner loop
            for y in range(size):
                z = sqrt_x + sqrt(y)

    main()

Use numba.jit

    numba can JIT-compile Python functions to machine code, dramatically improving execution speed.
    More information about numba: http://numba.pydata.org/


# Recommended. Runtime: 0.62 seconds
    import numba

    @numba.jit
    def computeSum(size: float) -> int:
        sum = 0
        for i in range(size):
            sum += i
        return sum

    def main():
        size = 10000
        for _ in range(size):
            sum = computeSum(size)

    main()

Choose the Right Data Structure

    Python's built-in data structures — str, tuple, list, set, dict — are all implemented in C
    and are very fast. Writing your own data structure to match their performance is nearly
    impossible.

    list is similar to std::vector in C++: a dynamic array. It pre-allocates memory; when the
    pre-allocated space is exhausted and new elements are added, it allocates a larger block,
    copies all existing elements, destroys the old block, then inserts the new element. Deletion
    works similarly. If there are frequent additions and deletions involving large numbers of
    elements, list efficiency is low. Consider using collections.deque  a double-ended queue
    that supports O(1) insertions and deletions at both ends.

    list's search operation is also expensive. If you frequently search for elements in a list
    or need ordered access, use the bisect module to maintain a sorted list and perform binary
    search for better lookup efficiency.

    Another common need is finding the minimum or maximum value. The heapq module converts a
    list into a heap so that getting the minimum value takes O(1) time.

    The time complexities for common Python data structure operations are listed here:
        TimeComplexity - Python Wiki
        wiki.python.org
Last updated on