Python Variables Practice Problems & Exercises
Practice: Variables and Assignment Deep Dive
← Back to lessonEasy
Predict the output. Think carefully about how many list objects a = b = [] creates.
a = b = [1, 2, 3] a[0] = 99 print(a) print(b) print(a is b)
Solution
[99, 2, 3]
[99, 2, 3]
True
Why: a = b = [1, 2, 3] creates one list object and binds both a and b to it. This is not the same as a = [1, 2, 3]; b = [1, 2, 3] (which creates two separate lists). Since both names reference the same mutable object, a[0] = 99 is visible through b as well.
The rule: Chained assignment evaluates the right-hand side once, then binds each target from left to right to that single object. With immutable types (int, str, tuple) this is harmless. With mutable types, it creates an aliasing trap.
Expected Output
[99, 2, 3]\n[99, 2, 3]\nTrueHints
Hint 1: Chained assignment `a = b = expr` evaluates `expr` once, then binds both names to that single object.
Hint 2: Since lists are mutable, mutating through one name is visible through the other.
Use star unpacking to split a tuple into a first element, a middle section, and a last element in a single assignment line.
data = (1, 2, 3, 4, 5, 6, 7)
# Goal: first=1, middle=[2,3,4,5,6], last=7
Solution
data = (1, 2, 3, 4, 5, 6, 7)
first, *middle, last = data
print(first) # 1
print(middle) # [2, 3, 4, 5, 6]
print(last) # 7
print(type(middle)) # <class 'list'>
Key details:
- The starred variable always collects into a list, even if the source is a tuple.
- If there are no leftover elements, the starred variable gets an empty list
[]. - Only one starred expression is allowed per unpacking target.
*a, *b = datais aSyntaxError.
data = (1, 2, 3, 4, 5, 6, 7)
# Unpack so that:
# first = 1
# middle = [2, 3, 4, 5, 6]
# last = 7
# Your unpacking line here
print(first)
print(middle)
print(last)
print(type(middle))Expected Output
1\n[2, 3, 4, 5, 6]\n7\n<class 'list'>Hints
Hint 1: The star expression `*name` in an unpacking target collects all remaining items into a list.
Hint 2: You can place the star variable anywhere — beginning, middle, or end — but only one star is allowed.
Without running the code, classify each name as valid or invalid as a Python variable name. Then run the checker to verify. Rules for Python identifiers:Solution
_class, None, True, False, if, while, etc.)- are never allowed (they are the minus operator)café is valid (PEP 3131)_ is valid and conventionally used for throwaway values
# For each name below, predict: valid or invalid Python variable name?
# Then uncomment and run to check.
names = [
"_private", # 1
"2fast", # 2
"class", # 3
"__dunder__", # 4
"café", # 5
"my-var", # 6
"_", # 7
"None", # 8
]
import keyword
for name in names:
is_keyword = keyword.iskeyword(name)
is_identifier = name.isidentifier()
if is_keyword:
status = "INVALID (reserved keyword)"
elif not is_identifier:
status = "INVALID (bad syntax)"
else:
status = "VALID"
print(f"{name:15s} -> {status}")Expected Output
_private -> VALID\n2fast -> INVALID (bad syntax)\nclass -> INVALID (reserved keyword)\n__dunder__ -> VALID\ncafé -> VALID\nmy-var -> INVALID (bad syntax)\n_ -> VALID\nNone -> INVALID (reserved keyword)Hints
Hint 1: Python variable names must start with a letter or underscore, followed by letters, digits, or underscores.
Hint 2: Python 3 allows Unicode letters (like accented characters) in identifiers.
Hint 3: Reserved keywords (class, None, True, False, etc.) cannot be used as variable names.
Predict whether id() changes after += for each case, then run to verify.
# Case 1: integer (immutable)
x = 10
print(f"int before: id={id(x)}")
x += 5
print(f"int after: id={id(x)}")
print(f"Same object? {id(x) == id(x)}")
print()
# Case 2: list (mutable)
y = [1, 2, 3]
id_before = id(y)
y += [4, 5]
id_after = id(y)
print(f"list before: id={id_before}")
print(f"list after: id={id_after}")
print(f"Same object? {id_before == id_after}")
print()
# Case 3: tuple (immutable)
z = (1, 2, 3)
id_before = id(z)
z += (4, 5)
id_after = id(z)
print(f"tuple before: id={id_before}")
print(f"tuple after: id={id_after}")
print(f"Same object? {id_before == id_after}")Solution
- int
x += 5: Creates a new int object15and rebindsx. Theidchanges. - list
y += [4, 5]: Callslist.__iadd__, which islist.extendunder the hood. Mutates in place. Theidstays the same. - tuple
z += (4, 5): Tuples are immutable, so a new tuple is created. Theidchanges.
The critical distinction: += dispatches to __iadd__ (in-place add) if the type defines it. Mutable types like list and set define __iadd__ to mutate in place. Immutable types like int, str, and tuple fall back to __add__, which creates a new object.
This matters when aliases exist:
a = [1, 2]
b = a
a += [3] # mutates in place — b sees [1, 2, 3]
x = (1, 2)
y = x
x += (3,) # creates new tuple — y still sees (1, 2)
Expected Output
See solutionHints
Hint 1: For immutable types like `int`, `+=` creates a new object and rebinds the name.
Hint 2: For mutable types like `list`, `+=` calls `__iadd__` which mutates in place.
Hint 3: Compare `id()` before and after to see if the object identity changed.
Medium
Rewrite the data-reading pattern using the walrus operator ( Why the walrus operator helps here: Without The walrus operator eliminates this duplication. The expression PEP 572 note: The walrus operator was introduced in Python 3.8. It uses :=) so you assign and test the chunk in a single while condition.Solution
:=, the traditional pattern requires duplicating the function call:(chunk := get_chunk(chunks, index)) both assigns the return value to chunk AND evaluates to that value for the != "" comparison.:= (not =) and must be wrapped in parentheses when used in contexts where the precedence would be ambiguous.
import random
# Simulate reading data chunks until we get an empty one.
# Use the walrus operator to assign and test in one expression.
def get_chunk(chunks, index_holder):
"""Simulate reading chunks from a source."""
if index_holder[0] < len(chunks):
result = chunks[index_holder[0]]
index_holder[0] += 1
return result
return ""
chunks = ["Hello ", "world, ", "this ", "is ", "Python!", ""]
index = [0]
result = []
# TODO: Use a while loop with the walrus operator (:=)
# to read chunks until an empty string is returned.
# Append each non-empty chunk to result.
print("".join(result))Expected Output
Hello world, this is Python!Hints
Hint 1: The walrus operator `:=` assigns a value and returns it in a single expression.
Hint 2: Pattern: `while (chunk := get_chunk(...)) != "":` — assigns and tests in one line.
Hint 3: Without the walrus operator, you would need to call get_chunk before the loop AND inside the loop body.
Use the walrus operator inside a list comprehension to compute How it works step by step: Without walrus (wasteful): This computes Evaluation order matters: In a comprehension, the math.sqrt() only once per element while filtering.Solution
n in numbers, the if clause runs first(root := math.sqrt(n)) computes the square root, assigns it to root, and returns the value3 — if greater, the element is includedroot uses the already-computed value — no second call to math.sqrtmath.sqrt(n) twice for every element that passes the filter. For expensive operations, the walrus operator provides a real performance benefit.if clause is evaluated before the output expression, so assigning in the if clause makes the variable available in the output.
import math
# Given a list of numbers, compute sqrt for each,
# but ONLY keep results where sqrt > 3.
# Use the walrus operator to avoid computing sqrt twice.
numbers = [4, 9, 16, 25, 2, 49, 1, 64, 100, 8]
# TODO: Write a list comprehension using := so that
# sqrt is computed once per element, not twice.
# filtered = ...
print(filtered)Expected Output
[4.0, 5.0, 7.0, 8.0, 10.0]Hints
Hint 1: Without walrus: `[math.sqrt(n) for n in numbers if math.sqrt(n) > 3]` computes sqrt twice.
Hint 2: With walrus: assign in the `if` clause, use the variable in the output expression.
Hint 3: Pattern: `[y for n in numbers if (y := math.sqrt(n)) > 3]`
Predict the output, then verify. Think about the order of evaluation in tuple swap.
# Two-variable swap
a = 10
b = 20
print(f"a={a}, b={b}")
a, b = b, a
print(f"a={a}, b={b}")
# Three-variable rotation
x, y, z = 1, 2, 3
print(f"x={x}, y={y}, z={z}")
x, y, z = z, x, y
print(f"x={x}, y={y}, z={z}")Solution
a=10, b=20
a=20, b=10
x=1, y=2, z=3
x=3, y=1, z=2
How Python tuple swap works under the hood:
For a, b = b, a:
- Python evaluates the entire right side first:
b, acreates the tuple(20, 10) - Then it unpacks the tuple into the left-side targets:
a = 20,b = 10
This is why no temporary variable is needed. The right side is a snapshot of the values before any rebinding occurs.
For x, y, z = z, x, y:
- Right side evaluates to
(3, 1, 2)(using the old values of z, x, y) - Then
x = 3,y = 1,z = 2
At the bytecode level, CPython uses ROT_TWO (for 2 variables) or ROT_THREE + ROT_TWO (for 3 variables) instructions, which are more efficient than creating an actual tuple object.
Expected Output
a=10, b=20\na=20, b=10\nx=1, y=2, z=3\nx=3, y=1, z=2Hints
Hint 1: Python evaluates the entire right side of an assignment before binding any names on the left.
Hint 2: This means `a, b = b, a` works because the right side creates a tuple `(b, a)` first.
Hint 3: This extends to any number of variables — the right side is fully evaluated before any assignments happen.
Write code that inspects Python's namespace dictionaries using Key namespace rules:locals() and globals(). Demonstrate that globals() is writable (you can create global variables dynamically) while locals() is read-only inside functions.Solution
locals() inside a function returns a snapshot of local variables. In CPython, writing to this dict has no effect on actual locals (they live in a fixed-size array for speed).globals() returns the actual module namespace dict. Writing to it immediately creates or modifies global variables.locals() at module level is the same dict as globals() — the distinction only matters inside functions.
# Explore how locals() and globals() expose Python's namespace dictionaries.
global_var = "I am global"
def inspect_namespaces():
local_var = "I am local"
another_local = 42
# TODO:
# 1. Print all local variable names (keys of locals())
# 2. Check if 'global_var' appears in locals()
# 3. Check if 'global_var' appears in globals()
# 4. Check if 'local_var' appears in globals()
# 5. Demonstrate that globals() can be used to CREATE
# a new global variable from inside a function
pass
inspect_namespaces()
# After the function call, check if the dynamically
# created global variable exists
# print(dynamic_global)Expected Output
Local variables: ['local_var', 'another_local']\n'global_var' in locals(): False\n'global_var' in globals(): True\n'local_var' in globals(): False\ndynamic_global = Created at runtime!Hints
Hint 1: `locals()` returns a dict of the current local scope. `globals()` returns the module-level namespace dict.
Hint 2: `globals()` is a live, writable dict — you can inject new global variables by assigning to it.
Hint 3: Unlike `globals()`, modifying the dict returned by `locals()` inside a function does NOT affect actual local variables (CPython optimization).
Predict the output for all three cases. Each demonstrates a different way to initialize multiple variables.
# Case 1: Chained assignment with mutable
a = b = c = []
a.append(1)
b.append(2)
c.append(3)
print(f"Case 1: a={a}, b={b}, c={c}")
print(f" All same object? {a is b is c}")
# Case 2: Separate assignment
x, y, z = [], [], []
x.append(1)
y.append(2)
z.append(3)
print(f"Case 2: x={x}, y={y}, z={z}")
print(f" All same object? {x is y is z}")
# Case 3: List multiplication trap
rows = [[0]] * 3
rows[0][0] = 99
print(f"Case 3: rows={rows}")
print(f" All same object? {rows[0] is rows[1] is rows[2]}")Solution
Case 1: a=[1, 2, 3], b=[1, 2, 3], c=[1, 2, 3]
All same object? True
Case 2: x=[1], y=[2], z=[3]
All same object? False
Case 3: rows=[[99], [99], [99]]
All same object? True
Case 1 — a = b = c = [] creates one list and binds all three names to it. Every append mutates the same object.
Case 2 — x, y, z = [], [], [] creates three separate list objects. The right side is a tuple of three independent lists, unpacked into three names.
Case 3 — [[0]] * 3 creates one inner list [0] and puts three references to it in the outer list. Mutating through any index affects all three. The safe alternative:
rows = [[0] for _ in range(3)] # Three independent inner lists
The pattern: Any time Python reuses a reference to a mutable object (chained assignment, list multiplication, default arguments), you get aliasing. When in doubt, create fresh objects explicitly.
Expected Output
See solutionHints
Hint 1: Think about how many list objects are created in `a = b = c = []`.
Hint 2: Then think about what `a = b = c = list()` does — is it any different?
Hint 3: Finally, consider `a, b, c = [], [], []` — how does this differ?
Hard
Build a TrackedNamespace class that logs every rebinding of a variable. It should track the full history of values assigned to each name and print a log message on every assignment.
ns = TrackedNamespace()
ns.x = 10
ns.x = 20
ns.x = 30
ns.y = "hello"
ns.y = "world"
print(f"x history: {ns.get_history('x')}")
print(f"y history: {ns.get_history('y')}")
print(f"Current x: {ns.x}")
Solution
class TrackedNamespace:
def __init__(self):
# Bypass our __setattr__ to set up internals
object.__setattr__(self, '_store', {})
object.__setattr__(self, '_history', {})
object.__setattr__(self, '_log', [])
def __setattr__(self, name, value):
store = object.__getattribute__(self, '_store')
history = object.__getattribute__(self, '_history')
log = object.__getattribute__(self, '_log')
if name in store:
old = store[name]
msg = f"[LOG] {name} rebind: {old} -> {value}"
else:
msg = f"[LOG] {name} = {value} (first binding)"
print(msg)
log.append(msg)
store[name] = value
history.setdefault(name, []).append(value)
def __getattr__(self, name):
store = object.__getattribute__(self, '_store')
if name in store:
return store[name]
raise AttributeError(f"'{type(self).__name__}' has no variable '{name}'")
def get_history(self, name):
history = object.__getattribute__(self, '_history')
return list(history.get(name, []))
def get_log(self):
log = object.__getattribute__(self, '_log')
return list(log)
How it works:
-
__init__usesobject.__setattr__to create internal storage dicts without triggering our custom__setattr__. This is the standard pattern for classes that override attribute setting. -
__setattr__intercepts everyns.x = valueassignment. It usesobject.__getattribute__to access internal dicts (again bypassing custom lookup), then logs whether this is a first binding or a rebinding. -
__getattr__is called only when normal attribute lookup fails (Python tried__dict__, class attributes, etc. first). It redirects to our internal store. -
get_historyreturns a copy of the value history list (returning a copy prevents external mutation of internal state).
Why object.__getattribute__ instead of self._store: If we used self._store inside __setattr__, Python would call our __getattr__ (since _store is not in __dict__), which would try to look up _store in _store — infinite recursion.
class TrackedNamespace:
"""A namespace that logs every variable rebinding.
Usage:
ns = TrackedNamespace()
ns.x = 10 # logs: x = 10 (first binding)
ns.x = 20 # logs: x rebind: 10 -> 20
ns.y = 'hello' # logs: y = hello (first binding)
print(ns.get_history('x')) # [(10,), (10, 20)]
"""
def __init__(self):
# Use object.__setattr__ to avoid triggering our own __setattr__
pass
def __setattr__(self, name, value):
# Log the binding and store the value
pass
def __getattr__(self, name):
# Retrieve the current value
pass
def get_history(self, name):
"""Return the full history of values for a variable name."""
pass
def get_log(self):
"""Return the full log of all operations."""
passExpected Output
[LOG] x = 10 (first binding)\n[LOG] x rebind: 10 -> 20\n[LOG] x rebind: 20 -> 30\n[LOG] y = hello (first binding)\n[LOG] y rebind: hello -> world\nx history: [10, 20, 30]\ny history: ['hello', 'world']\nCurrent x: 30Hints
Hint 1: Use `object.__setattr__(self, name, value)` in `__init__` to set up internal storage without triggering your custom `__setattr__`.
Hint 2: Store values in an internal dict and history in a list-of-lists dict.
Hint 3: `__getattr__` is only called when normal attribute lookup fails — use this to redirect to your internal storage.
Build a NamespaceSandbox that runs Python code in a completely isolated namespace. Code inside the sandbox should not affect the outer scope, and you can inject variables and inspect results.
sandbox = NamespaceSandbox()
sandbox.inject('x', 10)
sandbox.run('y = x * 2')
print(f"After run 1: y={sandbox.get('y')}")
print(f"All names: {sandbox.list_names()}")
sandbox.run('result = y * x')
print(f"After run 2: result={sandbox.get('result')}")
sandbox.reset()
print(f"After reset: {sandbox.list_names()}")
# Prove isolation
print(f"Leaked to outer scope: {'y' in dir()}")
Solution
class NamespaceSandbox:
def __init__(self):
self._namespace = {"__builtins__": __builtins__}
def inject(self, name, value):
self._namespace[name] = value
def run(self, code):
exec(code, self._namespace)
def get(self, name):
if name in self._namespace:
return self._namespace[name]
raise NameError(f"name '{name}' is not defined in sandbox")
def list_names(self):
hidden = {'__builtins__'}
return sorted(k for k in self._namespace if k not in hidden)
def reset(self):
self._namespace.clear()
self._namespace["__builtins__"] = __builtins__
How it works:
-
exec(code, globals_dict)executes code usingglobals_dictas both the global and local namespace. All variables created by the code are stored in this dict — not in the caller's actual namespace. -
__builtins__must be included so the sandboxed code can use built-in functions (print,len,range, etc.). Without it, Python would inject the real__builtins__module automatically, which is the same effect but less explicit. -
list_namesfilters out__builtins__since it is infrastructure, not a user-defined variable. -
resetclears the dict and re-adds__builtins__. This is cleaner than creating a new dict (which would break references held from previousruncalls).
Security note: This is namespace isolation, NOT security sandboxing. The executed code can still import modules, access the file system, and perform any operation Python allows. True sandboxing requires OS-level mechanisms (containers, seccomp, etc.). Never use exec() with untrusted input in production.
Namespace insight: This demonstrates that Python namespaces are fundamentally just dictionaries. Variables are keys, values are the objects they reference. The LEGB scope rules are implemented by looking up keys in a chain of these dictionaries.
class NamespaceSandbox:
"""Execute Python code in an isolated namespace.
Variables created inside the sandbox cannot leak out.
The sandbox can be given pre-defined variables.
After execution, you can inspect what was created.
Usage:
sandbox = NamespaceSandbox()
sandbox.inject('x', 10)
sandbox.run('y = x * 2')
print(sandbox.get('y')) # 20
print(sandbox.list_names()) # ['x', 'y']
print('y' in dir()) # False (didn't leak)
"""
def __init__(self):
pass
def inject(self, name, value):
"""Add a variable to the sandbox namespace."""
pass
def run(self, code):
"""Execute code within the sandboxed namespace."""
pass
def get(self, name):
"""Retrieve a variable from the sandbox."""
pass
def list_names(self):
"""List all user-defined variable names in the sandbox."""
pass
def reset(self):
"""Clear the sandbox namespace."""
passExpected Output
After run 1: y=20\nAll names: ['x', 'y']\nAfter run 2: result=200\nAfter reset: []\nLeaked to outer scope: FalseHints
Hint 1: Use `exec(code, globals_dict, locals_dict)` — Python lets you provide custom namespace dicts.
Hint 2: Use separate dicts for the global and local namespace to control what the code can see.
Hint 3: Filter out built-in names (like `__builtins__`) when listing user-defined names.
Design a TrackedVar class that wraps any value and maintains a complete audit trail of every assignment. Support undo, diff, and a configurable history limit.
from datetime import datetime
score = TrackedVar(0, name='score')
print(score)
score.set(10)
score.set(25)
score.set(17)
print(score)
print(f"History length: {len(score.history())}")
print(f"Diff (25 vs 17): {score.diff()}")
score.undo()
print(f"After undo: {score.get()}")
score.undo()
print(f"After second undo: {score.get()}")
# Show full history (including undo operations)
print(f"Full history values: {[v for v, t in score.history()]}")
# Test max_history
limited = TrackedVar(0, name='limited', max_history=3)
for i in [10, 25, 17, 25]:
limited.set(i)
print(f"Max history (last 3): {[v for v, t in limited.history()]}")
Solution
from datetime import datetime
class TrackedVar:
def __init__(self, initial_value, name='var', max_history=None):
self._name = name
self._max_history = max_history
self._history = [(initial_value, datetime.now())]
def set(self, value):
self._history.append((value, datetime.now()))
self._trim()
def get(self):
return self._history[-1][0]
def undo(self):
if len(self._history) < 2:
raise IndexError(f"Cannot undo: {self._name} has no previous value")
# Remove current value, exposing the previous one
removed_value, _ = self._history.pop()
# Record the undo as a new event (the "reverted to" value)
# This way history is an audit trail, not just a stack
return removed_value
def history(self):
return list(self._history)
def diff(self):
if len(self._history) < 2:
raise ValueError(f"Need at least 2 values to compute diff")
current = self._history[-1][0]
previous = self._history[-2][0]
try:
return abs(current - previous)
except TypeError:
raise TypeError(
f"Cannot compute diff between {type(previous).__name__} "
f"and {type(current).__name__}"
)
def _trim(self):
if self._max_history and len(self._history) > self._max_history:
self._history = self._history[-self._max_history:]
def __repr__(self):
changes = len(self._history) - 1
return (
f"TrackedVar(name='{self._name}', "
f"current={self.get()!r}, changes={changes})"
)
Design decisions explained:
-
History as append-only log: Each
set()appends a(value, timestamp)tuple. This gives you a complete audit trail. The current value is alwaysself._history[-1]. -
undo()pops from history: This reverts to the previous value by removing the latest entry. The undo itself is NOT recorded as a new event (to keep undo semantics clean). This meansundotruly removes the last state. -
diff()usesabs(): Returns the absolute difference between the two most recent values. RaisesTypeErrorfor non-numeric types, so the caller gets a clear error instead of a confusing traceback. -
max_historytrimming: Uses list slicingself._history[-N:]to keep only the most recent entries. This prevents unbounded memory growth in long-running applications. -
__repr__: Provides a clear summary for debugging. Shows the variable name, current value, and number of changes (history length minus 1 for the initial value).
Real-world applications: This pattern appears in undo systems (text editors, form state), configuration management (tracking config changes), financial systems (audit trails), and state management libraries (Redux-style time travel debugging).
from datetime import datetime
class TrackedVar:
"""A smart variable that remembers every value it has ever held.
Features:
- Tracks full history of (value, timestamp) pairs
- Can undo to previous value
- Can diff between current and previous value (for numbers)
- Supports a max_history limit
Usage:
score = TrackedVar(0, name='score', max_history=100)
score.set(10)
score.set(25)
score.set(17)
print(score.get()) # 17
print(score.history()) # [(0, ts), (10, ts), (25, ts), (17, ts)]
score.undo()
print(score.get()) # 25
print(score.diff()) # numeric diff from previous
"""
def __init__(self, initial_value, name='var', max_history=None):
pass
def set(self, value):
"""Set a new value, recording it in history."""
pass
def get(self):
"""Get the current value."""
pass
def undo(self):
"""Revert to the previous value. Raises if no history."""
pass
def history(self):
"""Return full history as list of (value, timestamp) tuples."""
pass
def diff(self):
"""Return the numeric difference between current and previous value.
Raises TypeError if values are not numeric."""
pass
def __repr__(self):
passExpected Output
TrackedVar(name='score', current=0, changes=0)\nTrackedVar(name='score', current=25, changes=3)\nHistory length: 4\nDiff (25 vs 17): 8\nAfter undo: 25\nAfter second undo: 17\nFull history values: [0, 10, 25, 17, 25, 17]\nMax history (last 3): [25, 17, 25]Hints
Hint 1: Store history as a list of `(value, timestamp)` tuples. The current value is always the last entry.
Hint 2: For `undo`, pop the last entry and return to the previous one. But also record the undo as a new historical event for auditability.
Hint 3: For `max_history`, trim the history list from the front when it exceeds the limit (keep the most recent entries).
