Skip to main content

Python Variables in Memory Practice Problems & Exercises

Practice: Variables in Memory

12 problems4 Easy5 Medium3 Hard45–60 min
← Back to lesson

Easy

#1Alias DetectorEasy
variablesidentityis-operator

Write a function are_aliases(a, b) that returns True if a and b refer to the same object in memory.

x = [1, 2, 3]
y = x
z = [1, 2, 3]
print(are_aliases(x, y)) # True
print(are_aliases(x, z)) # False
Solution
def are_aliases(a, b):
return a is b

Why it works: The is operator checks whether two names point to the same object (same id()). == checks value equality — two different list objects with the same contents are == but not is.

def are_aliases(a, b):
    # Return True if a and b point to the same object
    pass
Expected Output
True\nFalse
Hints

Hint 1: Think about what operator checks object identity (not equality).

Hint 2: The `is` operator compares memory addresses — same as comparing `id()` values.

#2ID InspectorEasy
ididentityinterning

Predict the output of the following code, then verify by running it.

Python
a = 100
b = 100
print(a is b)

c = 1000
d = 1000
print(c is d)

e = "hello"
f = "hello"
print(e is f)
Solution
True
False
True
  • a is bTrue: CPython interns integers from -5 to 256, so 100 is the same object.
  • c is dFalse: 1000 is outside the interning range — two separate objects are created.
  • e is fTrue: CPython interns short string literals that look like identifiers.

Key insight: Never rely on is for value comparison. Interning is a CPython implementation detail, not a language guarantee.

Expected Output
True\nFalse\nTrue
Hints

Hint 1: Small integers (-5 to 256) are cached by CPython.

Hint 2: Large integers are created as new objects each time.

#3Reassignment TracerEasy
variablesreassignmentimmutable

Predict the output. Think about what happens when you reassign an immutable value.

Python
a = 10
b = a
a = 20
print(b)
print(10)
print(a)
Solution
10
10
20

b = a makes b point to the same integer object 10. When a = 20 runs, a is rebound to a new integer object 20. b still points to the original 10. Integers are immutable — they can't be changed in place, only rebound.

Expected Output
10\n10\n20
Hints

Hint 1: Integers are immutable — reassigning `a` creates a new object.

Hint 2: `b` still points to the original object.

#4Mutable or Immutable?Easy
mutableimmutabletypes

Write a function classify(obj) that returns "mutable" or "immutable" based on the object's type. Handle the common built-in types.

print(classify(42)) # immutable
print(classify([1, 2])) # mutable
print(classify("hello")) # immutable
print(classify({"a": 1})) # mutable
print(classify((1, 2))) # immutable
print(classify({1, 2})) # mutable
Solution
def classify(obj):
immutable_types = (int, float, bool, str, bytes, tuple, frozenset, type(None))
return "immutable" if isinstance(obj, immutable_types) else "mutable"

Why it works: Python's mutable built-in types are list, dict, set, and bytearray. Everything else in the common built-ins is immutable. Using isinstance with a tuple of types is the clean, Pythonic check.

def classify(obj):
    """Return 'mutable' or 'immutable'."""
    pass
Expected Output
immutable\nmutable\nimmutable\nmutable\nimmutable\nmutable
Hints

Hint 1: Check the type of the object. Lists, dicts, sets, and bytearrays are mutable.

Hint 2: Tuples, strings, integers, floats, booleans, bytes, and frozensets are immutable.


Medium

#5The Aliasing BugMedium
aliasingmutationfunctions

The function below has a classic Python bug. Each call should return a list with only the new item, but instead the list grows. Fix the bug.

Python
def add_item(item, inventory=[]):
    inventory.append(item)
    return inventory

print(add_item("sword"))    # Expected: ['sword']
print(add_item("shield"))   # Expected: ['shield']
print(add_item("potion"))   # Expected: ['potion']
Solution
def add_item(item, inventory=None):
if inventory is None:
inventory = []
inventory.append(item)
return inventory

The bug: Python evaluates default argument values once at function definition time. The empty list [] is created once and shared across all calls. Every append mutates that same list object.

The fix: Use None as the sentinel default and create a fresh list inside the function body on each call.

def add_item(item, inventory=[]):
    inventory.append(item)
    return inventory

