← Back to DevBytes

Fix 'MemoryError' in Python

Understanding Python's MemoryError

MemoryError is a built-in exception in Python that is raised when an operation runs out of available memory. Unlike most Python exceptions that indicate logical errors in your code, MemoryError signals a fundamental resource constraint — your program has exhausted the RAM that the operating system allocated to it.

What Exactly Triggers a MemoryError?

Python raises MemoryError when the interpreter's memory manager cannot allocate memory for an object. This happens at the C level, deep within Python's internals. The operating system denies a memory allocation request, and Python translates that failure into an exception you can catch and handle. It's important to note that MemoryError is not the same as hitting a swap limit or experiencing general system slowdown — it specifically means a malloc() or similar allocation call returned NULL.

Here's a classic example that will trigger a MemoryError on most machines:

# This will raise MemoryError on systems with insufficient RAM
try:
    # Attempt to create a list with 10 billion integers
    huge_list = list(range(10_000_000_000))
except MemoryError as e:
    print(f"Caught MemoryError: {e}")
    print("The allocation failed — object too large for available memory.")

Why Understanding MemoryError Matters

Ignoring memory constraints leads to crashed applications, lost data, and poor user experiences. In production environments — especially cloud services with pay-per-resource pricing — unhandled memory exhaustion can trigger cascading failures across microservices. Understanding MemoryError helps you:

Common Causes of MemoryError

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

1. Loading Entire Large Files Into Memory

Reading a multi-gigabyte file all at once is the most frequent culprit. The file's contents, plus Python's object overhead, can easily exceed available RAM.

# Dangerous: loads entire file into a single string
with open('massive_logfile.json', 'r') as f:
    all_data = f.read()  # MemoryError if file is 10GB and RAM is 8GB
    process(all_data)

2. Unbounded List Growth in Loops

Appending to a list inside a long-running loop without any size check creates an ever-growing data structure that will eventually exhaust memory.

# Dangerous: infinite accumulation
results = []
while True:
    data = fetch_sensor_reading()  # Runs indefinitely
    results.append(data)  # List grows without bound → MemoryError

3. Memory-Intensive Object Creation in List Comprehensions

List comprehensions build the entire result list in memory before returning. With large input ranges, the intermediate list can be enormous.

# Dangerous: creates a list of 100 million dictionaries
n = 100_000_000
massive_list = [{'id': i, 'value': i * 2} for i in range(n)]
# Each dict has overhead; total memory may be tens of GB

4. Deep Recursion Without Tail Call Optimization

Python does not optimize tail recursion. Deep recursive calls consume stack memory, and while this raises RecursionError first in many cases, extremely deep recursion combined with large local variables can contribute to memory exhaustion.

5. Oversized In-Memory Data Structures (Matrices, Graphs, Tensors)

Scientific computing libraries like NumPy or custom graph structures can allocate enormous contiguous blocks. A single miscalculated dimension can request more memory than exists.

import numpy as np

# Dangerous: trying to allocate a 100000 x 100000 float64 matrix
# That's 10^10 elements × 8 bytes = 80 GB
try:
    huge_matrix = np.ones((100000, 100000))  # MemoryError on typical hardware
except MemoryError:
    print("Matrix too large for available RAM")

How to Fix and Prevent MemoryError

Strategy 1: Use Generators Instead of Lists

Generators produce values lazily, one at a time, without storing the entire sequence in memory. Replace list comprehensions with generator expressions by using parentheses instead of brackets.

# Before (list comprehension — memory heavy)
# results = [expensive_computation(i) for i in range(10_000_000)]

# After (generator expression — memory friendly)
results_gen = (expensive_computation(i) for i in range(10_000_000))

# Process one item at a time
for result in results_gen:
    process(result)  # Only one result exists in memory at a time

Strategy 2: Process Files in Chunks

Instead of reading an entire file, iterate over it line by line or read fixed-size chunks. This keeps memory usage constant regardless of file size.

# Fix: read file line by line
def process_large_file_line_by_line(filename):
    with open(filename, 'r') as f:
        for line in f:  # Iterator reads one line at a time
            processed = process_line(line)
            save_to_database(processed)
    # Memory usage stays roughly at one line's size

# Fix: read in fixed-size chunks for binary files
def process_binary_file_in_chunks(filename, chunk_size=8192):
    with open(filename, 'rb') as f:
        while True:
            chunk = f.read(chunk_size)
            if not chunk:
                break
            process_chunk(chunk)  # Process and release before next read

Strategy 3: Use Iterative Algorithms with In-Place Updates

For numerical computations, prefer algorithms that update existing arrays rather than creating new copies. Libraries like NumPy support in-place operations.

