Skip to main content

Python Line Profiler Practice Problems & Exercises

Practice: Line Profiler and Memory Profiler

11 problems4 Easy4 Medium3 Hard65–95 min
← Back to lesson

Easy

#1Simulate @profile — Line-by-Line TimingEasy
line-profilerprofile-decoratorsimulationtimeit

Simulate line-by-line profiling by manually timing each statement inside a data processing function.

import time

def process_data_timed(data):
"""Manually timed version of a data processing function."""
timings = {}

t0 = time.perf_counter()
filtered = [x for x in data if x % 2 == 0]
timings["filter evens"] = time.perf_counter() - t0

t0 = time.perf_counter()
squared = [x * x for x in filtered]
timings["square values"] = time.perf_counter() - t0

t0 = time.perf_counter()
total = sum(squared)
timings["sum"] = time.perf_counter() - t0

return total, timings

data = list(range(100_000))
result, timings = process_data_timed(data)

for line, elapsed in timings.items():
print(f"{line:20s}: {elapsed*1e6:.1f} us")

print("Line timings printed for each statement")
Solution
import time

def process_data_timed(data):
timings = {}

t0 = time.perf_counter()
filtered = [x for x in data if x % 2 == 0]
timings["filter evens"] = time.perf_counter() - t0

t0 = time.perf_counter()
squared = [x * x for x in filtered]
timings["square values"] = time.perf_counter() - t0

t0 = time.perf_counter()
total = sum(squared)
timings["sum"] = time.perf_counter() - t0

return total, timings

data = list(range(100_000))
result, timings = process_data_timed(data)

for line, elapsed in timings.items():
print(f"{line:20s}: {elapsed*1e6:.1f} us")

print("Line timings printed for each statement")

Why line-level profiling:

  • cProfile only shows function-level time. A slow function with 50 lines still leaves you guessing which line is hot.
  • line_profiler (install with pip install line_profiler) instruments every source line and reports hits and time.
  • Use kernprof -l -v script.py to run the profiler and print line-level stats.
  • The manual simulation above is useful for quick investigations without installing line_profiler.
Expected Output
Line timings printed for each statement
Hints

Hint 1: line_profiler is not in stdlib — simulate it by timing each line manually with time.perf_counter().

Hint 2: Wrap each logical "line" in start/stop timers to measure its individual contribution.

#2tracemalloc Basics — Peak AllocationEasy
tracemallocmemorypeak-allocationbaseline

Use tracemalloc to measure the peak memory allocated by building a list of 50,000 tuples.

import tracemalloc

def build_tuples(n):
return [(i, i * 2, i * 3) for i in range(n)]

# 1. Start tracemalloc
# 2. Call build_tuples(50_000)
# 3. Get peak memory
# 4. Stop tracemalloc
# 5. Print: "peak allocation: X KB" (round to 1 decimal)
Solution
import tracemalloc

def build_tuples(n):
return [(i, i * 2, i * 3) for i in range(n)]

tracemalloc.start()
build_tuples(50_000)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

print(f"peak allocation: {peak / 1024:.1f} KB")

tracemalloc basics:

  • tracemalloc.start() begins tracing all memory allocations.
  • get_traced_memory() returns (current_bytes, peak_bytes) since tracing started.
  • tracemalloc.stop() ends tracing and frees the tracing structures.
  • Each tuple (i, i*2, i*3) of small ints takes ~120–200 bytes including the tuple header and int objects.
  • For 50,000 tuples: expect ~8–12 MB peak depending on integer size and CPython version.
Expected Output
peak allocation: X KB
Hints

Hint 1: tracemalloc.start() begins memory tracing. tracemalloc.get_traced_memory() returns (current, peak) in bytes.

Hint 2: Call tracemalloc.stop() when done to free the tracing overhead.

#3tracemalloc Top Stats — Finding Memory-Hungry LinesEasy
tracemalloctop_statslinenomemory-hungry-lines

Use tracemalloc.take_snapshot() to find which lines allocate the most memory in a function with multiple data structures.

import tracemalloc

def multi_alloc():
small_list = list(range(1000))
medium_dict = {i: i * i for i in range(5000)}
large_list = list(range(100_000))
return small_list, medium_dict, large_list

tracemalloc.start()
multi_alloc()
snapshot = tracemalloc.take_snapshot()
tracemalloc.stop()