# This should print three separate lists
print(add_item("sword"))
print(add_item("shield"))
print(add_item("potion"))
Expected Output
['sword']\n['shield']\n['potion']
Hints

Hint 1: Default arguments are evaluated once — when the function is defined, not when it is called.

Hint 2: Use `None` as the default, then create a new list inside the function body.

#6Shallow Copy SurpriseMedium
shallow-copynestedmutation

Predict the output. Pay attention to what a shallow copy actually copies.

Python
original = [[1, 2, 3], [4, 5, 6]]
shallow = list(original)

shallow[0][2] = 99
original[1] = [7, 8, 9]

print(shallow)
print(original)
Solution
[[1, 2, 99], [4, 5, 6]]
[[1, 2, 99], [7, 8, 9]]
  • shallow = list(original) creates a new outer list, but the inner lists are shared references.
  • shallow[0][2] = 99 mutates the inner list at index 0 — visible from both shallow and original because they share the same inner list.
  • original[1] = [7, 8, 9] rebinds original[1] to a new list — shallow[1] still points to the old [4, 5, 6].

Key insight: Shallow copy = new container, same contents. Mutations to nested objects propagate; rebinding does not.

Expected Output
[[1, 2, 99], [4, 5, 6]]\n[[1, 2, 99], [4, 5, 6]]
Hints

Hint 1: `list()` and `[:]` create shallow copies — only the outer list is new.

Hint 2: Nested objects (inner lists) are still shared references.

#7Pass-by-Object-ReferenceMedium
functionsmutationpass-by-reference

Predict the output for each function call. Think about when mutation affects the caller vs. when it doesn't.

Python
def modify(lst):
    lst.append(4)

def replace(lst):
    lst = [10, 20]

data = [1, 2, 3]
modify(data)
print(data)

data2 = [1, 2, 3, 4]
replace(data2)
print(data2)

local = [10, 20]
print(local)
Solution
[1, 2, 3, 4]
[1, 2, 3, 4]
[10, 20]
  • modify(data): lst points to the same object as data. lst.append(4) mutates that object → data sees [1, 2, 3, 4].
  • replace(data2): lst starts pointing to data2, but lst = [10, 20] rebinds the local name lst to a new list. data2 is unaffected → still [1, 2, 3, 4].

Key insight: Python is "pass-by-object-reference." The function receives a copy of the reference, not a copy of the object. Mutation through the reference affects the original; reassigning the local name does not.

Expected Output
[1, 2, 3, 4]\n[1, 2, 3, 4]\n[10, 20]
Hints

Hint 1: Python passes object references by value — the function gets a copy of the reference.

Hint 2: Mutating via the reference affects the original. Rebinding the local name does not.

#8Deep Copy BuilderMedium
deep-copyshallow-copycopy-module

Implement safe_duplicate(obj) that returns a fully independent deep copy. Demonstrate that mutating the copy does not affect the original.

data = {"users": ["alice", "bob"], "settings": {"theme": "dark"}}
clone = safe_duplicate(data)

clone["users"].append("charlie")
clone["settings"]["theme"] = "light"

print("Original:", data)
print("Copy: ", clone)
Solution
import copy

def safe_duplicate(obj):
return copy.deepcopy(obj)

Why deepcopy: copy.copy() (shallow) would create a new dict, but the inner list and dict would be shared. Mutations to clone["users"] would leak into data. copy.deepcopy() recursively copies every nested object, so the two are completely independent.

Performance note: Deep copy is slower and uses more memory. Use it when you need true isolation; use shallow copy when you only need a new top-level container.

import copy

def safe_duplicate(obj):
    """Return a fully independent copy of obj.
    Modifying the copy must never affect the original.
    """
    pass
Expected Output
Original: {'users': ['alice', 'bob'], 'settings': {'theme': 'dark'}}\nCopy:     {'users': ['alice', 'bob', 'charlie'], 'settings': {'theme': 'light'}}
Hints

Hint 1: Think about what `copy.copy()` vs `copy.deepcopy()` actually copies.

Hint 2: `copy.deepcopy()` recursively copies all nested objects.

#9Reference Count ObserverMedium
reference-countingsys-getrefcountgc

Predict the reference counts at each print statement, then run to verify.

Python
import sys

a = [1, 2, 3]
print(sys.getrefcount(a))  # How many?

