Python Tricky Output & Gotchas — "What Will This Print?"
"What will be the output?" — The question that catches even experienced engineers. These are NOT coding questions. These test if you truly UNDERSTAND Python internals.
Memory Map
Q01 — Mutable Default Argument Trap
What will this print?
def add_item(item, lst=[]):
lst.append(item)
return lst
print(add_item("a"))
print(add_item("b"))
print(add_item("c"))
Think about it...
Common Wrong Answer: ['a'], ['b'], ['c']
Most people assume a fresh empty list is created on every call. That is how it works in most other languages.
Actual Output:
# Output:
# ['a']
# ['a', 'b']
# ['a', 'b', 'c']
Why: The default list [] is created ONCE when the function is defined, not each time it is called. Every call shares the SAME list object in memory. Python evaluates default arguments at function definition time, so lst points to the same list across all calls.
The Fix:
def add_item(item, lst=None):
if lst is None: # Create a NEW list each time — None is immutable, safe as default
lst = []
lst.append(item)
return lst
# Output:
# ['a']
# ['b']
# ['c']
Interview Tip: "Never use mutable objects (list, dict, set) as default arguments. Use None and create inside the function. This is Python's most famous gotcha."
What NOT to Say: "I think Python creates a new list each call" — this shows you have not encountered one of the most fundamental Python traps.
Q02 — List Aliasing vs Copy
What will this print?
a = [1, 2, 3]
b = a # b is NOT a copy — it is the SAME object
b.append(4)
print(a)
print(b)
Think about it...
Common Wrong Answer: [1, 2, 3] and [1, 2, 3, 4]
People assume b = a creates a copy. It does not.
Actual Output:
# Output:
# [1, 2, 3, 4]
# [1, 2, 3, 4]
Why: b = a creates an alias, not a copy. Both a and b point to the exact same list object in memory. Mutating through one name is visible through the other because there is only one list.
The Fix:
a = [1, 2, 3]
b = a[:] # Shallow copy via slicing — creates a NEW list
b.append(4)
print(a) # Output: [1, 2, 3] — original unchanged
print(b) # Output: [1, 2, 3, 4]
# Other ways to shallow copy:
b = a.copy() # Shallow copy via method
b = list(a) # Shallow copy via constructor
# WARNING: shallow copy is NOT enough for nested lists!
import copy
a = [[1, 2], [3, 4]]
b = a.copy() # Shallow copy — inner lists are still shared
b[0].append(5)
print(a) # Output: [[1, 2, 5], [3, 4]] — CHANGED!
b = copy.deepcopy(a) # Deep copy — everything is independent
Interview Tip: "Know the difference: assignment (same object), shallow copy (new outer, shared inner), deep copy (everything new). This is critical for data pipelines where you transform copies of data."
What NOT to Say: "b = a copies the list" — this is a fundamental misunderstanding of Python references.
Q03 — is vs == and Integer Caching
What will this print?
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # Comparing VALUES
print(a is b) # Comparing IDENTITY (same object in memory?)
x = 256
y = 256
print(x is y) # Integer caching range: -5 to 256
p = int("257") # int() forces a new object creation
q = int("257") # Another new object
print(p is q) # Outside caching range
Think about it...
Common Wrong Answer: True, True, True, True
People confuse equality with identity, and do not know about integer caching.
Actual Output:
# Output:
# True ← same values
# False ← different objects in memory
# True ← 256 is cached by Python (range -5 to 256)
# False ← 257 is outside cache range, so two separate objects
Why:
==checks value equality — are the contents the same?ischecks identity — are they the exact same object in memory?- Python interns (caches) integers from -5 to 256 for performance. So
256 is 256isTrue, but two separately created257objects are different. We useint("257")to prevent the compiler from reusing the same constant within one script.
The Fix:
# ALWAYS use == for value comparison
if a == b: # Correct — compares values
print("equal")
# ONLY use 'is' for None checks
if x is None: # Correct — None is a singleton
print("missing")
# NEVER do this:
if x is 257: # Wrong — identity check on integers is unreliable
print("match")
Interview Tip: "Use == for value comparison. Use is only for None checks: if x is None. Never use is to compare numbers or strings — integer caching makes it unreliable."
What NOT to Say: "is and == are the same thing" — this shows a lack of understanding of Python's object model.
Q04 — String Immutability and List Multiplication Trap
What will this print?
s = "abc"
try:
s[0] = "x" # Strings are IMMUTABLE — cannot change in place
except TypeError as e:
print(f"Error: {e}")
# Now the nested list trap:
matrix = [[0] * 3] * 3 # This creates 3 references to the SAME inner list
matrix[0][0] = 1
print(matrix)
Think about it...
Common Wrong Answer: [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
People expect only the first row to change.
Actual Output:
# Output:
# Error: 'str' object does not support item assignment
# [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
Why: Two traps in one:
- Strings are immutable — you cannot modify individual characters.
[[0]*3] * 3creates 3 references to the SAME inner list. Changing one row changes all three because they are the same object.
The Fix:
# Fix for string mutation:
s = "abc"
s = "x" + s[1:] # Output: "xbc" — creates a NEW string
# Fix for matrix — use list comprehension to create INDEPENDENT rows:
matrix = [[0] * 3 for _ in range(3)] # Each row is a separate list object
matrix[0][0] = 1
print(matrix)
# Output: [[1, 0, 0], [0, 0, 0], [0, 0, 0]] — only first row changed
Interview Tip: "[[0]*3]*3 creates 3 references to the SAME inner list. Use a list comprehension to create independent rows. This is the number one NumPy-free matrix bug."
What NOT to Say: "I would just use * to create a 2D list" — this shows you have fallen into the trap before without realizing it.
Q05 — Closure Late Binding Trap
What will this print?
functions = []
for i in range(5):
functions.append(lambda: i) # lambda captures the VARIABLE i, not its current value
print([f() for f in functions])
Think about it...
Common Wrong Answer: [0, 1, 2, 3, 4]
People assume each lambda captures the value of i at the time it was created.
Actual Output:
# Output:
# [4, 4, 4, 4, 4]
Why: Lambda captures the variable i, not its value at creation time. This is called late binding. By the time you call the functions, the loop is done and i = 4. All five lambdas look up i and find 4.
The Fix:
# Fix 1: Default argument captures current value at definition time
functions = []
for i in range(5):
functions.append(lambda i=i: i) # i=i binds the current value as a default
print([f() for f in functions])
# Output: [0, 1, 2, 3, 4]
# Fix 2: Use functools.partial
from functools import partial
functions = [partial(lambda x: x, i) for i in range(5)]
print([f() for f in functions])
# Output: [0, 1, 2, 3, 4]
Interview Tip: "This is the classic late-binding closure trap. The fix lambda i=i: i captures the value at definition time via default argument. This applies to any closure in a loop, not just lambdas."
What NOT to Say: "Each lambda gets its own copy of i" — this is the exact misconception the question tests.
Q06 — Tuple with One Element
What will this print?
a = (1) # Parentheses around an integer — just grouping
b = (1,) # Trailing comma makes it a tuple
c = () # Empty tuple — no ambiguity here
print(type(a))
print(type(b))
print(type(c))
Think about it...
Common Wrong Answer: , ,
People assume parentheses always create a tuple.
Actual Output:
# Output:
# <class 'int'>
# <class 'tuple'>
# <class 'tuple'>
Why: Parentheses in Python serve two purposes: grouping and tuple creation. (1) is just the integer 1 wrapped in grouping parentheses. A single-element tuple requires a trailing comma: (1,). The comma is what makes the tuple, not the parentheses.
The Fix:
# Always use trailing comma for single-element tuples
single = (1,) # Correct — this is a tuple
print(type(single)) # Output: <class 'tuple'>
# You can even omit parentheses — the comma is what matters
also_tuple = 1,
print(type(also_tuple)) # Output: <class 'tuple'>
# Multi-element tuples do not need trailing comma (but it is good style)
multi = (1, 2, 3) # Output: tuple
multi = (1, 2, 3,) # Also valid — trailing comma is optional
Interview Tip: "Trailing comma makes the tuple, not the parentheses. (1) is an int, (1,) is a tuple, 1, is also a tuple. This catches even senior developers."
What NOT to Say: "Parentheses create tuples" — this is only partially true and shows incomplete understanding.
Q07 — Dictionary Key Overwrite (True == 1 == 1.0)
What will this print?
d = {
True: 'yes', # True has hash same as 1
1: 'one', # 1 == True, so this OVERWRITES the value
1.0: 'float_one' # 1.0 == 1 == True, overwrites again
}
print(d)
print(len(d))
Think about it...
Common Wrong Answer: {True: 'yes', 1: 'one', 1.0: 'float_one'} with length 3
People expect three separate keys.
Actual Output:
# Output:
# {True: 'float_one'}
# 1
Why: In Python, True == 1 == 1.0 and hash(True) == hash(1) == hash(1.0). Since they are "equal" and have the same hash, they are treated as the same dictionary key. Each subsequent assignment overwrites the value but keeps the first key (True). So you end up with one entry: key True, value 'float_one'.
The Fix:
# If you need them as separate keys, use different types or wrappers
d = {
'bool_true': 'yes',
'int_one': 'one',
'float_one': 'float_one'
}
# Or be aware that True/1/1.0 collide and design accordingly
Interview Tip: "True, 1, and 1.0 are equal and have the same hash, so they collapse to one dict key. The key stays as True (first inserted), but the value gets overwritten to 'float_one' (last assigned)."
What NOT to Say: "True and 1 are different types so they would be different keys" — Python dicts use equality and hash, not type, for key identity.
Q08 — Chained Comparison Surprise
What will this print?
print(1 < 2 < 3) # Chained: (1 < 2) and (2 < 3)
print(1 < 2 > 0) # Chained: (1 < 2) and (2 > 0)
print(1 < 3 > 2) # Chained: (1 < 3) and (3 > 2)
print(False == False in [False]) # Tricky chaining with 'in' operator
Think about it...
Common Wrong Answer: True, True, True, False
The last one trips people up — they parse it as (False == False) in [False] which is True in [False] which is True. But the actual chaining behavior also gives True, just for a different reason.
Actual Output:
# Output:
# True
# True
# True
# True
Why: Python chains ALL comparisons (including in):
1 < 2 < 3→(1 < 2) and (2 < 3)→True and True→TrueFalse == False in [False]→(False == False) and (False in [False])→True and True→True
The in operator is treated as a comparison operator, so it participates in chaining.
The Fix:
# Use explicit parentheses to make intent clear
result = (False == False) in [False] # True in [False] → True
# vs
result = False == (False in [False]) # False == True → False
# Chaining is great for range checks though:
x = 5
if 0 <= x <= 10: # Clean and Pythonic — instead of: if x >= 0 and x <= 10
print("valid")
# Output: valid
Interview Tip: "Python's comparison chaining includes in and is. a op1 b op2 c becomes (a op1 b) and (b op2 c). Use parentheses when mixing different operators to avoid confusion."
What NOT to Say: "Python evaluates left to right like (a < b) < c" — that would compare a boolean with c, which is NOT what Python does.
Q09 — *args and **kwargs Unpacking
What will this print?
def show(a, b, *args, **kwargs):
print(f"a={a}, b={b}")
print(f"args={args}") # Extra positional → tuple
print(f"kwargs={sorted(kwargs.items())}") # Extra keyword → dict (sorted for determinism)
show(1, 2, 3, 4, 5, x=10, y=20)
Think about it...
Common Wrong Answer: People often confuse the types — thinking args is a list or kwargs is a list of tuples.
Actual Output:
# Output:
# a=1, b=2
# args=(3, 4, 5)
# kwargs=[('x', 10), ('y', 20)]
Why:
aandbconsume the first two positional args*argscollects remaining positional args as a tuple (not a list!)**kwargscollects keyword args as a dict- The order is enforced: positional →
*args→ keyword-only →**kwargs
The Fix:
# Unpacking in function calls — the reverse operation
def add(a, b, c):
return a + b + c
nums = [1, 2, 3]
print(add(*nums)) # Output: 6 — unpacks list as positional args
config = {'a': 1, 'b': 2, 'c': 3}
print(add(**config)) # Output: 6 — unpacks dict as keyword args
# Full signature order in Python 3.8+:
# def f(pos_only, /, normal, *, kw_only, **kwargs)
Interview Tip: "*args is a tuple, **kwargs is a dict. Order: def f(pos, /, normal, *args, kw_only, **kwargs). Know where / and * go for positional-only and keyword-only parameters."
What NOT to Say: "*args gives you a list" — it is a tuple. This is a common slip that interviewers notice.
Q10 — Scope: Local vs Global (LEGB Rule)
What will this print?
x = 10 # Global scope
def outer():
x = 20 # Enclosing scope
def inner():
x = 30 # Local scope — shadows outer and global
print("inner:", x)
inner()
print("outer:", x) # Still 20 — inner's x was local to inner
outer()
print("global:", x) # Still 10 — nothing modified global x
Think about it...
Common Wrong Answer: Some expect inner() to modify outer()'s x, giving inner: 30, outer: 30, global: 10.
Actual Output:
# Output:
# inner: 30
# outer: 20
# global: 10
Why: Python follows the LEGB rule: Local → Enclosing → Global → Built-in. Each x = ... inside a function creates a new local variable that shadows the outer one. Without nonlocal or global, assignment never modifies an outer scope.
The Fix:
x = 10
def outer():
x = 20
def inner():
nonlocal x # Now refers to outer's x — not a new local
x = 30
inner()
print("outer:", x) # Output: 30 — changed by inner via nonlocal
outer()
def change_global():
global x # Now refers to module-level x
x = 99
change_global()
print("global:", x) # Output: 99 — changed by global keyword
Interview Tip: "LEGB = Local, Enclosing, Global, Built-in. Without nonlocal/global, assignment creates a NEW local variable. Reading works through LEGB, but writing always creates local unless you explicitly declare otherwise."
What NOT to Say: "Assignment in a function automatically modifies the global variable" — this shows confusion about Python's scoping rules.
Q11 — enumerate and zip Tricks
What will this print?
names = ['Alice', 'Bob', 'Charlie']
scores = [85, 92, 78]
# enumerate starts at 0 by default — but you can change it
for i, name in enumerate(names, start=1):
print(f"{i}. {name}")
print("---")
# zip pairs elements from multiple iterables
for name, score in zip(names, scores):
print(f"{name}: {score}")
print("---")
# Unzip trick — zip(*iterable) transposes rows and columns
pairs = [('a', 1), ('b', 2), ('c', 3)]
letters, numbers = zip(*pairs) # zip(('a',1), ('b',2), ('c',3))
print(letters)
print(numbers)
Think about it...
Common Wrong Answer: People often forget zip returns tuples, or expect enumerate to start at 1 by default.
Actual Output:
# Output:
# 1. Alice
# 2. Bob
# 3. Charlie
# ---
# Alice: 85
# Bob: 92
# Charlie: 78
# ---
# ('a', 'b', 'c')
# (1, 2, 3)
Why:
enumerate(iterable, start=0)yields(index, element)pairs.start=1shifts indices.zippairs elements position-by-position and stops at the shortest iterable.zip(*pairs)is the unzip trick — it transposes rows into columns. The*unpacks the list so each tuple becomes a separate argument tozip.
The Fix:
# If iterables have different lengths, zip silently truncates:
a = [1, 2, 3]
b = [10, 20]
print(list(zip(a, b))) # Output: [(1, 10), (2, 20)] — 3 is dropped!
# Use itertools.zip_longest to keep all elements:
from itertools import zip_longest
print(list(zip_longest(a, b, fillvalue=0)))
# Output: [(1, 10), (2, 20), (3, 0)]
Interview Tip: "Know that enumerate starts at 0 by default but accepts start=. And zip(*pairs) is the unzip/transpose trick. Also know that zip truncates silently — use zip_longest if you need all elements."
What NOT to Say: "zip raises an error if lengths differ" — it silently truncates, which can cause subtle data loss bugs.
Q12 — Walrus Operator := (Python 3.8+)
What will this print?
# Walrus operator assigns AND returns the value in one expression
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Without walrus — you compute x**2 twice (once to filter, once to keep)
results_old = [x**2 for x in numbers if x**2 > 25]
# With walrus — compute once, use twice
results_new = [y for x in numbers if (y := x**2) > 25]
print(results_old)
print(results_new)
print(results_old == results_new)
Think about it...
Common Wrong Answer: People sometimes think := only works in while loops, or that y is not accessible in the yield expression.
Actual Output:
# Output:
# [36, 49, 64, 81, 100]
# [36, 49, 64, 81, 100]
# True
Why: The walrus operator := assigns a value to a variable and returns that value, all in a single expression. In the comprehension, (y := x**2) computes x**2, assigns it to y, and the result is used in the > 25 comparison. Then y is used as the element to include. This avoids computing x**2 twice.
The Fix:
# Classic use case — avoid calling a function twice
import re
text = "Today is 2026-03-27"
if (match := re.search(r'\d{4}-\d{2}-\d{2}', text)): # Assign and test in one line
print(f"Found date: {match.group()}")
# Output: Found date: 2026-03-27
# While loop — cleaner than the two-call pattern
# Without walrus:
# line = input()
# while line != "quit":
# process(line)
# line = input()
# With walrus:
# while (line := input()) != "quit":
# process(line)
Interview Tip: "Walrus operator assigns AND returns the value. It avoids repeated computation in comprehensions and cleans up while-loop patterns. Available since Python 3.8."
What NOT to Say: "I have never seen := before" — it has been in Python since 3.8 (2019) and is commonly used in modern codebases.
Q13 — any() and all() with Empty Collections
What will this print?
print(any([0, '', None, False, 42])) # Is ANY element truthy?
print(all([1, 'a', True, [1]])) # Are ALL elements truthy?
print(all([1, 'a', True, []])) # [] is falsy!
print(any([])) # any of nothing?
print(all([])) # all of nothing?
Think about it...
Common Wrong Answer: Most people get the last one wrong — they expect all([]) to be False.
Actual Output:
# Output:
# True ← 42 is truthy, so any() returns True
# True ← all elements are truthy
# False ← [] is falsy, so all() returns False
# False ← no elements to be truthy
# True ← vacuous truth! no elements to be falsy
Why:
any()returnsTrueif at least one element is truthy. Empty →False(nothing is truthy).all()returnsTrueif no element is falsy. Empty →True(nothing is falsy). This is called vacuous truth — a concept from formal logic.- Python's falsy values:
0,0.0,'',None,False,[],{},set(),().
The Fix:
# Guard against vacuous truth if it matters in your logic:
items = []
if items and all(validate(x) for x in items): # Short-circuits on empty
print("All valid")
else:
print("No items or some invalid")
# Output: No items or some invalid
# Practical use in data engineering:
import os
files = ['data.csv', 'config.yaml']
if all(os.path.exists(f) for f in files):
print("All files ready")
Interview Tip: "all([]) returns True — this is vacuous truth and surprises everyone. Guard against it with if items and all(...). Know all the falsy values: 0, '', None, False, [], {}, set()."
What NOT to Say: "all([]) returns False because there are no elements" — this is the most common wrong answer and shows you have not tested it.
Q14 — try/except/else/finally Flow
What will this print?
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("Error!")
return -1
else:
print("Success!") # Only runs if NO exception
return result
finally:
print("Cleanup!") # ALWAYS runs — even after return!
print(divide(10, 2))
print("---")
print(divide(10, 0))
Think about it...
Common Wrong Answer: People often think finally does not run after a return, or that else always runs.
Actual Output:
# Output:
# Success!
# Cleanup!
# 5.0
# ---
# Error!
# Cleanup!
# -1
Why:
elseruns only if NO exception occurred intryfinallyALWAYS runs — even if there is areturnstatement intry,except, orelsefinallyruns after the return value is determined but before the function actually returns
The Fix:
# DANGER: What if finally also has a return?
def tricky():
try:
return 1 # Return value is determined as 1
finally:
return 2 # But finally OVERRIDES it!
print(tricky())
# Output: 2 ← finally's return REPLACES try's return!
# Rule: NEVER put return in finally — it silently swallows exceptions too
def swallowed():
try:
raise ValueError("important error!")
finally:
return "oops" # Exception is silently swallowed!
print(swallowed())
# Output: oops ← the ValueError is gone!
Interview Tip: "finally always runs. If both try and finally have return statements, finally wins. Never put return in finally — it can silently swallow exceptions."
What NOT to Say: "finally only runs if there is no exception" — that is else, not finally. Confusing these is a red flag.
Q15 — map, filter, reduce vs Comprehensions
What will this print?
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Functional style — map + filter
evens_squared_func = list(
map(lambda x: x**2, # Step 2: square each
filter(lambda x: x % 2 == 0, # Step 1: keep evens
numbers))
)
# Pythonic style — list comprehension (reads left to right)
evens_squared_comp = [x**2 for x in numbers if x % 2 == 0]
print(evens_squared_func)
print(evens_squared_comp)
print(evens_squared_func == evens_squared_comp)
# reduce — must be imported in Python 3
from functools import reduce
total = reduce(lambda a, b: a + b, numbers) # Accumulates: ((((1+2)+3)+4)+...+10)
print(total)
print(total == sum(numbers))
Think about it...
Common Wrong Answer: People sometimes think reduce is a built-in, or that map/filter return lists directly.
Actual Output:
# Output:
# [4, 16, 36, 64, 100]
# [4, 16, 36, 64, 100]
# True
# 55
# True
Why:
map()andfilter()return lazy iterators in Python 3, not lists — you must wrap withlist().- List comprehensions are more Pythonic and readable — they read left to right instead of inside out.
reducewas moved from built-in tofunctoolsin Python 3 — Guido considered it hard to read.- For simple aggregations, use built-ins:
sum(),max(),min()instead ofreduce.
The Fix:
# When to use map/filter — passing an EXISTING function (no lambda needed)
names = ['alice', 'bob', 'charlie']
upper_names = list(map(str.upper, names)) # Cleaner than [x.upper() for x in names]
print(upper_names)
# Output: ['ALICE', 'BOB', 'CHARLIE']
# When to use comprehension — when you need a lambda anyway
squared = [x**2 for x in range(10)] # Cleaner than list(map(lambda x: x**2, range(10)))
# Generator expression for memory efficiency with large data
total = sum(x**2 for x in range(1000000)) # No list created in memory
Interview Tip: "List comprehensions are more Pythonic than map/filter when you would need a lambda. Use map/filter when passing an existing function like str.upper. reduce is in functools in Python 3, but prefer sum/max/min for simple cases."
What NOT to Say: "reduce is a built-in function" — it was moved to functools in Python 3. Saying this suggests you are stuck in Python 2 thinking.