# Print top 5 allocations by lineno
# Final: "Top memory allocations by line printed"
Solution
import tracemalloc

def multi_alloc():
small_list = list(range(1000))
medium_dict = {i: i * i for i in range(5000)}
large_list = list(range(100_000))
return small_list, medium_dict, large_list

tracemalloc.start()
multi_alloc()
snapshot = tracemalloc.take_snapshot()
tracemalloc.stop()

top_stats = snapshot.statistics("lineno")
print("Top 5 memory allocations:")
for stat in top_stats[:5]:
print(f" {stat.traceback[0].filename}:{stat.traceback[0].lineno} "
f"-> {stat.size / 1024:.1f} KB ({stat.count} allocs)")
print("Top memory allocations by line printed")

tracemalloc snapshot analysis:

  • snapshot.statistics("lineno") groups allocations by source line and sorts by total size descending.
  • Each Statistic has .size (bytes), .count (number of allocations), and .traceback.
  • "filename" groups by file, "lineno" by line, "traceback" shows full call stack.
  • large_list (100K ints) will dominate — confirming that line allocates the most memory.
  • Compare two snapshots with snapshot2.compare_to(snapshot1, "lineno") to see what changed.
Expected Output
Top memory allocations by line printed
Hints

Hint 1: tracemalloc.take_snapshot().statistics("lineno") returns a list of StatisticDiff sorted by size.

Hint 2: Each entry has .traceback[0].lineno and .size attributes.

#4Generator vs List Memory ComparisonEasy
generatorlistmemorytracemalloclazy-evaluation

Use tracemalloc to compare the memory footprint of a list vs a generator for processing 100,000 numbers.

import tracemalloc

def sum_with_list(n):
numbers = [i * 2 for i in range(n)]
return sum(numbers)

def sum_with_generator(n):
numbers = (i * 2 for i in range(n))
return sum(numbers)

N = 100_000

# Measure peak allocation for each version
# Print:
# "list: X KB"
# "generator: Y KB"
# "generator uses less memory: True"
Solution
import tracemalloc

def sum_with_list(n):
numbers = [i * 2 for i in range(n)]
return sum(numbers)

def sum_with_generator(n):
numbers = (i * 2 for i in range(n))
return sum(numbers)

N = 100_000

tracemalloc.start()
sum_with_list(N)
_, list_peak = tracemalloc.get_traced_memory()
tracemalloc.clear_traces()

sum_with_generator(N)
_, gen_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

print(f"list: {list_peak / 1024:.1f} KB")
print(f"generator: {gen_peak / 1024:.1f} KB")
print(f"generator uses less memory: {gen_peak < list_peak}")

Generator memory advantage:

  • The list comprehension allocates a Python list object holding 100K integer pointers (~800 KB + int objects).
  • The generator expression holds only the iterator state — a few hundred bytes regardless of N.
  • Trade-off: generators are single-pass (cannot index or reuse), lists are random-access.
  • Use generators for pipelines where you process each element once and discard it.
Expected Output
list: X KB\ngenerator: Y KB\ngenerator uses less memory: True
Hints

Hint 1: A generator expression does not allocate all values upfront — it yields them on demand.

Hint 2: Use tracemalloc to measure peak allocation for list(range(N)) vs a generator that never materializes.


Medium

#5__slots__ Memory Savings MeasurementMedium
__slots__memorytracemallocclass-optimization

Compare the memory usage of 50,000 instances of a regular class (with __dict__) vs one using __slots__.

import tracemalloc
import sys

class PointDict:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z

class PointSlots:
__slots__ = ("x", "y", "z")
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z

N = 50_000

# Measure peak tracemalloc allocation for creating N instances of each class
# Print:
# "dict-based: X KB"
# "slots-based: Y KB"
# "slots saves Z%"
Solution
import tracemalloc

class PointDict:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z

class PointSlots:
__slots__ = ("x", "y", "z")
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z

N = 50_000

tracemalloc.start()
points_dict = [PointDict(i, i * 2.0, i * 3.0) for i in range(N)]
_, dict_peak = tracemalloc.get_traced_memory()
tracemalloc.clear_traces()

points_slots = [PointSlots(i, i * 2.0, i * 3.0) for i in range(N)]
_, slots_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