import numpy as np

# Before: creates new arrays at each step (memory-wasteful)
# data = np.random.randn(1_000_000)
# for _ in range(1000):
#     data = data * 1.01  # Allocates new array each iteration

# After: in-place update reuses existing memory
data = np.random.randn(1_000_000)
for _ in range(1000):
    data *= 1.01  # Modifies array in-place, no new allocation

Strategy 4: Use Appropriate Data Types

Choose compact data representations. A Python integer is 28 bytes, but a NumPy int8 is 1 byte. For large collections, the difference is enormous.

import numpy as np
import sys

# Python list of integers: ~28 bytes per int + list overhead
python_ints = list(range(1_000_000))
memory_mb_python = sys.getsizeof(python_ints) / (1024 * 1024)
print(f"Python list: approximately {memory_mb_python:.1f} MB (plus element overhead)")

# NumPy array of int8: 1 byte per element + minimal overhead
numpy_ints = np.arange(1_000_000, dtype=np.int8)
memory_mb_numpy = numpy_ints.nbytes / (1024 * 1024)
print(f"NumPy int8 array: {memory_mb_numpy:.1f} MB")

# For boolean flags, use bit arrays or np.bool_
flags = np.ones(1_000_000, dtype=np.bool_)
print(f"NumPy bool array: {flags.nbytes / (1024 * 1024):.2f} MB")

Strategy 5: Release Memory Explicitly with del and gc

In long-running applications, explicitly delete large objects and invoke the garbage collector to reclaim memory promptly. This is especially useful between phases of a pipeline.

import gc

def pipeline_phase_one():
    data = load_large_dataset()  # Allocates several GB
    result = heavy_computation(data)
    # Explicitly release the original dataset
    del data
    gc.collect()  # Force garbage collection
    return result

def pipeline_phase_two():
    # Phase one's memory has been freed
    another_dataset = load_another_dataset()
    return process(another_dataset)

Strategy 6: Catch MemoryError and Degrade Gracefully

When you cannot prevent memory exhaustion entirely, catch the exception and implement a fallback strategy — process smaller batches, reduce resolution, or notify the user.

def process_with_fallback(data_source, full_batch_size=10000):
    try:
        # Attempt full batch processing
        batch = data_source.get_batch(full_batch_size)
        return process_batch(batch)
    except MemoryError:
        print(f"MemoryError with batch size {full_batch_size}. Falling back to smaller chunks.")
        # Fall back to smaller batches
        small_batch_size = full_batch_size // 10
        results = []
        while True:
            batch = data_source.get_batch(small_batch_size)
            if not batch:
                break
            results.append(process_batch(batch))
        return combine_results(results)

Strategy 7: Leverage Disk-Backed Storage (mmap, shelve, SQLite)

When datasets exceed RAM, use memory-mapped files or databases that transparently move data between disk and memory.

import mmap
import json

# Use memory-mapped file for random access without loading everything
def access_large_file_mmap(filename):
    with open(filename, 'r+b') as f:
        # Map the file into virtual memory (OS manages paging)
        with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mapped:
            # Access portions as needed — only touched pages load into RAM
            chunk = mapped[1000:2000]  # Reads a small slice
            return chunk.decode('utf-8')
import sqlite3

# Use SQLite for structured data that doesn't fit in RAM
def query_large_dataset_with_sqlite(db_path):
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    # SQLite manages memory; only results are loaded, not entire table
    cursor.execute("SELECT * FROM massive_table WHERE category = ?", ("target",))
    for row in cursor.fetchmany(100):  # Fetch in small batches
        process_row(row)
    conn.close()

Diagnosing Memory Usage Before It Causes Errors

Using sys.getsizeof() for Quick Estimates

import sys

# Check object sizes during development
sample_list = [0] * 1000
size_bytes = sys.getsizeof(sample_list)
print(f"List overhead for 1000 elements: {size_bytes} bytes")

# Remember: getsizeof() gives shallow size only
# For nested structures, use a recursive approach
def deep_size(obj, seen=None):
    """Estimate total memory footprint of a Python object."""
    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(deep_size(k, seen) + deep_size(v, seen) for k, v in obj.items())
    elif isinstance(obj, (list, tuple, set, frozenset)):
        size += sum(deep_size(item, seen) for item in obj)
    return size

Using tracemalloc for Production Profiling

import tracemalloc

# Start tracing memory allocations
tracemalloc.start()

# Run your code
def memory_intensive_function():
    data = [b'x' * 1024 for _ in range(100000)]  # 100 MB allocation
    return data

result = memory_intensive_function()

# Take a snapshot and analyze top memory consumers
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

