I remember the first time a production job died with MemoryError. It wasn’t a huge dataset, and the code looked innocent. The real issue was a loop that quietly kept extra copies of data around. That moment taught me a hard truth: memory bugs don’t always come from "big data"; they come from small mistakes that compound over time. If you write Python for real systems (ETL, data APIs, ML pipelines, or even daily automation), you will meet memory pressure sooner or later.
In this post, I’ll show you how I reason about memory errors in Python, what tends to trigger them (especially in loops), and how I fix them in practice. I’ll also share tactics I rely on in 2026: newer Python features, better tooling, and AI-assisted workflows that catch memory bloat early. You’ll leave with a mental model, concrete patterns, and runnable code you can drop into your own projects.
What a MemoryError really means (and why it’s not random)
A MemoryError in Python means the interpreter asked the operating system for more memory and didn’t get it. That can happen for two broad reasons: you truly ran out of RAM, or you asked for a chunk that couldn’t be allocated because of fragmentation and process limits. Either way, the symptom is the same: allocation failed.
I like a simple analogy: imagine a warehouse with shelves. Each list, dict, or class instance is a box that needs a shelf. A memory error happens when you ask the warehouse to place a box but there’s no space left, or the empty space is too small for the box you want. The tricky part is that the warehouse is shared with other processes, and Python’s view of "free space" can be stale or fragmented.
Key takeaways I keep in mind:
MemoryErrorhappens at allocation time, not at the moment your loop "feels big".- You can hit it even when total RAM looks available, especially in containers or under cgroup limits.
- If you’re on a 32-bit process (rare now, but still around in embedded or legacy systems), the address space limit can hit you before RAM does.
Memory error vs memory leak vs fragmentation
People use these terms interchangeably, but they’re different problems that show up in different ways.
- Memory error: an allocation fails right now. The process asked the OS for more memory and got denied.
- Memory leak: memory use keeps growing and never levels off, usually because references are kept around unintentionally.
- Fragmentation: there is free memory, but not in a single contiguous chunk big enough for what you want.
If memory usage climbs slowly and then spikes, that is often a leak. If memory usage looks flat but you still fail on large allocations (like building a huge list or dataframe), fragmentation or address space limits may be the actual cause.
How Python holds memory (fast, but not always lean)
Python favors speed and developer ergonomics over raw memory efficiency. That’s often a good trade, but it matters when you run loops for hours or build large structures.
A few realities that shape memory usage:
- Every object has overhead: references, headers, type metadata, and alignment padding.
- Lists and dicts over-allocate to keep appends fast, which means extra capacity is reserved memory.
- The garbage collector handles cyclic references, but it won’t free everything immediately.
- Reference cycles in long-running processes can accumulate if you keep global caches or module-level containers.
If you want a rough intuition: a list of one million small integers costs far more than the raw integer bytes. In many workflows, that overhead is the actual reason you hit MemoryError, not the data itself.
A quick (useful) mental model of Python’s allocator
Python uses a small-object allocator (often called pymalloc) for objects below a certain size. It grabs memory from the OS in larger chunks, then hands out small blocks quickly to Python objects. That’s fast, but it can cause fragmentation inside the process. Even when you delete objects, the freed blocks may stay in Python’s arena pools instead of being returned to the OS. This is why memory can look "stuck" at a high watermark.
That does not mean garbage collection is broken. It means memory can be freed inside Python while the process RSS (resident set size) stays high. If you keep allocating and freeing lots of small objects in a loop, the internal allocator can get fragmented and wasteful.
The three loop patterns that trigger memory errors
Most memory failures I debug come from just a few patterns. I’ll walk through each, show how it breaks, then show a safer variant.
1) Infinite loops that keep allocating
When a loop doesn’t terminate, it can keep creating objects forever. The loop body might look tiny, but if it allocates just a little memory per iteration, that turns into a leak.
# badinfiniteloop.py
records = []
while True:
# Imagine this reads one more event each time
event = {"source": "sensor-42", "value": 7.2}
records.append(event) # grows without bound
A safer approach is to cap the growth or write to disk incrementally:
# capped_buffer.py
from collections import deque
Keep only the last 10,000 events
buffer = deque(maxlen=10_000)
while True:
event = {"source": "sensor-42", "value": 7.2}
buffer.append(event)
2) Unintended memory allocation in loops
This is the most common issue I see. A loop builds or duplicates a structure each iteration. It looks fine in small tests, then collapses in production.
# bad_growth.py
log_lines = []
for path in log_paths:
with open(path, "r", encoding="utf-8") as f:
log_lines += f.readlines() # builds a giant list
The fix is to process lazily and keep only what you need:
# stream_processing.py
import gzip
error_count = 0
for path in log_paths:
with gzip.open(path, "rt", encoding="utf-8") as f:
for line in f:
if "ERROR" in line:
error_count += 1
print(error_count)
Here, memory use stays almost constant, even for massive logs.
3) Loops without a base case (recursive calls)
Recursion is elegant, but it consumes stack frames. If you forget a base case, or the base case is never reached, stack growth and object retention eventually blow up memory.
# bad_recursion.py
def walk_tree(node):
# Missing base case
return walk_tree(node)
walk_tree("root")
Safer recursion: add a base case and free references when possible.
# safe_recursion.py
def sum_nodes(node):
if node is None: # base case
return 0
total = node.value
for child in node.children:
total += sum_nodes(child)
return total
If your tree can be deep, prefer an explicit stack:
# iterative_tree.py
from collections import deque
def sumnodesiterative(root):
if root is None:
return 0
total = 0
stack = deque([root])
while stack:
node = stack.pop()
total += node.value
for child in node.children:
stack.append(child)
return total
Extra loop patterns I see in real code
Three patterns are common, but there are a few others that show up a lot in production.
4) Repeated concatenation of large lists
This looks harmless, but it duplicates the list each time.
# bad_concat.py
items = []
for chunk in chunks:
items = items + chunk # makes a new list each loop
Fix it by using extend, or stream the data instead.
# good_concat.py
items = []
for chunk in chunks:
items.extend(chunk) # in-place
5) Building huge strings in a loop
Strings are immutable. Concatenating in a loop makes a new string each time.
# badstringbuild.py
text = ""
for line in lines:
text += line
Use list + join or write to disk incrementally.
# goodstringbuild.py
parts = []
for line in lines:
parts.append(line)
text = "".join(parts)
If the text is massive, write to a file or stream to an output buffer so it never all lives in memory.
6) Pandas concat in a loop
This is a classic trap for data pipelines.
# badpandasconcat.py
import pandas as pd
df = pd.DataFrame()
for path in paths:
df = pd.concat([df, pd.read_csv(path)])
Each concat creates a new dataframe. If you do this in a loop, memory grows fast. Fix it by collecting smaller chunks or using a list and a single concat.
# goodpandasconcat.py
import pandas as pd
frames = []
for path in paths:
frames.append(pd.read_csv(path))
df = pd.concat(frames, ignore_index=True)
If the full dataframe is too big, use chunking or write out incremental results instead.
Diagnosing memory pressure: a hands-on workflow
When a memory error happens, I want answers fast. I follow a simple workflow that works in local dev, containers, and production incident response.
1) Measure peak memory and hotspots
Python’s tracemalloc is my first stop. It tracks allocations by line, which is exactly what I need.
# tracemalloc_demo.py
import tracemalloc
tracemalloc.start()
Your workload
largelist = ["order" + str(i) for i in range(2000_000)]
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:5]:
print(stat)
This tells you which lines are responsible for most allocations.
2) Compare snapshots before and after a loop
I often do a before/after comparison to find the real leak.
# tracemalloc_compare.py
import tracemalloc
def run_job():
data = []
for i in range(1000000):
data.append({"i": i, "v": i * 2})
return data
tracemalloc.start()
start = tracemalloc.take_snapshot()
run_job()
end = tracemalloc.take_snapshot()
for stat in end.compare_to(start, "lineno")[:5]:
print(stat)
3) Find object growth with live inspection
When memory grows slowly, I use objgraph or pympler to see what keeps expanding. In 2026, I often wire this into a debugging mode that I can toggle at runtime.
# pympler_summary.py
from pympler import summary, muppy
allobjects = muppy.getobjects()
print(summary.summarize(all_objects)[:10])
4) Treat the process as a budget
If I’m inside a container, I read cgroup limits to see how much memory the process can truly use. Even on a large host, the container might only have 1–2 GB.
# memory_budget.py
import os
cgroup v2 path on many Linux systems
limit_path = "/sys/fs/cgroup/memory.max"
if os.path.exists(limit_path):
with open(limit_path, "r") as f:
print("cgroup memory limit:", f.read().strip())
That number often explains the "mystery" errors.
5) Measure with a memory profiler
I still use memory_profiler for line-by-line insights, especially when refactoring. It’s slower, but clear.
# profile_memory.py
from memory_profiler import profile
@profile
def load_data():
rows = [f"row-{i}" for i in range(1000000)]
return rows
load_data()
6) Track RSS during a long run
Sometimes I want a live view of process memory, not just allocation snapshots. A simple loop with psutil is enough.
# rss_monitor.py
import os
import time
import psutil
process = psutil.Process(os.getpid())
for _ in range(5):
print("RSS MB:", process.memory_info().rss / (1024 * 1024))
time.sleep(1)
Patterns I trust to prevent memory errors
Here are patterns I rely on when I want loops to stay steady in memory, even with large datasets.
Generators instead of lists
Generators keep only one item in memory at a time. When I’m processing files, APIs, or data pipelines, I reach for generators first.
# generator_pipeline.py
def read_orders(path):
with open(path, "r", encoding="utf-8") as f:
for line in f:
yield line.strip()
for order in read_orders("orders.csv"):
# Process each line without storing the whole file
if order.startswith("PAID"):
print(order)
Chunking for predictable memory use
When you must hold data in memory, say batching for a database insert, chunking makes the memory cost stable.
# chunking.py
def chunks(iterable, size):
buffer = []
for item in iterable:
buffer.append(item)
if len(buffer) == size:
yield buffer
buffer = []
if buffer:
yield buffer
for batch in chunks(range(1000000), 10_000):
# Insert or send in batches
pass
Use arrays or typed containers when possible
If you’re storing numeric data, Python lists are expensive. Arrays, array module, or numpy can cut memory use massively.
# typed_arrays.py
from array import array
scores = array("f") # 32-bit floats
for i in range(1000000):
scores.append(i * 0.1)
Memory-mapped files for huge datasets
If a dataset is too big for RAM, I prefer memory mapping. It gives you file-backed access without loading everything at once.
# mmap_read.py
import mmap
with open("events.log", "r", encoding="utf-8") as f:
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
for line in iter(mm.readline, b""):
if b"CRITICAL" in line:
print(line.decode("utf-8").strip())
Streaming APIs and iterators
If you use external services, request pagination and process each page as it arrives. Don’t build a mega list in memory.
# api_paging.py
import requests
page = 1
while True:
resp = requests.get("https://api.example.com/orders", params={"page": page})
data = resp.json()
if not data["items"]:
break
for item in data["items"]:
# Process immediately
pass
page += 1
Bounded queues and backpressure
In pipelines, I use bounded queues to prevent a fast producer from overwhelming memory.
# bounded_queue.py
from queue import Queue
from threading import Thread
q = Queue(maxsize=1000)
def producer():
for i in range(1000000):
q.put(i) # blocks when full
q.put(None)
def consumer():
while True:
item = q.get()
if item is None:
break
# process item
Thread(target=producer).start()
Thread(target=consumer).start()
A quick decision table: traditional vs modern approach
When I’m choosing a strategy, I use a mental table like this:
Traditional approach
When I choose it
—
—
readlines() then loop
Any file over ~50MB
Python list of dicts
pandas with chunks or Arrow tables Analytics pipelines
Build new list each step
ETL and log processing
Python list of floats
numpy array or array module ML features and stats
Collect all pages
External API syncs## Error handling that keeps systems alive
Even with good patterns, memory pressure happens: bad input, unexpected spikes, or other processes on the same host. I always wrap high-risk sections with try/except MemoryError and make a recovery plan.
A graceful failure path
If memory allocation fails, I clean up references, log the state, and fall back to a smaller batch size.
# memory_resilience.py
import logging
logger = logging.getLogger("orders")
batchsize = 200000
while True:
try:
batch = list(range(batch_size))
# Process batch
break
except MemoryError:
logger.error("MemoryError at batchsize=%s", batchsize)
batch = None # release reference
batchsize = max(10000, batch_size // 2)
if batchsize == 10000:
# Fallback to a safer path
for item in range(1000000):
pass
break
Cleaning up large temporary structures
If you need to build a large structure once, then discard it, drop references and allow the garbage collector to reclaim it.
# cleanup.py
import gc
cache = {"customer-" + str(i): i for i in range(2000000)}
... use cache ...
cache = None
Encourage cleanup for long-running processes
gc.collect()
Avoiding hidden retention
It’s common to keep references by accident, especially in closures, global caches, and logging.
# hidden_retention.py
error_samples = []
def track_error(err):
# Keep at most 100 samples
if len(error_samples) >= 100:
error_samples.pop(0)
error_samples.append(err)
I prefer bounded caches and fixed-size buffers. They stop memory growth even in long runtimes.
Real-world scenarios and edge cases I see in practice
I’ll walk through three real situations where memory errors bite teams. Each has a simple fix once you know the pattern.
Scenario 1: Data science notebook grows until the kernel dies
Analysts often re-run cells without restarting the kernel. Old objects remain, and memory grows silently. My fix is simple:
- Use
%reset -for restart kernels regularly - Track memory with
tracemallocsnapshots - Avoid copying dataframes for every step
If you do need copies, use df.copy(deep=False) when appropriate, and write intermediate artifacts to disk.
Scenario 2: Web service with a growing cache
A service caches responses for speed, but never evicts items. It works in tests, then collapses after a day in production. I replace unbounded dicts with LRU caches.
# lru_cache.py
from functools import lru_cache
@lrucache(maxsize=10000)
def getcustomerprofile(customer_id):
# fetch from database
return {"id": customer_id}
When I need custom logic, I use cachetools with TTL and size limits.
Scenario 3: ETL job that loads "only one file"
Teams often process a single large file and store everything in memory "just once." It’s rarely necessary. Instead:
- Use streaming reads
- Parse line by line
- Use chunked writes to the target
For CSV, I reach for pandas.read_csv(..., chunksize=...) or csv with manual iteration.
Pandas and dataframes: the sharp edges
Pandas is powerful, but it can be memory-hungry. I see the same issues repeatedly:
- Chained operations that materialize intermediate dataframes
applyfunctions that build large Python objects in each row- Merging large frames without filtering first
Chunked CSV processing with aggregation
Here is a pattern I rely on when a full dataframe does not fit.
# pandaschunkagg.py
import pandas as pd
chunkiter = pd.readcsv("events.csv", chunksize=200_000)
counts = {}
for chunk in chunk_iter:
for value, group in chunk.groupby("event_type"):
counts[value] = counts.get(value, 0) + len(group)
print(counts)
This keeps memory flat because each chunk is processed and discarded.
Avoiding full copies with views
Many pandas operations return views or copies depending on context. When possible, I slice and select columns before expensive operations to reduce the working set.
# pandasselectfirst.py
import pandas as pd
df = pd.read_parquet("big.parquet")
small = df[["userid", "eventtype"]] # reduce width
result = small[small["event_type"] == "click"]
The smaller the columns, the cheaper the processing.
Multiprocessing and memory: the hidden multiplier
If you use multiprocessing, memory use can explode because each worker may hold its own copy of data. This is especially painful in Linux when copy-on-write gets broken by a mutation.
A common pitfall
You load a giant dataset in the parent, then spin up workers that mutate it.
# bad_multiprocessing.py
from multiprocessing import Pool
DATA = [i for i in range(5000000)]
def worker(idx):
DATA[idx] = DATA[idx] + 1 # mutation breaks copy-on-write
return DATA[idx]
with Pool(4) as p:
p.map(worker, range(1000))
Each worker now owns its own copy. A safer pattern is to pass smaller chunks or use shared memory for numeric data.
# good_multiprocessing.py
from multiprocessing import Pool
def worker(chunk):
return [x + 1 for x in chunk]
chunks = [list(range(i, i + 1000)) for i in range(0, 100_000, 1000)]
with Pool(4) as p:
p.map(worker, chunks)
Common pitfalls that lead to MemoryError
These are the mistakes I see most in code reviews:
1) Unbounded caches. A dict grows forever because no eviction strategy exists.
2) Storing raw API responses when you only need a few fields.
3) Repeated list(...) conversions just to iterate once.
4) Building huge intermediate lists during data transformation.
5) Overusing deepcopy on large structures.
6) Logging full payloads into in-memory lists for later inspection.
The fix is usually the same: store less, stream more, and keep boundaries on collections.
Performance considerations and tradeoffs
Memory-sane patterns sometimes cost CPU or throughput. I plan for that by using ranges and targets, not exact numbers. For example:
- Streaming log processing often keeps memory under 200 to 400 MB for multi-GB inputs, but can add 10 to 20 percent CPU time due to IO.
- Chunked batches usually raise per-item latency by 1 to 3 ms but prevent spikes that crash the process.
- Memory mapping adds a small setup cost (typically 5 to 15 ms) but keeps peak memory flat.
When I build a pipeline, I choose stability over raw speed unless I’m in a controlled environment with explicit memory budgets.
A practical memory checklist I keep nearby
When I hit a MemoryError, I walk through this checklist:
1) What was the last allocation that failed? Use tracemalloc to identify the line.
2) Is memory growth linear with time or with input size? If time, suspect leaks.
3) Is the process inside a container with low memory limits?
4) Are any loops appending to a list or dict without bounds?
5) Are we building large strings or dataframes repeatedly?
6) Can we stream or chunk the workload instead?
This keeps me from guessing and pushes me toward data-driven fixes.
Modern workflows that catch issues earlier
In 2026, I rely on a few tools and practices to prevent memory errors from reaching production:
- Static analysis with AI-assisted code review: I ask my assistant to scan loops for growth patterns and hidden retention. It catches issues like list accumulation inside loops that humans miss.
- Automated memory tests: I add a stress test that runs a task with 3 to 5 times the typical dataset. If memory rises without leveling off, I flag it.
- Profiling in CI: I capture memory metrics in CI for critical jobs. That gives me a baseline and alerts on regressions.
- Container budgets: I set explicit memory limits for services and jobs. That forces my code to behave in realistic budgets instead of relying on unlimited dev machines.
- Runtime dashboards: I log RSS and object counts at intervals and alert if they trend upward over long runtimes.
I treat memory the same way I treat latency. If it is not measured, it will drift.
Monitoring in production without a heavy footprint
I like lightweight monitoring that is safe to run even in production. One pattern is to sample memory once per minute and log a short line.
# memory_logger.py
import os
import time
import psutil
process = psutil.Process(os.getpid())
while True:
rssmb = process.memoryinfo().rss / (1024 * 1024)
print(f"memoryrssmb={rss_mb:.1f}")
time.sleep(60)
This is simple, but it gives you a time series you can alert on.
When to tune the garbage collector
I rarely tune GC unless I’m building a long-running service with lots of short-lived objects and periodic spikes. If you keep seeing memory climb in cycles, you can experiment with thresholds, but do it deliberately. I recommend:
- First find which objects are growing. GC tuning is not a fix for leaks.
- If memory spikes during specific phases, trigger
gc.collect()after those phases. - Avoid over-tuning. It can degrade performance and hide deeper problems.
Alternative approaches: choose the right tool for the job
Sometimes the best way to handle memory error is to avoid the path entirely.
- Use a database or key-value store for large collections instead of Python lists.
- Use columnar formats like Parquet or Arrow for analytics workloads.
- Use streaming frameworks (like Kafka consumers) that impose backpressure.
- Offload heavy data transformation to systems that are designed for it.
These are bigger architectural decisions, but they can eliminate entire classes of memory issues.
A deeper example: transforming large CSV files safely
Here is a more complete example of a memory-safe CSV pipeline that does validation, transformation, and output without holding everything in RAM.
# csv_pipeline.py
import csv
INPUT = "events.csv"
OUTPUT = "events_clean.csv"
with open(INPUT, "r", encoding="utf-8", newline="") as fin, \
open(OUTPUT, "w", encoding="utf-8", newline="") as fout:
reader = csv.DictReader(fin)
fieldnames = reader.fieldnames + ["normalized_event"]
writer = csv.DictWriter(fout, fieldnames=fieldnames)
writer.writeheader()
for row in reader:
event = row.get("event", "").strip().lower()
row["normalized_event"] = event
writer.writerow(row)
This scales to huge files because it only processes one row at a time.
A deeper example: memory-safe API ingestion with retries
External APIs can return large payloads. I prefer pagination plus streaming processing.
# api_ingest.py
import time
import requests
BASE_URL = "https://api.example.com/orders"
page = 1
while True:
try:
resp = requests.get(BASE_URL, params={"page": page, "limit": 500})
resp.raiseforstatus()
data = resp.json()
except requests.RequestException:
time.sleep(2)
continue
items = data.get("items", [])
if not items:
break
for item in items:
# process each item immediately
pass
page += 1
The batch size is explicit, and the loop never stores more than one page at a time.
What not to do when you hit MemoryError
When a job fails, the instinct is to try random changes. I avoid these because they often make the problem worse:
- Do not just increase memory limits without understanding growth. You might hide a leak.
- Do not swallow
MemoryErrorand keep going with corrupt state. - Do not keep large debug logs in memory. Write them to disk or emit them externally.
- Do not solve with
deepcopyunless you absolutely need isolation.
A realistic before-and-after story
I once inherited a nightly job that read JSON logs, parsed them, and built a list of dicts before writing results. It worked for weeks, then started failing as volumes grew.
Before:
- Read all logs into a list
- Parse all items into dicts
- Transform all dicts into a final list
- Write final list to disk
After:
- Stream each file line by line
- Parse, transform, and write each record immediately
- Keep only counters in memory
The change cut memory usage from several GB to a few hundred MB, and the job stopped failing. The performance impact was minor, but the stability gain was massive.
Putting it all together: my personal playbook
When I build a new Python job or service, I bake in memory safety from the start:
1) Stream inputs and avoid readlines() or list(...) unless I truly need full materialization.
2) If I need batches, I pick an explicit batch size and test it under a memory budget.
3) I set bounds on caches and buffers, especially for long-running services.
4) I profile early with tracemalloc and check for growth across loops.
5) I add a simple memory monitor in production for visibility.
This playbook is not fancy. It’s just consistent. And consistency is what prevents late-night memory incidents.
Final thoughts
MemoryError is not a mystery; it’s a signal. It tells you your code is asking for more memory than it can safely get. Once you know where that request comes from, the fix is usually straightforward: stream instead of store, chunk instead of hoard, and always cap growth.
If you adopt these patterns and keep memory visible in your workflow, you’ll spend far less time firefighting. That is the real win: fewer failures, more predictable systems, and code that scales with your data instead of collapsing under it.
If you want, I can also help you build a memory budget plan for a specific job or audit a snippet for hidden growth.