savings_pct = (dict_peak - slots_peak) / dict_peak * 100
print(f"dict-based: {dict_peak / 1024:.1f} KB")
print(f"slots-based: {slots_peak / 1024:.1f} KB")
print(f"slots saves {savings_pct:.0f}%")

slots memory impact:

  • Without __slots__: each instance has a __dict__ (hash table). CPython's empty dict takes ~232 bytes.
  • With __slots__: Python creates a C-level struct with one slot per attribute — no dict overhead.
  • Typical savings: 30–60% per instance for classes with 2–5 attributes.
  • Trade-offs: no dynamic attributes, no __dict__, no weakref support (add __weakref__ to slots if needed).
  • Use __slots__ for value objects created in large quantities: coordinates, events, data records.
Expected Output
dict-based: X KB\nslots-based: Y KB\nslots saves Z%
Hints

Hint 1: Without __slots__, each instance has a __dict__ holding attribute names and values — overhead is ~200-300 bytes per instance.

Hint 2: With __slots__, Python allocates a fixed-size struct per instance with no __dict__ — saves 30-60% per instance.

#6tracemalloc Snapshot Comparison — Incremental AllocationMedium
tracemallocsnapshotcompare_toincrementalmemory-leak

Use tracemalloc snapshot comparison to identify which lines cause incremental allocations in a loop.

import tracemalloc

cache = {}

def expensive_compute(key):
if key not in cache:
cache[key] = [i * key for i in range(1000)]
return cache[key]

tracemalloc.start()

# Take snapshot before
snap1 = tracemalloc.take_snapshot()

# Run 20 cache-populating calls
for i in range(20):
expensive_compute(i)

# Take snapshot after
snap2 = tracemalloc.take_snapshot()
tracemalloc.stop()

# Compare snapshots and print top 5 lines that grew
# Final: "Incremental allocations identified between snapshots"
Solution
import tracemalloc

cache = {}

def expensive_compute(key):
if key not in cache:
cache[key] = [i * key for i in range(1000)]
return cache[key]

tracemalloc.start()
snap1 = tracemalloc.take_snapshot()

for i in range(20):
expensive_compute(i)

snap2 = tracemalloc.take_snapshot()
tracemalloc.stop()

diff = snap2.compare_to(snap1, "lineno")
print("Top allocations since snap1:")
for stat in diff[:5]:
if stat.size_diff > 0:
print(f" {stat.traceback[0].filename}:{stat.traceback[0].lineno} "
f"+{stat.size_diff / 1024:.1f} KB ({stat.count_diff:+d} allocs)")
print("Incremental allocations identified between snapshots")

Snapshot comparison workflow:

  • snap2.compare_to(snap1, "lineno") returns a sorted list of StatisticDiff objects.
  • stat.size_diff > 0 means memory grew — potential unbounded growth / cache leak.
  • stat.size_diff < 0 means memory was freed — GC collected objects between snapshots.
  • This technique identifies memory leaks in long-running processes: take snapshots every 60 seconds and alert when size_diff trends upward.
Expected Output
Incremental allocations identified between snapshots
Hints

Hint 1: Call tracemalloc.take_snapshot() before and after a block. Use snapshot2.compare_to(snapshot1, "lineno") to see the diff.

Hint 2: Positive size in the diff means new allocations; negative means released memory.

#7Finding Memory-Hungry Lines in a Parsing FunctionMedium
tracemallocparsingmemory-hungryline-profiling

Instrument a CSV-parsing function with tracemalloc snapshots to identify which step allocates the most memory.

import tracemalloc
import io
import csv

CSV_DATA = "\n".join(
f"id_{i},{i},{i*0.5},{('A' if i % 3 == 0 else 'B')}"
for i in range(20_000)
)

def parse_and_analyze(csv_text):
tracemalloc.start()

# Step 1: parse all rows
reader = csv.reader(io.StringIO(csv_text))
rows = list(reader)
snap1 = tracemalloc.take_snapshot()

# Step 2: build lookup dict
lookup = {row[0]: row for row in rows}
snap2 = tracemalloc.take_snapshot()

# Step 3: extract numeric column
values = [float(row[2]) for row in rows]
snap3 = tracemalloc.take_snapshot()

tracemalloc.stop()
return snap1, snap2, snap3, len(rows)

