๐Ÿง  Memory Optimization Under Load

Stream, don't buffer โ€” the fastest way to crash at scale is to load it all into RAM.

The Problem: RSS ร— Concurrency = OOM

Scenario: An endpoint reads a 2 GB export into a list, transforms it, and returns it. On your laptop with one request it's fine. In production with 40 concurrent requests it's 2 GB ร— 40 = 80 GB of peak RSS. The OOM killer reaps the pod, the load balancer reroutes the same traffic to the next pod, and it dies too. That's a memory-driven cascading failure.

Under load, memory is never "how much does one request use" โ€” it's per-request RSS multiplied by in-flight concurrency. Little's Law (intro) sizes that concurrency for you. The single most effective memory technique is to make per-request peak memory bounded and small, independent of input size. That almost always means: stream.

flowchart LR subgraph BUF["โŒ Buffer everything"] B1["load whole input"] --> B2["build giant list"] --> B3["๐Ÿ’ฅ RSS spikes with input size"] end subgraph STR["โœ… Stream in chunks"] S1["pull one chunk"] --> S2["process + emit"] --> S3["drop it, GC reclaims"] S3 --> S1 end STR --> FLAT["flat, bounded memory
(independent of input size)"]

Generators: Lazy Iteration Instead of Giant Lists

A list comprehension materializes every element at once. A generator expression yields them one at a time and holds only the current one. Same syntax, one character of difference, a 1000ร— memory difference.

import sys

# โŒ Eager: builds the whole list in RAM before you touch a single element
squares_list = [x * x for x in range(10_000_000)]
print(sys.getsizeof(squares_list))   # ~85 MB just for the list object

# โœ… Lazy: holds one value at a time, computes on demand
squares_gen = (x * x for x in range(10_000_000))
print(sys.getsizeof(squares_gen))    # ~200 bytes โ€” the generator object, nothing more

total = sum(squares_gen)             # streams through, peak memory stays flat

The same idea powers yield โ€” a function that produces a sequence without ever holding it:

# Process a multi-GB log file line by line โ€” RSS stays at one-line size
def parse_errors(path):
    with open(path) as f:
        for line in f:                    # the file object is itself a lazy iterator
            if "ERROR" in line:
                yield line.rstrip()

# Never materializes the file. Works on a 100 GB log on a 512 MB container.
error_count = sum(1 for _ in parse_errors("app.log"))
Rule: if you're going to iterate once and don't need random access or len(), use a generator. Reserve lists for data you genuinely need to keep and revisit. itertools (islice, chain, groupby) composes generators without ever buffering.

Streaming DB Cursors โ€” Don't Fetch a Million Rows at Once

The classic memory bomb: cursor.fetchall() on a huge result set pulls the entire table into the driver's client buffer and your Python list. Use a server-side cursor so the DB streams rows in windows.

# psycopg (Postgres): a NAMED cursor is server-side and streams
import psycopg

with psycopg.connect(dsn) as conn:
    with conn.cursor(name="stream") as cur:   # named => server-side => streamed
        cur.itersize = 5_000                  # fetch 5k rows per network round-trip
        cur.execute("SELECT id, payload FROM events")
        for row in cur:                       # peak memory = one window, not the table
            process(row)
# SQLAlchemy: yield_per streams the ORM result in batches
from sqlalchemy import select

with Session(engine) as session:
    stmt = select(Event).execution_options(yield_per=1_000)
    for event in session.scalars(stmt):      # 1,000 rows in flight at a time
        process(event)                        # NOT a 10M-row list in RAM
Gotcha: the default client-side cursor buffers the whole result even though for row in cur looks lazy. On Postgres you need a named cursor; on MySQL, SSCursor. Without it, the streaming loop is a lie and you still OOM.

Chunked HTTP Downloads โ€” Never .content a Big Response

response.content / response.json() buffers the entire body. For large payloads, stream the socket and process (or write) in chunks.

import requests

# โœ… stream=True + iter_content: peak memory = one chunk, not the whole file
with requests.get(url, stream=True, timeout=30) as r:
    r.raise_for_status()
    with open("big.parquet", "wb") as f:
        for chunk in r.iter_content(chunk_size=1 << 20):   # 1 MiB windows
            f.write(chunk)
# Async equivalent with httpx โ€” stream and consume as bytes arrive
import httpx

async def download(url):
    async with httpx.AsyncClient(timeout=30) as client:
        async with client.stream("GET", url) as r:
            r.raise_for_status()
            async for chunk in r.aiter_bytes(1 << 20):
                await sink.write(chunk)      # backpressure lives here, see below

__slots__: Shrink Millions of Instances

Every ordinary Python object carries a per-instance __dict__ to hold attributes โ€” flexible, but ~100+ bytes of overhead each. When you have millions of small objects (rows, points, cache entries), __slots__ drops the dict and stores attributes in a fixed C array.

from pympler import asizeof   # pip install pympler โ€” measures the FULL object graph

class PointDict:                # normal: has __dict__
    def __init__(self, x, y):
        self.x, self.y = x, y

class PointSlots:               # slotted: no __dict__
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x, self.y = x, y

print(asizeof.asizeof(PointDict(1, 2)))    # ~152 bytes
print(asizeof.asizeof(PointSlots(1, 2)))   # ~ 56 bytes

# 10M instances:  ~1.5 GB  vs  ~0.56 GB  โ€” the slotted version fits, the other OOMs
Object~Per-instance sizeร— 10M instances
Plain class (__dict__)~152 B~1.5 GB
Class with __slots__~56 B~0.56 GB
@dataclass(slots=True)~56 B~0.56 GB
Tuple / NamedTuple~64 B~0.64 GB
Note: sys.getsizeof(obj) only measures the object's shallow size (not the values it references), so it understates real cost โ€” use pympler.asizeof for the deep total. In Python 3.10+, prefer @dataclass(slots=True) for the same win with less boilerplate. __slots__ costs you dynamic attribute assignment โ€” a fair trade at 10M objects.

Interning: Stop Storing Duplicate Small Objects

If you parse a million records with a status field of "active" / "pending", a naive parser creates a million distinct string objects with the same content. Interning collapses them to one shared object.

import sys

# โŒ each parsed value is its own object even when identical
statuses = [record["status"] for record in stream]   # 1M separate "active" strings

# โœ… intern: identical strings share one object in memory
statuses = [sys.intern(record["status"]) for record in stream]

# Same trick for any small hashable value set โ€” a manual cache:
_cache = {}
def dedupe(value):
    return _cache.setdefault(value, value)   # first wins; the rest reuse it
CPython already interns small ints (โˆ’5..256) and many string literals automatically. Interning pays off for runtime-generated low-cardinality strings โ€” enum-like fields, country codes, MIME types โ€” where you'd otherwise hold millions of duplicate copies.

Object Pools: Reuse Expensive Buffers

Allocating and freeing large buffers on every request churns the allocator and the GC. When the buffer is fixed-size and reusable (a parse scratch area, an I/O buffer), pool it: hand it out, clear it, hand it back.

import queue

# A bounded pool of reusable 1 MiB bytearrays โ€” allocate N once, reuse forever
class BufferPool:
    def __init__(self, count=8, size=1 << 20):
        self._free = queue.Queue()
        for _ in range(count):
            self._free.put(bytearray(size))

    def acquire(self):
        return self._free.get()        # blocks if all buffers are checked out (backpressure!)

    def release(self, buf):
        buf[:] = b"\x00" * len(buf)    # or just reuse the bytes in place
        self._free.put(buf)

pool = BufferPool()

def handle(sock):
    buf = pool.acquire()
    try:
        n = sock.recv_into(buf)        # write straight into the reused buffer, zero new alloc
        process(memoryview(buf)[:n])
    finally:
        pool.release(buf)
A bounded pool doubles as a concurrency limit and backpressure mechanism: when every buffer is in use, acquire() blocks, which throttles intake instead of letting memory balloon. Same shape as connection pools (pooling) and bounded queues (backpressure).

Numeric Data: array, memoryview, NumPy โ€” Not Lists of Ints

A Python int is a full heap object (~28 bytes) and a list stores 8-byte pointers to them. Ten million ints in a list โ‰ˆ 360 MB. The same values in a NumPy int64 array โ‰ˆ 80 MB โ€” a flat C buffer, no per-element objects.

import sys, array
import numpy as np

n = 10_000_000

py_list = list(range(n))                 # list of int objects
arr     = array.array("q", range(n))     # stdlib: packed 64-bit ints, no numpy dep
np_arr  = np.arange(n, dtype=np.int64)   # numpy: packed + vectorized ops

print(sys.getsizeof(py_list))            # ~ 90 MB list + ~ 270 MB of int objects
print(np_arr.nbytes)                     # 80,000,000 bytes โ€” exactly 8 bytes ร— 10M
# memoryview: slice/process a big buffer WITHOUT copying it
data = bytearray(open("frame.raw", "rb").read())
view = memoryview(data)
header = view[:64]          # a window into the same memory โ€” zero-copy
body   = view[64:]          # no new allocation, no duplication
process(body)
For anything numeric and large โ€” feature vectors, time series, image frames โ€” reach for NumPy first. Beyond memory, it moves the loop into C, dodging both per-object overhead and the interpreter cost of a Python for.

The Garbage Collector Under Load

CPython frees most objects immediately via reference counting. The separate generational cyclic GC exists only to reclaim reference cycles (a.b = b; b.a = a). Under load it can pause your process at bad moments โ€” and in preforked servers it can silently blow up memory via copy-on-write.

flowchart LR N["New objects"] --> G0["Gen 0
(collected often)"] G0 -->|survives| G1["Gen 1"] G1 -->|survives| G2["Gen 2
(collected rarely)"] G2 -.->|"gc.freeze()"| PERM["frozen
(never rescanned)"]
import gc

# 1) Preforked servers (gunicorn/uvicorn workers): freeze long-lived startup
#    objects so the child's GC never touches them โ†’ copy-on-write pages stay SHARED.
def post_fork_setup():
    gc.collect()      # clean up first
    gc.freeze()       # move everything alive now into a permanent, unscanned set

# 2) Tune thresholds if gen-0 collections fire too often on allocation-heavy work
gc.set_threshold(50_000, 100, 100)     # raise gen-0 trigger (default is 700)

# 3) Manual control for a short, latency-critical batch: disable, then re-enable
gc.disable()
try:
    crunch_numbers()        # no GC pauses during the hot path
finally:
    gc.enable()
    gc.collect()            # pay the cost once, deliberately, afterwards
gc.disable() caveat: ref-counting still frees non-cyclic garbage, so short jobs are fine. But leave it off in a long-running server and any reference cycle (common with caches, closures, ORM back-refs) never gets collected โ€” a slow, guaranteed leak. Disable only for bounded work, or call gc.collect() yourself at safe points. gc.freeze() after fork is the single biggest COW-memory win for gunicorn/uWSGI fleets.

Batch Processing: Bound the Peak

When you must transform a huge dataset, process it in fixed-size batches so peak memory is a constant you choose, not a function of input size. This is the memory face of batching and pairs with backpressure.

from itertools import islice

def chunked(iterable, size):
    it = iter(iterable)
    while batch := list(islice(it, size)):   # pull exactly `size` items, then stop
        yield batch

# Peak memory = one batch (1,000 rows), regardless of total row count
for batch in chunked(stream_rows(), size=1_000):
    enriched = enrich(batch)      # bulk-transform 1k at a time
    bulk_insert(enriched)         # bulk-write 1k at a time
    # `batch` and `enriched` go out of scope here โ†’ reclaimed before the next pull
Pick your batch size like a pool size: big enough to amortize per-batch overhead (round-trips, vectorized ops), small enough that batch_size ร— concurrency fits in RAM with headroom. Batching trades a little latency for bounded, predictable memory โ€” exactly the trade you want under load.

Key Takeaways

PatternMemory win
Generators / yield / itertoolsO(1) instead of O(n) โ€” hold one item, not the list
Server-side cursors / yield_perOne row-window instead of the whole result set
stream=True + iter_contentOne chunk instead of the whole HTTP body
__slots__ / dataclass(slots=True)~60% smaller per instance ร— millions of objects
sys.intern / dedupe cacheOne shared object instead of N duplicate small values
Object / buffer poolsReuse fixed buffers โ†’ zero alloc churn + built-in backpressure
array / memoryview / NumPyPacked C buffers, zero-copy slices โ€” no per-int objects
gc.freeze() after forkKeeps COW pages shared across preforked workers
Fixed-size batchingPeak memory bounded by batch size, not input size
The mantra: make per-request peak memory bounded and small. Stream inputs, bound batches, reuse buffers, shrink hot objects โ€” and only then worry about the GC. If peak memory grows with input size, you don't have a scaling service; you have a time bomb with a load-dependent fuse.