Table of Contents

    Memory Optimization

    PYTHON & PERFORMANCE

    Memory Optimization — The Complete Guide

    Learn practical techniques to reduce Python's memory footprint: __slots__, generators, arrays, interning, and smart data structures.

    Introduction

    Memory optimization is the practice of reducing how much RAM your program uses — critical when handling large datasets, running on constrained devices, or scaling services to many users. Python prioritizes developer convenience over raw efficiency, so its objects carry overhead. The good news: with a few targeted techniques — __slots__, generators, efficient data structures, and object interning — you can dramatically shrink memory usage without rewriting your whole program.

    In one line: Memory optimization means storing the same data using fewer bytes — by choosing leaner objects and lazy evaluation.

    Real-World Analogy

    Packing a Suitcase Efficiently

    A careless packer throws bulky items in loosely and runs out of space. A smart traveller rolls clothes, uses compression bags, and only packs what's needed. Memory optimization is that smart packing — fitting the same "trip" (your data) into far less space through better organization.

    Prerequisites

    Before You Start

    • Python 3.x installed
    • Understanding of classes, lists, and dictionaries
    • Familiarity with generators and iterators (helpful)
    • The sys module (for getsizeof) is built in
    • Awareness of reference counting and how Python stores objects

    Rule Zero: Measure First

    Never optimize blindly — measure memory usage before and after with sys.getsizeof or profilers.

    import sys
    
    print(sys.getsizeof(42))         # int size in bytes
    print(sys.getsizeof("hello"))    # string size
    print(sys.getsizeof([1, 2, 3]))  # list size
    
    # For deep/total size, use tools like tracemalloc or pympler
    Tip Use tracemalloc (built in) to find which lines allocate the most memory before optimizing anything.

    Technique 1: __slots__

    By default, each object stores its attributes in a per-instance __dict__, which is memory-hungry. Defining __slots__ tells Python to use a fixed, compact layout instead — often cutting memory per object by more than half.

    import sys
    
    # Without __slots__ — uses a per-instance __dict__
    class PointA:
        def __init__(self, x, y):
            self.x = x
            self.y = y
    
    # With __slots__ — no __dict__, fixed layout
    class PointB:
        __slots__ = ("x", "y")
        def __init__(self, x, y):
            self.x = x
            self.y = y
    
    a = PointA(1, 2)
    b = PointB(1, 2)
    # PointB instances use significantly less memory each
    Trade-off With __slots__ you can't add new attributes dynamically, and you lose __dict__. Use it for many small, fixed-shape objects.

    Technique 2: Generators over Lists

    A list holds every element in memory at once; a generator produces items one at a time — huge savings for large sequences.

    import sys
    
    # List comprehension — stores ALL values in memory
    squares_list = [x * x for x in range(1_000_000)]
    print(sys.getsizeof(squares_list))   # many megabytes
    
    # Generator expression — stores only the recipe
    squares_gen = (x * x for x in range(1_000_000))
    print(sys.getsizeof(squares_gen))    # a few hundred bytes!
    When to use Use generators when you iterate once and don't need random access or the full list at the same time.

    Technique 3: Leaner Data Structures

    Instead of Use Why
    list of numbers array.array Stores raw values, not full objects
    large numeric data numpy arrays Compact, typed, contiguous memory
    class with fixed fields namedtuple / __slots__ No per-instance dict overhead
    list for membership tests set Faster and often smaller for lookups
    immutable sequence tuple Smaller than an equivalent list
    import array
    import sys
    
    # A list of ints — each int is a full Python object
    py_list = [i for i in range(1000)]
    
    # An array of typed ints — raw C-level storage
    arr = array.array("i", range(1000))
    
    print(sys.getsizeof(py_list))   # larger
    print(sys.getsizeof(arr))       # much smaller

    Technique 4: String Interning

    Python reuses (interns) small strings and integers so identical values share one object in memory.

    import sys
    
    # Manually intern repeated strings to share memory
    words = [sys.intern(w) for w in load_many_repeated_words()]
    # Now identical words point to the SAME object,
    # saving memory when the same value appears thousands of times
    Best for Interning helps most when you have huge numbers of duplicate strings (e.g. parsing logs or CSV columns).

    Technique 5: Process Data Lazily

    Read and process large files line by line instead of loading everything into memory.

    # Bad: loads the ENTIRE file into memory
    with open("huge.log") as f:
        lines = f.readlines()        # all lines at once
        for line in lines:
            process(line)
    
    # Good: streams one line at a time
    with open("huge.log") as f:
        for line in f:               # lazy iteration
            process(line)

    Quantifying the Savings

    Suppose you have \(N\) objects, and an optimization reduces per-object size from \(s\) bytes to \(s'\) bytes. The total memory saved is:

    \[ \Delta M = N \times (s - s') \]

    This is why per-object optimizations like __slots__ matter so much: a small saving \(s - s'\) multiplied by millions of objects \(N\) becomes enormous.

    Technique Summary

    Technique Best For
    __slots__ Many small objects with fixed attributes
    Generators Large sequences iterated once
    array / numpy Large homogeneous numeric data
    String interning Massive duplicate strings
    Lazy file reading Large files/streams
    del + gc Freeing large temporaries early

    Best Practices

    Do This

    • Profile first with tracemalloc — optimize the real hotspots
    • Use __slots__ for classes instantiated in large numbers
    • Prefer generators and lazy iteration for big datasets
    • Use array or numpy for large numeric collections
    • Intern strings that repeat heavily
    • Delete large temporaries with del when done to free them sooner

    Common Mistakes

    Bad Optimizing before measuring — you may spend effort on code that isn't the memory bottleneck.
    Good Profile with tracemalloc first, then target the biggest allocations.
    Bad Adding __slots__ everywhere, even to rarely-created classes — the complexity isn't worth it.
    Good Reserve __slots__ for classes you instantiate thousands or millions of times.

    Interview Questions

    Question Short Answer
    What does __slots__ do? Replaces the per-instance __dict__ with a compact fixed layout, saving memory.
    Why do generators save memory? They yield items lazily instead of storing the whole sequence at once.
    When use array over list? For large collections of the same numeric type — it stores raw values.
    What is string interning? Reusing one object for identical string values to avoid duplicates.
    How do you find memory hotspots? Profile with tracemalloc or tools like pympler.

    Quick Revision

    Goal Technique
    Shrink objects __slots__
    Avoid holding all data Generators
    Compact numbers array / numpy
    Deduplicate strings sys.intern
    Find hotspots tracemalloc

    Key Takeaways

    Memory optimization is about storing the same data in fewer bytes: measure first with tracemalloc, then apply __slots__ for small objects, generators for big sequences, array/numpy for numbers, and interning for duplicate strings. Small per-object savings scale massively across millions of objects.