s1, s2, s3, n = parse_and_analyze(CSV_DATA)

def snapshot_size_kb(snap):
return sum(stat.size for stat in snap.statistics("lineno")) / 1024

print(f"After parsing rows: {snapshot_size_kb(s1):.1f} KB")
print(f"After building dict: {snapshot_size_kb(s2):.1f} KB")
print(f"After extracting values: {snapshot_size_kb(s3):.1f} KB")
print("Most memory-hungry line identified")
Solution
import tracemalloc
import io
import csv

CSV_DATA = "\n".join(
f"id_{i},{i},{i*0.5},{('A' if i % 3 == 0 else 'B')}"
for i in range(20_000)
)

def parse_and_analyze(csv_text):
tracemalloc.start()

reader = csv.reader(io.StringIO(csv_text))
rows = list(reader)
snap1 = tracemalloc.take_snapshot()

lookup = {row[0]: row for row in rows}
snap2 = tracemalloc.take_snapshot()

values = [float(row[2]) for row in rows]
snap3 = tracemalloc.take_snapshot()

tracemalloc.stop()
return snap1, snap2, snap3, len(rows)

s1, s2, s3, n = parse_and_analyze(CSV_DATA)

def snapshot_size_kb(snap):
return sum(stat.size for stat in snap.statistics("lineno")) / 1024

print(f"After parsing rows: {snapshot_size_kb(s1):.1f} KB")
print(f"After building dict: {snapshot_size_kb(s2):.1f} KB")
print(f"After extracting values: {snapshot_size_kb(s3):.1f} KB")
print("Most memory-hungry line identified")

Staged snapshot analysis:

  • Taking snapshots between processing steps narrows down the memory-heavy operation.
  • The lookup dict duplicates the row references — it holds pointers to the same row lists but adds dict overhead.
  • Optimization: if only one key per row is needed, build {row[0]: float(row[2])} in a single pass — avoids the intermediate rows list.
  • Accumulating snapshots inside a function requires starting tracemalloc before the call.
Expected Output
Most memory-hungry line identified
Hints

Hint 1: Use tracemalloc.take_snapshot() inside the function after each major allocation.

Hint 2: The line building a large intermediate structure will dominate the snapshot.

#8Memory Profile of a Recursive AlgorithmMedium
tracemallocrecursioncall-stackmemorymemoization

Compare the peak memory of naive recursive Fibonacci vs memoized Fibonacci using tracemalloc.

import tracemalloc
import functools

def fib_naive(n):
if n <= 1:
return n
return fib_naive(n - 1) + fib_naive(n - 2)

@functools.lru_cache(maxsize=None)
def fib_memo(n):
if n <= 1:
return n
return fib_memo(n - 1) + fib_memo(n - 2)

N = 30

# Measure peak for fib_naive(N)
# Clear lru_cache, measure peak for fib_memo(N)
# Print:
# "naive peak: X KB"
# "memoized peak: Y KB"
# "reduction: Z%"
Solution
import tracemalloc
import functools

def fib_naive(n):
if n <= 1:
return n
return fib_naive(n - 1) + fib_naive(n - 2)

@functools.lru_cache(maxsize=None)
def fib_memo(n):
if n <= 1:
return n
return fib_memo(n - 1) + fib_memo(n - 2)

N = 30

import sys
sys.setrecursionlimit(5000)

tracemalloc.start()
fib_naive(N)
_, naive_peak = tracemalloc.get_traced_memory()
tracemalloc.clear_traces()

fib_memo.cache_clear()
fib_memo(N)
_, memo_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

reduction_pct = (naive_peak - memo_peak) / naive_peak * 100 if naive_peak > memo_peak else 0.0
print(f"naive peak: {naive_peak / 1024:.1f} KB")
print(f"memoized peak: {memo_peak / 1024:.1f} KB")
print(f"reduction: {reduction_pct:.0f}%")

Recursion and memory:

  • Naive fib(30) makes 2,692,537 calls — each Python frame uses ~2–4 KB.
  • Peak memory occurs at the deepest recursion depth — O(n) frames on the call stack at any time.
  • Memoized fib(30) makes 31 unique calls — cache holds 31 entries.
  • The dramatic call reduction (2.7M vs 31) explains both the memory and time savings.
  • lru_cache.cache_clear() resets the cache between measurements.