b = a
print(sys.getrefcount(a))  # How many now?

c = [a, a, a]
print(sys.getrefcount(a))  # And now?

del b
print(sys.getrefcount(a))  # After deleting b?
Solution
2
3
6
5

Breakdown:

  • After a = [1,2,3]: 1 reference (a) + 1 temporary from getrefcount arg = 2
  • After b = a: a + b + temporary = 3
  • After c = [a, a, a]: a + b + 3 slots in c + temporary = 6
  • After del b: removes one reference → 5

Key insight: sys.getrefcount() always returns one extra because passing the object to the function creates a temporary reference. This is CPython's reference-counting garbage collector in action.

Expected Output
See solution for explanation
Hints

Hint 1: `sys.getrefcount(obj)` always returns one MORE than you expect because the function argument itself is a temporary reference.

Hint 2: Deleting a name or reassigning it decrements the reference count.


Hard

#10Mutation-Safe CacheHard
designdeep-copyencapsulation

Design a SafeCache class where:

  1. put(key, value) stores a value that cannot be mutated by the caller afterward.
  2. get(key) returns a value that cannot be used to mutate the cached copy.
cache = SafeCache()

data = [1, 2, 3]
cache.put("items", data)
data.append(999) # Caller mutates original — cache must be unaffected
print("Cached:", cache.get("items"))

result = cache.get("items")
result.append("injected") # Mutating the returned value — cache must be unaffected
print("After mutation:", cache.get("items"))
Solution
import copy

class SafeCache:
def __init__(self):
self._store = {}

def put(self, key, value):
self._store[key] = copy.deepcopy(value)

def get(self, key):
if key not in self._store:
return None
return copy.deepcopy(self._store[key])

Why deepcopy on both operations:

  • On put: Prevents the caller from mutating data and accidentally changing the cached value.
  • On get: Prevents the caller from mutating the returned reference and corrupting the cache.

Trade-off: This is the safest but most expensive approach. In performance-critical code, you might use copy.copy() (shallow) if your values aren't nested, or return frozen/immutable types (tuples, frozensets) instead.

import copy

class SafeCache:
    """A cache that stores and returns deep copies.
    Callers can never mutate cached values."""

    def __init__(self):
        self._store = {}

    def put(self, key, value):
        # Store a safe copy
        pass

    def get(self, key):
        # Return a safe copy (or None if missing)
        pass
Expected Output
Cached: [1, 2, 3]\nAfter mutation: [1, 2, 3]\nGet 1: [1, 2, 3]\nGet 2: [1, 2, 3, 'injected']  -- WRONG if not safe\nExpected: both gets return [1, 2, 3]
Hints

Hint 1: You need to deepcopy on BOTH put and get — otherwise the caller can mutate the stored copy via the returned reference.

Hint 2: Think about what happens if you only copy on put but return the stored reference on get.

Hint 3: Consider the trade-off: extra memory and CPU for safety vs. risk of silent corruption.

#11Reference Counter SimulatorHard
reference-countinggcsimulation

Build a RefCounter class that simulates CPython's reference-counting garbage collector. It should track allocation, reference increments/decrements, and free objects when their count hits zero.

rc = RefCounter()
rc.allocate("obj_a")
rc.inc_ref("obj_a") # a = something; b = a
rc.inc_ref("obj_a") # c = a
print("obj_a count:", rc.get_count("obj_a")) # 3

rc.dec_ref("obj_a") # del c
print("obj_a count after dec:", rc.get_count("obj_a")) # 2

rc.allocate("obj_b")
rc.dec_ref("obj_b") # Only reference removed → freed
print("obj_b freed:", rc.get_count("obj_b") == 0) # True

rc.dec_ref("obj_a")
rc.dec_ref("obj_a") # Hits 0 → freed
print("obj_a count:", rc.get_count("obj_a")) # 0
print("Freed order:", rc.get_freed()) # ['obj_b', 'obj_a']
Solution
class RefCounter:
def __init__(self):
self._counts = {}
self._freed = []

def allocate(self, obj_id: str) -> None:
self._counts[obj_id] = 1

def inc_ref(self, obj_id: str) -> None:
if obj_id in self._counts:
self._counts[obj_id] += 1

