Python Line Profiler Practice Problems & Exercises
Practice: Line Profiler and Memory Profiler
← Back to lessonEasy
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:
cProfileonly shows function-level time. A slow function with 50 lines still leaves you guessing which line is hot.line_profiler(install withpip install line_profiler) instruments every source line and reports hits and time.- Use
kernprof -l -v script.pyto 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 statementHints
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.
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 KBHints
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.
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
Statistichas.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 printedHints
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.
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: TrueHints
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
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__, noweakrefsupport (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.
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 ofStatisticDiffobjects.stat.size_diff > 0means memory grew — potential unbounded growth / cache leak.stat.size_diff < 0means 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_difftrends upward.
Expected Output
Incremental allocations identified between snapshotsHints
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.
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
lookupdict 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 intermediaterowslist. - Accumulating snapshots inside a function requires starting tracemalloc before the call.
Expected Output
Most memory-hungry line identifiedHints
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.
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
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_diffthat 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)orcachetools.TTLCacheto bound the cache size. - In production: track
tracemallocmetrics with Prometheus — alert when heap grows beyond a threshold.
Expected Output
Memory leak detected: cache grows unboundedlyHints
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.
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. Eachintobject 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.arrayfor large numeric datasets where you need list-like access but minimal memory.
Expected Output
list: X KB\narray: Y KB\narray is Zx more compactHints
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.
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,totalsnever 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.