Expected Output
naive peak: X KB\nmemoized peak: Y KB\nreduction: Z%
Hints

Hint 1: Naive recursive Fibonacci allocates a new stack frame for every call — peak memory is O(2^n) call frames.

Hint 2: Memoized Fibonacci allocates O(n) cache entries — peak is O(n).


Hard

#9Detect a Memory Leak with tracemallocHard
tracemallocmemory-leakunbounded-cachesnapshot-diff

Use tracemalloc snapshot diffs to detect an unbounded cache that grows on every call — a classic Python memory leak pattern.

import tracemalloc

# Intentional memory leak: cache never evicts
_leak_cache = {}

def leaky_function(key):
if key not in _leak_cache:
_leak_cache[key] = [0] * 1000 # 1000 ints per entry
return _leak_cache[key]

def measure_growth(iterations):
tracemalloc.start()
snapshots = []
batch = 50

for batch_num in range(iterations // batch):
for i in range(batch_num * batch, (batch_num + 1) * batch):
leaky_function(i)
snapshots.append((batch_num + 1) * batch, tracemalloc.take_snapshot())

tracemalloc.stop()
return snapshots

# Call with 200 iterations, print memory growth across 4 batches of 50
# Detect that growth is monotonically increasing
# Final: "Memory leak detected: cache grows unboundedly"
Solution
import tracemalloc

_leak_cache = {}

def leaky_function(key):
if key not in _leak_cache:
_leak_cache[key] = [0] * 1000
return _leak_cache[key]

tracemalloc.start()
batch_size = 50
prev_snap = tracemalloc.take_snapshot()
sizes = []

for batch_num in range(4):
start_key = batch_num * batch_size
for i in range(start_key, start_key + batch_size):
leaky_function(i)
snap = tracemalloc.take_snapshot()
diff = snap.compare_to(prev_snap, "lineno")
growth_kb = sum(s.size_diff for s in diff if s.size_diff > 0) / 1024
sizes.append(growth_kb)
print(f"Batch {batch_num+1} ({(batch_num+1)*batch_size} calls): +{growth_kb:.1f} KB")
prev_snap = snap

tracemalloc.stop()

monotonic = all(sizes[i] >= 0 for i in range(len(sizes)))
print(f"Memory leak detected: cache grows unboundedly" if monotonic else "No clear leak")

Memory leak detection pattern:

  • Take a snapshot before each batch of calls. Compare with the previous snapshot.
  • A leak shows consistent positive size_diff that never decreases.
  • Unbounded dicts/lists are the most common Python "leak" — not true memory leaks (GC can collect them) but unbounded growth.
  • Fix: use functools.lru_cache(maxsize=1000) or cachetools.TTLCache to bound the cache size.
  • In production: track tracemalloc metrics with Prometheus — alert when heap grows beyond a threshold.
Expected Output
Memory leak detected: cache grows unboundedly
Hints

Hint 1: A memory leak in Python is typically an unbounded collection (dict, list) that grows indefinitely.

Hint 2: Take snapshots before and after repeated calls, then compare. If size_diff keeps growing, you have a leak.

#10array Module vs list — Numeric Memory ComparisonHard
arraylistmemorysys.getsizeofnumeric-data

Compare the memory footprint of a list of integers vs array.array for 100,000 values using both sys.getsizeof and tracemalloc.

import array
import sys
import tracemalloc

N = 100_000

# Create a list of N integers and an array.array of N signed longs ('l' type code)
# Measure size with sys.getsizeof (shallow) and tracemalloc (deep)
# Print:
# "list: X KB"
# "array: Y KB"
# "array is Zx more compact"
Solution
import array
import sys
import tracemalloc

N = 100_000

tracemalloc.start()
int_list = list(range(N))
_, list_peak = tracemalloc.get_traced_memory()
tracemalloc.clear_traces()

int_array = array.array("l", range(N))
_, array_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

list_kb = list_peak / 1024
array_kb = array_peak / 1024
ratio = list_peak / array_peak if array_peak > 0 else float("inf")

print(f"list: {list_kb:.1f} KB")
print(f"array: {array_kb:.1f} KB")
print(f"array is {ratio:.1f}x more compact")

# Shallow size comparison
print(f"\nsys.getsizeof(list): {sys.getsizeof(int_list)} bytes (container only)")
print(f"sys.getsizeof(array): {sys.getsizeof(int_array)} bytes (all data)")

array.array vs list memory:

  • list: the list container uses 8 bytes per pointer. Each int object takes 28 bytes. Total ~3.6 MB for 100K ints.
  • array.array("l"): signed long = 8 bytes per value on 64-bit Python. Total ~800 KB.
  • array.array("i"): signed int = 4 bytes per value. Total ~400 KB.
  • sys.getsizeof(list) returns only the container size (pointers) — not the size of the objects pointed to.
  • sys.getsizeof(array) returns the full size including all the data — more honest for arrays.
  • Use array.array for large numeric datasets where you need list-like access but minimal memory.
Expected Output
list: X KB\narray: Y KB\narray is Zx more compact
Hints

Hint 1: Python list stores pointers to Python objects — each int object takes 28 bytes. list overhead is 8 bytes per pointer.

Hint 2: array.array stores raw C values — a signed int takes exactly 4 bytes, a double takes 8 bytes.

#11Full Memory Profile — Before and After OptimizationHard
tracemallocoptimizationbefore-after__slots__generator

Profile a data processing pipeline that creates many instances, optimize it with __slots__ and a generator, then measure the memory reduction.

import tracemalloc

# v1: regular class + list pipeline
class RecordV1:
def __init__(self, id, value, label):
self.id = id
self.value = value
self.label = label

def pipeline_v1(n):
records = [RecordV1(i, i * 1.5, f"L{i}") for i in range(n)]
filtered = [r for r in records if r.value > n * 0.5]
totals = [r.value * 2 for r in filtered]
return sum(totals)

# v2: __slots__ + generator pipeline
class RecordV2:
__slots__ = ("id", "value", "label")
def __init__(self, id, value, label):
self.id = id
self.value = value
self.label = label

def pipeline_v2(n):
records = (RecordV2(i, i * 1.5, f"L{i}") for i in range(n))
filtered = (r for r in records if r.value > n * 0.5)
totals = (r.value * 2 for r in filtered)
return sum(totals)

N = 20_000

# Measure peak tracemalloc for each version
# Print:
# "v1 peak: X KB"
# "v2 peak: Y KB"
# "Memory reduction: Z%"
Solution
import tracemalloc

class RecordV1:
def __init__(self, id, value, label):
self.id = id
self.value = value
self.label = label

def pipeline_v1(n):
records = [RecordV1(i, i * 1.5, f"L{i}") for i in range(n)]
filtered = [r for r in records if r.value > n * 0.5]
totals = [r.value * 2 for r in filtered]
return sum(totals)

class RecordV2:
__slots__ = ("id", "value", "label")
def __init__(self, id, value, label):
self.id = id
self.value = value
self.label = label

def pipeline_v2(n):
records = (RecordV2(i, i * 1.5, f"L{i}") for i in range(n))
filtered = (r for r in records if r.value > n * 0.5)
totals = (r.value * 2 for r in filtered)
return sum(totals)

N = 20_000

tracemalloc.start()
pipeline_v1(N)
_, v1_peak = tracemalloc.get_traced_memory()
tracemalloc.clear_traces()

pipeline_v2(N)
_, v2_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

reduction = (v1_peak - v2_peak) / v1_peak * 100
print(f"v1 peak: {v1_peak / 1024:.1f} KB")
print(f"v2 peak: {v2_peak / 1024:.1f} KB")
print(f"Memory reduction: {reduction:.0f}%")

Combined optimization impact:

  • __slots__ removes per-instance __dict__ — saves ~200 bytes per instance = 4 MB for 20K instances.
  • Generator pipeline: records, filtered, totals never all exist in memory simultaneously — only one item at a time flows through.
  • v1 holds 3 intermediate lists (20K records + ~10K filtered + ~10K totals) in memory at once.
  • v2 holds at most O(1) items across the generator chain.
  • Combined: expect 80–95% memory reduction depending on string interning and Python version.
Expected Output
v1 peak: X KB\nv2 peak: Y KB\nMemory reduction: Z%
Hints

Hint 1: Combine multiple optimizations: __slots__ for instances, generator instead of list for the pipeline.

Hint 2: Profile v1 first to identify the two biggest contributors, then apply both fixes and profile v2.

© 2026 EngineersOfAI. All rights reserved.