Understanding Python's MemoryError
A MemoryError is raised when Python's memory manager cannot allocate enough memory for an operation. Unlike a typical exception that you can catch and gracefully handle, a MemoryError often indicates that your process has exhausted available RAM — and sometimes even swap space — leaving the interpreter in a precarious state.
The error message is deceptively simple:
Traceback (most recent call last):
File "script.py", line 42, in <module>
result = [i ** 2 for i in range(10**9)]
MemoryError
But the root cause can range from genuinely insufficient hardware to subtle code patterns that accidentally materialize enormous data structures in memory. This guide walks you through every facet of diagnosing and fixing MemoryError in Python.
What Exactly Triggers a MemoryError?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Python manages memory through a private heap. When you create objects — integers, lists, dictionaries, class instances — the interpreter requests memory from the operating system. If the OS denies that request (because physical RAM and swap are exhausted, or because a per-process limit has been hit), Python raises MemoryError.
Importantly, MemoryError is not the same as a slow program or high CPU usage. It specifically means an allocation request failed. You may still have gigabytes of free RAM on the machine, but a single allocation for a multi-gigabyte contiguous block can fail if the OS cannot satisfy it.
Key Distinction: MemoryError vs. Other Resource Errors
- MemoryError — Allocation failure (RAM/swap exhausted or fragmented)
- OverflowError — Numeric value exceeds maximum limit (e.g.,
math.exp(100000)) - RecursionError — Call stack depth exceeded (fixable with
sys.setrecursionlimit) - SystemExit / KeyboardInterrupt — Process termination, not memory exhaustion
Knowing this distinction prevents you from applying the wrong fix. If you see MemoryError, the problem is always about memory allocation.
Why Fixing MemoryError Matters
Ignoring or working around MemoryError without understanding its cause leads to several problems:
- Application crashes in production — A web worker that dies with MemoryError takes down user requests with it
- Data corruption — If the error occurs mid-write, files or database transactions may be left in an inconsistent state
- Cascading failures — A memory-starved process can cause the OS to kill other processes (OOM killer on Linux)
- Unrepeatable results — Heisenbugs that appear only under specific memory pressure are notoriously hard to debug
Properly addressing MemoryError makes your code robust, predictable, and ready for production environments where resources are finite.
Common Causes (and How to Spot Them)
1. Loading Entire Datasets Into Memory
The most frequent culprit. Reading a large CSV, JSON file, or database query result into a single list or DataFrame can easily exhaust RAM.
# Dangerous: loads entire file into memory
with open('massive_log.json') as f:
data = json.load(f) # MemoryError if file is multi-gigabyte
# Also dangerous: materializing a huge list
rows = [row for row in csv.reader(open('big.csv'))] # All rows in RAM
2. Unbounded Accumulation in Loops
Lists, dicts, or sets that grow without limit inside long-running loops will eventually consume all available memory.
# Memory leak by accumulation
cache = {}
for user_id in infinitely_streaming_ids():
cache[user_id] = expensive_computation(user_id) # Dict grows forever
3. Exploding Cartesian Products
Cross joins, permutations, or combinatorial generators materialized as lists can produce exponentially large data structures.
from itertools import product
# Fine as an iterator — but materializing it is deadly
combinations = list(product(range(1000), range(1000), range(100))) # 100 million tuples
4. Large Object Graphs with Circular References
Objects that reference each other cyclically can prevent the garbage collector from freeing memory promptly, causing retained memory to grow until allocation fails. This is especially dangerous when combined with __del__ methods that the GC cannot safely collect.
class Node:
def __init__(self, name):
self.name = name
self.parent = None
self.children = []
def add_child(self, child):
self.children.append(child)
child.parent = self # Circular reference created
5. Inefficient Data Structures
Using Python's default types without considering their memory footprint can waste enormous amounts of RAM. A Python int is 28 bytes (on 64-bit CPython), not 4. A list of 1 million integers uses ~8 MB just for pointers, plus 28 MB for the integer objects themselves.
6. Forking Processes (Copy-on-Write Blowup)
On Linux, multiprocessing with fork start method shares memory pages via copy-on-write. But when the child process modifies reference counts (which happens on almost any Python operation), pages are duplicated. A parent with 4 GB of state can cause each child to gradually consume 4 GB of its own.
Diagnosing MemoryError: Tools and Techniques
Before applying fixes, you must understand where memory is going. Use these approaches in order:
1. Check System and Process Limits
# Linux: check available memory
$ free -h
$ ulimit -v # virtual memory limit (if set)
$ ulimit -m # max resident set size (if set)
# macOS: check process limits
$ launchctl limit
If ulimit -v returns a small value (like 1 GB), your process may be hitting an artificial ceiling. Raise it with ulimit -v unlimited (or consult your container orchestrator if running in Docker/Kubernetes).
2. Profile Memory Usage with tracemalloc
Python's built-in tracemalloc module tracks allocations by line number. It's lightweight enough to run in production for short periods.
import tracemalloc
tracemalloc.start()
# ... run your code that triggers MemoryError ...
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
print("Top 10 memory allocations:")
for stat in top_stats[:10]:
print(stat)
# Shows file, line, and memory usage
# e.g.: script.py:42: size=856 MiB (+782 MiB), count=1000000, average=896 B
3. Use memory_profiler for Line-by-Line Analysis
The memory_profiler third-party package decorates functions to report memory usage per line.
# pip install memory-profiler
from memory_profiler import profile
@profile
def process_records(filepath):
with open(filepath) as f:
data = f.readlines() # Shows memory increment
parsed = [parse(line) for line in data] # Shows another jump
return parsed
4. Inspect Object Sizes with sys.getsizeof
For a quick sanity check on individual objects:
import sys
data = [1] * 1_000_000
print(f"List of 1M ints: {sys.getsizeof(data) / 1e6:.2f} MB") # ~8 MB for list container
# But to get the full recursive size, use a helper:
def total_size(obj, seen=None):
if seen is None:
seen = set()
obj_id = id(obj)
if obj_id in seen:
return 0
seen.add(obj_id)
size = sys.getsizeof(obj)
if isinstance(obj, dict):
size += sum(total_size(k, seen) + total_size(v, seen) for k, v in obj.items())
elif isinstance(obj, (list, tuple, set, frozenset)):
size += sum(total_size(item, seen) for item in obj)
return size
print(f"Total size: {total_size(data) / 1e6:.2f} MB") # ~36 MB (28 bytes per int + 8 bytes per pointer)
5. Monitor Real-Time with guppy3 or pympler
For long-running processes, take periodic heap snapshots to detect growth trends.
# pip install pympler
from pympler import tracker
tr = tracker.SummaryTracker()
# ... first snapshot ...
tr.print_diff() # Shows what changed since last snapshot
Fix #1: Use Iterators and Generators Instead of Lists
The single most impactful fix: replace materialized sequences with lazy iterators. Generators compute values on demand and hold only one item at a time in memory.
# Before: list comprehension — all results in memory
squares = [i ** 2 for i in range(10**8)] # MemoryError
# After: generator expression — yields one value at a time
squares_gen = (i ** 2 for i in range(10**8)) # No memory allocated yet
for sq in squares_gen:
if sq > 1000:
break # Only computes what's needed, minimal RAM
Apply this pattern to file reading as well:
# Before: read entire file
with open('huge.log') as f:
lines = f.readlines() # All lines in memory
for line in lines:
process(line)
# After: iterate lazily
with open('huge.log') as f:
for line in f: # f is an iterator; one line at a time
process(line)
For JSON, use ijson for streaming parsing:
import ijson
with open('massive.json', 'rb') as f:
# Streaming: yields objects one at a time
for record in ijson.items(f, 'records.item'):
process(record)
Fix #2: Process Data in Chunks
When you must work with large collections, process them in fixed-size chunks. This bounds memory usage to a predictable ceiling.
import pandas as pd
# Read a large CSV in chunks
chunk_size = 100_000
results = []
for chunk in pd.read_csv('big_table.csv', chunksize=chunk_size):
# chunk is a DataFrame with at most 100k rows
filtered = chunk[chunk['value'] > 0]
agg = filtered.groupby('category').sum()
results.append(agg) # Still accumulating, but much smaller
# Combine chunk-level aggregates
final = pd.concat(results).groupby('category').sum()
The chunking pattern works universally:
def chunked_iterable(iterable, size):
"""Yield fixed-size chunks from an iterable."""
chunk = []
for item in iterable:
chunk.append(item)
if len(chunk) >= size:
yield chunk
chunk = []
if chunk:
yield chunk
# Process 1 million records in chunks of 10,000
for batch in chunked_iterable(range(1_000_000), 10_000):
processed = [expensive_op(x) for x in batch]
save_to_db(processed)
# Each batch uses bounded memory, then gets garbage collected
Fix #3: Use Memory-Efficient Data Structures
Use array.array for Homogeneous Numeric Data
Python's array module stores typed numeric values compactly (similar to C arrays).
import array
# Before: list of 10 million integers (~280 MB for ints + ~80 MB for list pointers)
nums_list = [i for i in range(10_000_000)]
# After: array of type 'i' (signed int, 4 bytes each) — only ~40 MB
nums_array = array.array('i', range(10_000_000))
Use NumPy Arrays for Numerical Work
NumPy arrays store data in contiguous C-style buffers with minimal overhead.
import numpy as np
# 10 million float64 values
arr = np.arange(10_000_000, dtype=np.float64)
print(f"NumPy array size: {arr.nbytes / 1e6:.2f} MB") # 80 MB
# Equivalent Python list would be ~360+ MB
Use __slots__ for Class Instances
By default, Python class instances store attributes in a __dict__ which is a hash map with overhead. Using __slots__ stores attributes in a compact array, saving roughly 40-60% memory per instance.
# Before: each instance has __dict__ overhead
class DataPoint:
def __init__(self, x, y, z, label):
self.x = x
self.y = y
self.z = z
self.label = label
# After: __slots__ eliminates __dict__ and __weakref__
class DataPointSlotted:
__slots__ = ('x', 'y', 'z', 'label')
def __init__(self, x, y, z, label):
self.x = x
self.y = y
self.z = z
self.label = label
# For 1 million instances, this saves hundreds of MB
Use bytes/bytearray Instead of Strings for Binary Data
If you're storing large blobs of text that don't need rich string operations, keep them as bytes.
Fix #4: Release References Explicitly
Python's garbage collector usually does the right thing, but in tight memory situations you can help by explicitly deleting references and calling gc.collect().
import gc
def heavy_computation():
large_intermediate = [expensive_calc(i) for i in range(10**7)]
result = summarize(large_intermediate)
# Explicitly release the large intermediate list
del large_intermediate
gc.collect() # Force collection before proceeding
return result
For long-running loops, periodically clear accumulators:
accumulator = []
for i, item in enumerate(streaming_source()):
accumulator.append(process(item))
if i % 100_000 == 0:
# Flush accumulator to disk and reset
flush_to_disk(accumulator)
accumulator.clear() # or: accumulator = []
gc.collect()
Fix #5: Break Circular References
Use weakref to avoid reference cycles that prevent garbage collection:
import weakref
class Parent:
def __init__(self, name):
self.name = name
self.children = [] # Strong references to children
class Child:
def __init__(self, name, parent):
self.name = name
# Use weak reference to avoid circular dependency
self.parent = weakref.ref(parent) # Does not increase reference count
@property
def parent_name(self):
p = self.parent()
return p.name if p else None
For objects with __del__ methods, the GC places them in a special list (gc.garbage) if they're part of a cycle. Avoid __del__ when possible, or use weakref to break cycles.
Fix #6: Use Disk-Backed Storage (Out-of-Core Processing)
When data doesn't fit in RAM, keep it on disk and access it incrementally.
Use SQLite for Structured Data
import sqlite3
# Instead of keeping everything in a dict
conn = sqlite3.connect('file:cache.db?mode=memory&cache=shared', uri=True)
conn.execute('CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value BLOB)')
def get_or_compute(key):
cursor = conn.execute('SELECT value FROM cache WHERE key = ?', (key,))
row = cursor.fetchone()
if row:
return row[0]
value = expensive_computation(key)
conn.execute('INSERT OR REPLACE INTO cache VALUES (?, ?)', (key, value))
conn.commit()
return value
Use Memory-Mapped Files
The mmap module lets you access a file as if it were a byte array, with the OS handling paging:
import mmap
with open('huge_data.bin', 'r+b') as f:
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_WRITE) as m:
# m behaves like a bytearray, but OS pages data in/out on demand
# You can access m[offset] without loading the whole file
for offset in range(0, len(m), record_size):
process(m[offset:offset+record_size])
Use Dask or Vaex for Out-of-Core DataFrames
import dask.dataframe as dd
# Dask splits the DataFrame into chunks, processing them lazily
df = dd.read_csv('s3://bucket/massive/*.csv')
result = df[df.value > 0].groupby('category').sum().compute()
# Only materializes final (hopefully smaller) result in memory
Fix #7: Optimize Multiprocessing Memory Sharing
When using multiprocessing, the fork start method can cause memory duplication. Switch to spawn (available in Python 3.4+) or use shared memory explicitly.
import multiprocessing as mp
# Option A: Use 'spawn' start method (no copy-on-write)
if __name__ == '__main__':
mp.set_start_method('spawn')
with mp.Pool(processes=4) as pool:
results = pool.map(worker, tasks)
# Option B: Use shared memory for large read-only data
# (Python 3.8+ multiprocessing.shared_memory)
from multiprocessing.shared_memory import SharedMemory
import numpy as np
# Create shared NumPy array
a = np.zeros(1_000_000, dtype=np.float64)
shm = SharedMemory(create=True, size=a.nbytes)
shared_array = np.ndarray(a.shape, dtype=a.dtype, buffer=shm.buf)
np.copyto(shared_array, a) # Copy data once into shared memory
# Workers can now access shared_array without duplicating it
Fix #8: Switch to 64-bit Python and Increase Virtual Memory
If you're running 32-bit Python, the process is limited to ~2-4 GB of addressable memory regardless of physical RAM. Verify with:
import sys
print(sys.maxsize) # If ~2**31-1, you're on 32-bit
Switch to 64-bit Python to access the full machine address space. Additionally, ensure swap space is configured:
# Linux: create a swap file
$ sudo fallocate -l 8G /swapfile
$ sudo chmod 600 /swapfile
$ sudo mkswap /swapfile
$ sudo swapon /swapfile
# Verify
$ free -h
Note: Swap allows allocations to succeed but may make your program extremely slow if it causes thrashing. It's a safety net, not a performance solution.
Fix #9: Use Cloud-Scale Patterns (MapReduce, Streaming)
For truly massive datasets, redesign your architecture around streaming and distributed processing:
- Apache Kafka + Faust — Process events as they arrive, never accumulate
- Apache Beam / Google Dataflow — Define a pipeline that processes data in windows
- Ray / Dask Distributed — Spread computation across a cluster
- Serverless functions — Each invocation handles one chunk; no persistent memory
# Conceptual streaming pattern
def streaming_pipeline(source_stream):
for record in source_stream:
enriched = enrich(record)
validated = validate(enriched)
yield validated # Downstream consumer processes and frees memory
Best Practices for Preventing MemoryError
1. Preallocate and Reuse Buffers
Avoid repeated allocation of large temporary objects. Preallocate once and reuse:
# Preallocate a buffer for processing
BUFFER_SIZE = 1_000_000
buffer = bytearray(BUFFER_SIZE)
def process_data(source):
while True:
n = source.readinto(buffer) # Reuses the same buffer
if n == 0:
break
handle(buffer[:n])
2. Set Hard Memory Limits
Use resource module (Unix) to enforce a ceiling and fail fast:
import resource
# Set max heap to 4 GB (in bytes)
resource.setrlimit(resource.RLIMIT_DATA, (4 * 1024**3, 4 * 1024**3))
3. Monitor and Alert on Memory Growth
In production, expose memory metrics and set alerts:
import os
import psutil
def memory_usage_gb():
process = psutil.Process(os.getpid())
return process.memory_info().rss / 1e9 # Resident Set Size in GB
# Log or expose as Prometheus metric
print(f"Current RSS: {memory_usage_gb():.3f} GB")
4. Use Context Managers for Resource Cleanup
Always wrap resource-intensive operations in context managers:
from contextlib import contextmanager
@contextmanager
def temporary_buffer(size):
buf = bytearray(size)
try:
yield buf
finally:
del buf # Guaranteed cleanup
5. Test Under Memory Constraints
Simulate low-memory conditions in CI to catch MemoryError before deployment:
# Run your test suite with limited memory
$ docker run --memory=512m --memory-swap=512m my-python-app pytest
6. Favor Functional Pipeline Patterns
Chain operations without materializing intermediate collections:
# Bad: materializes three large lists
step1 = [op1(x) for x in data]
step2 = [op2(x) for x in step1]
step3 = [op3(x) for x in step2]
# Good: generator pipeline
result = (op3(op2(op1(x))) for x in data)
for item in result:
consume(item)
7. Profile Before Optimizing
Don't guess where memory goes — always profile first with tracemalloc or memory_profiler. Premature memory optimization can complicate code without solving the real bottleneck.
Emergency Fixes When MemoryError Strikes in Production
If you encounter MemoryError in a running system and need immediate mitigation:
- Restart the process — The quickest way to reclaim leaked memory
- Reduce worker count — Fewer concurrent workers means less aggregate memory pressure
- Enable swap temporarily — Buys time while you deploy a proper fix
- Drop caches — On Linux:
sync; echo 3 > /proc/sys/vm/drop_caches(frees OS cache, not process memory) - Roll back recent deployment — If the error started after a code change
Conclusion
MemoryError in Python is a signal that your code is attempting to allocate more memory than the system can provide — whether due to genuinely large data, inefficient data structures, or accidental materialization of lazy sequences. The fix is rarely a single line change; it requires understanding your data flow, choosing appropriate data structures (array, NumPy, __slots__), embracing iterators over lists, processing data in chunks, and sometimes redesigning your architecture around streaming or distributed patterns. Profile relentlessly with tracemalloc and memory_profiler before applying any fix, test under memory constraints, and set up monitoring so you catch memory growth before it becomes an outage. With the techniques in this guide — from generator expressions to shared memory multiprocessing — you now have a complete toolkit to diagnose, fix, and prevent MemoryError in any Python application.