def dec_ref(self, obj_id: str) -> None:
if obj_id not in self._counts:
return
self._counts[obj_id] -= 1
if self._counts[obj_id] <= 0:
del self._counts[obj_id]
self._freed.append(obj_id)

def get_count(self, obj_id: str) -> int:
return self._counts.get(obj_id, 0)

def get_freed(self) -> list:
return list(self._freed)

How it mirrors CPython:

  • allocate = object creation (refcount starts at 1)
  • inc_ref = new name binding (b = a) or adding to a container
  • dec_ref = del name, name going out of scope, or container removal
  • When count hits 0, the object is immediately freed (no waiting for a GC cycle)

Limitation of real reference counting: It can't handle cyclic references (A → B → A). CPython uses a separate cycle-detecting GC for those.

class RefCounter:
    """Simulate a simple reference-counting memory manager."""

    def __init__(self):
        self._counts = {}  # object_id -> ref count
        self._freed = []   # list of freed object_ids

    def allocate(self, obj_id: str) -> None:
        """Create a new object with refcount 1."""
        pass

    def inc_ref(self, obj_id: str) -> None:
        """Increment reference count (new name points to object)."""
        pass

    def dec_ref(self, obj_id: str) -> None:
        """Decrement reference count. Free if it hits 0."""
        pass

    def get_count(self, obj_id: str) -> int:
        """Return current reference count (0 if freed)."""
        pass

    def get_freed(self) -> list:
        """Return list of freed object IDs in order."""
        pass
Expected Output
obj_a count: 3\nobj_a count after dec: 2\nobj_b freed: True\nobj_a count: 0\nFreed order: ['obj_b', 'obj_a']
Hints

Hint 1: Track counts in a dictionary. Allocate sets count to 1.

Hint 2: When `dec_ref` drops count to 0, move the id to the freed list and remove from counts.

Hint 3: Handle edge cases: decrementing a freed or unknown object should be a no-op.

#12Cyclic Reference DetectorHard
gccyclic-referencesweakref

Create a cyclic reference between two objects, then demonstrate that Python's garbage collector can still collect them. Use weakref to verify the objects are actually freed.

# Your code should:
# 1. Create two objects that reference each other (a cycle)
# 2. Create a weak reference to observe one of them
# 3. Delete all strong references
# 4. Show the weak ref is still alive (reference counting alone can't free them)
# 5. Run gc.collect() and show the weak ref is now dead
Solution
import gc
import weakref

class Node:
def __init__(self, name):
self.name = name
self.ref = None

def demonstrate_cycle_collection():
# Disable automatic GC so we control when collection happens
gc.disable()

# Create a cycle: a -> b -> a
a = Node("A")
b = Node("B")
a.ref = b
b.ref = a

# Weak reference lets us observe without preventing collection
weak_a = weakref.ref(a)

# Delete strong references — refcount won't hit 0 due to cycle
del a
del b

print("Weak ref alive before GC:", weak_a() is not None) # True

# Manually trigger cycle-detecting GC
gc.collect()

print("Weak ref alive after GC:", weak_a() is not None) # False
print("Cycle was collected!")

gc.enable()

demonstrate_cycle_collection()

How it works:

  • After del a and del b, the objects still exist because each has a refcount of 1 (from the other's .ref attribute). Reference counting alone cannot free them.
  • gc.collect() runs Python's cycle detector — a generational mark-and-sweep algorithm that finds groups of objects reachable only from each other (not from any root).
  • The weak reference confirms the objects were actually freed: weak_a() returns None after collection.

Real-world implication: Cyclic references in long-running processes (caches, graph structures, observer patterns) can leak memory if the cycle detector is disabled or if objects define __del__ finalizers (which can prevent collection in older Python versions).

import gc
import weakref

def create_cycle():
    """Create a cyclic reference and return a weak reference
    to detect when the cycle is collected."""
    pass

def demonstrate_cycle_collection():
    """Show that Python's GC can collect cyclic references."""
    pass
Expected Output
Weak ref alive before GC: True\nWeak ref alive after GC: False\nCycle was collected!
Hints

Hint 1: Create two objects that reference each other: `a.ref = b` and `b.ref = a`.

Hint 2: Use `weakref.ref()` to observe the object without preventing collection.

Hint 3: After deleting all strong references, call `gc.collect()` to trigger cycle detection.

© 2026 EngineersOfAI. All rights reserved.