print("Top 5 memory allocations:")
for stat in top_stats[:5]:
    print(f"{stat.count} blocks: {stat.size / 1024:.1f} KB at {stat.traceback[0]}")

tracemalloc.stop()

Best Practices for Memory-Safe Python Programming

Complete Example: Refactoring a Memory-Heavy Script

Below is a before-and-after comparison of a script that processes a large CSV file. The original version loads everything into memory; the refactored version uses streaming and generators to maintain constant memory usage.

# ============================================
# BEFORE: Memory-heavy version (prone to MemoryError)
# ============================================
def analyze_sales_data_bad(filename):
    with open(filename, 'r') as f:
        # Loads ALL lines into a list — potentially gigabytes
        lines = f.readlines()
    
    headers = lines[0].strip().split(',')
    rows = []
    for line in lines[1:]:
        values = line.strip().split(',')
        rows.append(dict(zip(headers, values)))
    
    # Now processes everything in memory
    total_revenue = sum(float(r['revenue']) for r in rows)
    by_category = {}
    for r in rows:
        cat = r['category']
        by_category.setdefault(cat, []).append(float(r['revenue']))
    
    averages = {cat: sum(vals)/len(vals) for cat, vals in by_category.items()}
    return total_revenue, averages

# ============================================
# AFTER: Memory-safe streaming version
# ============================================
def analyze_sales_data_good(filename):
    total_revenue = 0.0
    category_totals = {}
    category_counts = {}
    
    with open(filename, 'r') as f:
        headers_line = f.readline()  # Read only the header
        headers = headers_line.strip().split(',')
        
        # Find column indices once
        revenue_idx = headers.index('revenue')
        category_idx = headers.index('category')
        
        # Stream remaining lines — only one line in memory at a time
        for line in f:
            values = line.strip().split(',')
            revenue = float(values[revenue_idx])
            category = values[category_idx]
            
            # Update running totals (constant memory)
            total_revenue += revenue
            category_totals[category] = category_totals.get(category, 0.0) + revenue
            category_counts[category] = category_counts.get(category, 0) + 1
    
    # Calculate averages from accumulated totals
    averages = {
        cat: category_totals[cat] / category_counts[cat]
        for cat in category_totals
    }
    return total_revenue, averages

# Test with a sample file
if __name__ == '__main__':
    # Create a small test file
    with open('test_sales.csv', 'w') as f:
        f.write('date,revenue,category\n')
        f.write('2024-01-01,100.50,electronics\n')
        f.write('2024-01-02,75.00,books\n')
        f.write('2024-01-03,200.00,electronics\n')
    
    total, avgs = analyze_sales_data_good('test_sales.csv')
    print(f"Total revenue: ${total:.2f}")
    print(f"Category averages: {avgs}")
    # Output: Total revenue: $375.50
    # Category averages: {'electronics': 150.25, 'books': 75.0}

Advanced: Handling MemoryError in Multi-Threaded and Async Code

MemoryError can occur in any thread or coroutine. In threaded environments, catching MemoryError in one thread allows other threads to continue operating normally. In async code, use try/except around memory-intensive coroutines to prevent a single failure from crashing the entire event loop.

import asyncio

async def memory_safe_task(task_id, data_generator):
    try:
        # Process data asynchronously in bounded chunks
        async for chunk in data_generator:
            await process_chunk_async(chunk)
    except MemoryError:
        # Log the failure but don't crash the event loop
        print(f"Task {task_id}: Memory exhausted, pausing and retrying later")
        await asyncio.sleep(60)  # Wait for other tasks to free memory
        # Could implement exponential backoff or reduce chunk size
    else:
        print(f"Task {task_id}: Completed successfully")

async def main():
    tasks = [
        memory_safe_task(1, async_data_source_1()),
        memory_safe_task(2, async_data_source_2()),
        memory_safe_task(3, async_data_source_3()),
    ]
    # All tasks run concurrently; one MemoryError doesn't kill others
    await asyncio.gather(*tasks, return_exceptions=True)

# Run the async main
# asyncio.run(main())

Conclusion

MemoryError in Python is a resource exhaustion signal, not a bug in the traditional sense. It arises when your program's memory demands exceed what the operating system can provide. The key to fixing it lies in shifting from eager, in-memory processing to lazy, streaming patterns. Generators, chunked file reading, in-place array operations, compact data types, and explicit memory management with del and gc form your toolkit. When memory requirements genuinely outstrip hardware limits, disk-backed storage via memory-mapped files or databases provides a practical escape hatch. By adopting these strategies and integrating memory profiling into your development workflow, you can build Python applications that handle large datasets reliably without ever crashing from a MemoryError.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles