Tricky Python Questions That Confuse Beginners (With Answers & Explanations)
You’ve been learning Python for a few weeks. The syntax feels clean. Variables, loops, functions — you’ve got them down. Then one day you run a simple-looking script and the output makes absolutely no sense.
Sound familiar?
You’re not alone. Python is famous for being beginner-friendly, but underneath that friendly surface are some genuinely surprising behaviors that trip up even developers who’ve been coding for months. These aren’t bugs — they’re intentional design decisions that make Python powerful. But they look like magic (the bad kind) until someone explains why.
This guide covers the most tricky Python questions that confuse beginners, each with a real code example, the unexpected output, a clear explanation of what’s actually happening, and the mental model you need to never get confused again.
Whether you’re learning Python for the first time or brushing up before a technical interview, this is the article that fills the gaps your tutorial left behind.
Note: All examples in this article have been verified against Python 3.13, the current stable release as of 2026. Some behaviors mentioned (like integer caching and string interning) are specific to CPython, the standard Python interpreter.
Why Python Questions Confuse Beginners More Than Other Languages
Python Looks Simple, But Has Surprising Depth
Python’s greatest strength — readable, minimal syntax — can actually work against you as a beginner. Because Python code looks straightforward, you don’t expect surprises. When they come, they’re more jarring than in languages that are visually complex from the start.
Features like mutable default arguments, late binding in closures, and integer caching are not accidents or bugs. They’re deliberate design choices that make Python efficient and expressive. But they require a different mental model than most beginners bring to the language.
The Gap Between “What You Expect” and “What Python Does”
Most beginners come to Python from other programming contexts — a bit of JavaScript, some pseudocode, or a CS101 class that used Java. Those backgrounds create assumptions. Python breaks several of them.
The good news: once you understand why Python does what it does, these “surprises” become intuitive. And understanding them is exactly what Python interviews test. Check out this list of Python interview questions for beginners to see how many of these gotchas appear in real job interviews.
Let’s get into them.
Question 1 — The Mutable Default Argument Trap
This is the single most common Python gotcha. It shows up in interviews, it causes real production bugs, and it catches nearly every beginner at some point.
The Question
What is the output of this code?
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("apple"))
print(add_item("banana"))
print(add_item("cherry"))
Take a moment. What do you think prints?
Expected Output
['apple']
['banana']
['cherry']
Actual Output
['apple']
['apple', 'banana']
['apple', 'banana', 'cherry']
Why Beginners Get It Wrong
Most beginners assume that items=[] creates a fresh empty list every time the function is called without the items argument. That’s a perfectly logical assumption — and it’s wrong.
Python evaluates default argument values exactly once, at the time the function is defined. The [] is not a blueprint for “create a new list here.” It’s a single list object that gets created once and attached to the function.
Every call to add_item("banana") without a second argument uses that same list object. By the third call, that list has been accumulating items since the function was first defined.
You can actually prove this by checking the function’s default:
print(add_item.__defaults__)
# After three calls: (['apple', 'banana', 'cherry'],)
The default is baked into the function itself.
The Correct Mental Model
Think of the default argument as a sticky note attached to the function. It’s written once when the function is created. It’s not rewritten on each call — it’s reused. And if it’s a mutable object like a list or dictionary, changes to it persist.
To understand why Python stores it this way, it helps to understand how Python handles objects in memory. See how Python stores objects in memory for a deeper explanation.
The Fix
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['banana']
print(add_item("cherry")) # ['cherry']
Using None as the sentinel value and creating a new list inside the function guarantees a fresh list on every call.
Best Practice
Rule: Never use a mutable object (
list,dict,set) as a default argument value. Always useNoneand create the object inside the function.
Common Mistakes
- Using
items={}for dictionary defaults — same problem - Forgetting this rule when writing class methods
- Being surprised when this same pattern produces useful caching behavior intentionally (it’s a known technique, but requires conscious use)
Question 2 — == vs is: Value vs. Identity
This trips up beginners because both operators look like they’re checking “equality.” They’re not checking the same thing.
The Question
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b)
print(a is b)
Expected Output
Many beginners expect both to print True.
Actual Output
True
False
Why Beginners Get It Wrong
The word “is” in English means “equals.” So a is b feels like it should mean “a equals b.” In Python, it means something more specific: “a and b are the exact same object in memory.”
a and b here are two separate list objects. They were created independently. They happen to contain the same values, but they live at different memory addresses.
print(id(a)) # e.g., 140234567890
print(id(b)) # e.g., 140234567920 ← different!
== calls the object’s __eq__ method, which compares values. is compares object identity (memory addresses via id()).
The Integer Caching Twist
Here’s where it gets extra confusing:
x = 256
y = 256
print(x is y) # True
x = 257
y = 257
print(x is y) # False
CPython (the standard Python interpreter) caches small integers from -5 to 256. Two variables assigned the same small integer share the same object. For integers outside that range, Python creates new objects, so is returns False.
This is an implementation detail specific to CPython and should never be relied on in production code.
Correct Mental Model & Best Practice
Use == when you want to… | Use is when you want to… |
|---|---|
| Compare values | Check object identity |
| Compare lists, strings, dicts | Check if something is None |
| Most real-world code | Rarely — mainly for None checks |
# Correct
if value is None:
handle_missing()
# Incorrect (works, but violates PEP 8 and is fragile)
if value == None:
handle_missing()
Quick Tip: In almost every situation, use
==. Reserveisexclusively forNone,True, andFalsecomparisons (though evenTrue/Falsecomparisons are better written as direct truthiness checks).
Question 3 — Variable Scope and the LEGB Rule
This question produces one of Python’s most confusing error messages.
The Question
x = 10
def foo():
print(x)
x += 1
foo()
Expected Output
Most beginners expect: 10 (then x becomes 11 globally).
Actual Output
UnboundLocalError: local variable 'x' referenced before assignment
Why Beginners Get It Wrong
The line print(x) comes before x += 1. So beginners think: “Python reads the global x, prints it, then tries to add 1.” That’s not what happens.
Python analyzes the entire function body at compile time before executing it. The moment Python sees x += 1 anywhere in the function, it marks x as a local variable for the entire function — including lines before the assignment.
When print(x) runs, Python looks for a local variable named x. It finds that one is expected (because of the assignment below), but it hasn’t been assigned yet. Hence: UnboundLocalError.
The LEGB Rule Explained
Python resolves variable names using the LEGB rule — it searches scopes in this order:
| Letter | Scope | Example |
|---|---|---|
| L | Local | Inside the current function |
| E | Enclosing | Outer function (for nested functions) |
| G | Global | Module-level variables |
| B | Built-in | Python’s built-ins: print, len, range |
The moment you assign to a variable inside a function, Python treats it as Local for the entire function — even lines above that assignment.
The Fix
x = 10
def foo():
global x
print(x)
x += 1
foo()
# Output: 10
# x is now 11 globally
For nested functions, use nonlocal:
def outer():
count = 0
def inner():
nonlocal count
count += 1
inner()
print(count) # 1
outer()
Best Practice
Avoid global wherever possible. It makes code harder to understand and debug. Instead, pass values as parameters and use return statements.
# Better approach
def increment(x):
return x + 1
x = 10
x = increment(x)
print(x) # 11
Question 4 — List Copy vs. Assignment (The Reference Trap)
The Question
my_list = [1, 2, 3]
new_list = my_list
new_list.append(4)
print(my_list)
print(new_list)
Expected Output
[1, 2, 3]
[1, 2, 3, 4]
Actual Output
[1, 2, 3, 4]
[1, 2, 3, 4]
Why Beginners Get It Wrong
In Python, variables are labels (references) on objects, not containers that hold values. When you write new_list = my_list, you’re not copying the list — you’re creating a second label that points to the same list object.
Both my_list and new_list point to the same object in memory. Appending to one is appending to both, because they’re both “the same thing.”
print(id(my_list)) # e.g., 140234567890
print(id(new_list)) # same number!
This ties directly back to Python’s reference-based memory model — understanding it prevents dozens of frustrating bugs.
How to Actually Copy a List
# Shallow copy (new list, same element objects)
new_list = my_list.copy()
new_list = my_list[:] # slice syntax
new_list = list(my_list) # list constructor
# Deep copy (completely independent — needed for nested lists)
import copy
new_list = copy.deepcopy(my_list)
The Nested List Gotcha
This one is extra sneaky:
# Trying to create a 3x3 grid of zeros
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)
Expected: [[1,0,0],[0,0,0],[0,0,0]]
Actual: [[1,0,0],[1,0,0],[1,0,0]]
The * 3 multiplies references to the same inner list. All three rows are the same object. Change one, change all.
The Fix:
grid = [[0] * 3 for _ in range(3)] # Each row is a new list
grid[0][0] = 1
print(grid) # [[1,0,0],[0,0,0],[0,0,0]] ✓
Best Practice
Rule: If you need an independent copy of a list, always use
.copy(). For nested structures (lists of lists, lists of dicts), usecopy.deepcopy().
Question 5 — Truthy and Falsy Values
The Question
values = [0, "", None, [], False, 0.0, "0", [0]]
for v in values:
if v:
print(f"{v!r} → truthy")
else:
print(f"{v!r} → falsy")
Why Beginners Get It Wrong
Most beginners know that False and None are falsy. Few realize just how many other values Python treats as False in a boolean context. The most common surprises: 0, "", [], and {}.
Complete Output
0 → falsy
'' → falsy
None → falsy
[] → falsy
False → falsy
0.0 → falsy
'0' → truthy ← surprises many!
[0] → truthy ← non-empty list, even with a falsy element
Complete Falsy Values Reference
| Value | Type | Falsy? |
|---|---|---|
False | bool | ✅ Yes |
None | NoneType | ✅ Yes |
0 | int | ✅ Yes |
0.0 | float | ✅ Yes |
0j | complex | ✅ Yes |
"" | str | ✅ Yes |
[] | list | ✅ Yes |
{} | dict | ✅ Yes |
set() | set | ✅ Yes |
() | tuple | ✅ Yes |
"0" | str | ❌ No (non-empty string) |
[0] | list | ❌ No (non-empty list) |
The Pythonic Use of Truthiness
# Less Pythonic
if len(my_list) > 0:
process(my_list)
# More Pythonic
if my_list:
process(my_list)
# Checking for non-empty string
user_input = input("Enter your name: ")
if user_input:
print(f"Hello, {user_input}!")
else:
print("No name provided.")
Quick Tip
The string
"0"is truthy. It’s a non-empty string. Only""(empty string) is falsy. This surprises many beginners who check form inputs withif input_value:and wonder why"0"passes the check.
Question 6 — Lambda Functions and Late Binding
This is a classic Python gotcha that appears in callbacks, GUIs, and anywhere functions are created inside loops.
The Question
functions = [lambda x: x * i for i in range(5)]
print(functions[0](2)) # What does this print?
print(functions[3](2)) # And this?
Expected Output
0 # (2 * 0)
6 # (2 * 3)
Actual Output
8
8
Why Beginners Get It Wrong
This one genuinely surprises everyone the first time. It looks like each lambda should “remember” the value of i when it was created. The lambda for index 0 was created when i = 0, so surely functions[0](2) should return 2 * 0 = 0?
The problem is late binding. Closures in Python capture the variable name, not the variable value at the time of creation. By the time any of these functions are called, the loop has already finished running — and i is now 4 (the last value from range(5)).
Every lambda uses the current value of i when it’s called, not the value of i when it was defined.
# Proof:
i = 99
print(functions[0](2)) # 198 — they all use the current 'i'!
The Fix: Capture by Value with a Default Argument
functions = [lambda x, i=i: x * i for i in range(5)]
print(functions[0](2)) # 0 ✓
print(functions[3](2)) # 6 ✓
By using i=i as a default argument, you’re capturing the current value of i at the time the lambda is created. Default arguments are evaluated at definition time — exactly the behavior you want here.
Alternative: functools.partial
from functools import partial
from operator import mul
functions = [partial(mul, i) for i in range(5)]
print(functions[0](2)) # 0 ✓
print(functions[3](2)) # 6 ✓
Real-World Impact
Late binding causes bugs in:
- GUI button click handlers created in a loop
- Asynchronous callbacks
- Test parameterization
Understanding closures also unlocks Python decorators, which are built on the same concept of functions that remember their enclosing scope.
Question 7 — Exhausted Generators
The Question
def my_gen():
yield 1
yield 2
yield 3
g = my_gen()
print(list(g))
print(list(g))
Expected Output
[1, 2, 3]
[1, 2, 3]
Actual Output
[1, 2, 3]
[]
Why Beginners Get It Wrong
Generators look like collections. They produce values. You can convert them to a list. So beginners naturally assume that converting them a second time would produce the same list again.
But generators are not collections — they are iterators. Once a generator has produced all its values, it’s exhausted. There’s no “rewind.” Converting an exhausted generator to a list gives an empty list.
This is one of the fundamental differences between a generator and a list — generators compute values on demand (lazily) and each value can only be consumed once.
g = my_gen()
print(next(g)) # 1
print(next(g)) # 2
print(list(g)) # [3] — only the remaining value
print(list(g)) # [] — nothing left
How to Re-iterate
# Option 1: Call the generator function again
g = my_gen()
print(list(g)) # [1, 2, 3]
g = my_gen() # Fresh generator
print(list(g)) # [1, 2, 3]
# Option 2: Store as a list if you need multiple passes
data = list(my_gen())
print(data) # [1, 2, 3]
print(data) # [1, 2, 3] — lists can be iterated many times
Best Practice
If you need to iterate over a generator’s values more than once, convert it to a list immediately. If you only need one pass (which is often the case with large datasets), keep it as a generator for memory efficiency.
For a complete comparison, see the difference between generators and iterators in Python.
Question 8 — Floating Point Precision Trap
Short, sharp, and surprisingly controversial when developers first encounter it.
The Question
print(0.1 + 0.2 == 0.3)
Expected Output
True
Actual Output
False
Why This Happens
This is not a Python bug. It’s a fundamental property of how computers represent floating-point numbers in binary. 0.1 cannot be represented exactly in binary (just as 1/3 cannot be represented exactly in decimal). What Python actually stores is:
print(0.1 + 0.2)
# 0.30000000000000004
The representation error is tiny — but == requires exact equality, so it returns False.
This behavior exists in every programming language that uses IEEE 754 floating-point arithmetic: C, Java, JavaScript, C#, Ruby — all of them.
The Fix
import math
# Use math.isclose() for float comparisons
print(math.isclose(0.1 + 0.2, 0.3)) # True
# With explicit tolerance
print(abs(0.1 + 0.2 - 0.3) < 1e-9) # True
# For financial calculations, use Decimal
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2") == Decimal("0.3")) # True
Best Practice
Rule: Never use
==to compare floating point numbers. Usemath.isclose()for general purposes. Usedecimal.Decimalwhen exactness matters (money, tax calculations, accounting).
Question 9 — Class Variables vs. Instance Variables
Object-oriented Python has its own set of gotchas, and this is the most common one.
The Question
class Counter:
count = 0
a = Counter()
b = Counter()
a.count += 1
print(a.count)
print(b.count)
print(Counter.count)
Expected Output
Many beginners expect:
1
1
1
Actual Output
1
0
0
Why Beginners Get It Wrong
count = 0 looks like it should be a shared property of all instances. When you do a.count += 1, it looks like you’re modifying the shared counter.
Here’s what actually happens: a.count += 1 is equivalent to a.count = a.count + 1. Python reads a.count (finds the class variable 0, returns it), adds 1, then creates a new instance attribute count on a with value 1. It doesn’t modify the class variable at all.
b still has no instance attribute count, so it falls back to the class variable, which is still 0.
print(a.__dict__) # {'count': 1} ← instance attribute
print(b.__dict__) # {} ← no instance attribute
print(Counter.__dict__) # {'count': 0, ...} ← class variable unchanged
Correctly Modifying the Class Variable
Counter.count += 1 # Modifies the class variable directly
print(Counter.count) # 1
print(a.count) # 1 (unless a has its own instance attribute)
print(b.count) # 1
Best Practice
Use class variables for data that should truly be shared across all instances. Use __init__ to initialize per-instance data:
class Counter:
total_created = 0 # class variable: shared
def __init__(self):
Counter.total_created += 1
self.personal_count = 0 # instance variable: per-object
To understand the full OOP model, including how Python resolves attribute lookups, see how Python classes and instance variables work.
Question 10 — is not None vs. != None
Short, important, and directly tested in interviews and code reviews.
The Question
x = []
print(x == None)
print(x is None)
print(x != None)
print(x is not None)
Output
False
False
True
True
Everything works as expected here. So what’s the issue?
Why This Matters
PEP 8 — Python’s official style guide — explicitly states:
Comparisons to singletons like
Noneshould always be done withisoris not, never the equality operators.
Using == None is fragile because it can be overridden by a custom __eq__ method on a class:
class Weird:
def __eq__(self, other):
return True # "equals" everything
w = Weird()
print(w == None) # True (wrong! w is not None)
print(w is None) # False (correct)
is None cannot be faked — it compares object identity directly.
Best Practice
# Always write it this way:
if x is None:
...
if x is not None:
...
# Never write:
if x == None:
...
Question 11 — String Immutability and the += Misconception
The Question
s = "hello"
original_id = id(s)
s += " world"
print(s)
print(id(s) == original_id)
Expected Output
Many beginners expect True — they think += modifies the existing string.
Actual Output
hello world
False
Why This Happens
Strings in Python are immutable. You cannot change a string once it’s created. When you write s += " world", Python creates a brand new string object "hello world" and rebinds the variable s to point at it. The original "hello" string is untouched (and eventually garbage collected).
This contrasts with lists, which are mutable:
my_list = [1, 2, 3]
original_id = id(my_list)
my_list += [4]
print(id(my_list) == original_id) # True — same object, modified in place
Performance Warning
This immutability has a performance implication for string building in loops:
# Slow — creates a new string object on every iteration
result = ""
for word in words:
result += word # O(n²) total
# Fast — builds a list, joins once at the end
result = "".join(words) # O(n)
For large amounts of string concatenation, always use "".join().
Question 12 — The Walrus Operator := Scope Trap
The walrus operator (:=) was introduced in Python 3.8 and is now universally available in all supported Python versions. It’s increasingly appearing in code reviews, interviews, and modern Python codebases — so beginners need to understand its surprising scoping behavior.
The Question
data = [1, 5, 3, 8, 2]
result = [y for x in data if (y := x * 2) > 6]
print(result)
print(y) # What does this print?
Expected Output
Many beginners expect y to be inaccessible outside the comprehension (like loop variables inside comprehensions usually are from Python 3+), or they expect y to hold the last value that passed the filter.
Actual Output
[10, 16]
4 # ← y holds the LAST value assigned, not the last that passed the filter
Why This Surprises Beginners
Variables assigned with := inside a comprehension leak into the enclosing scope — unlike regular comprehension variables. And critically, y holds the last value it was assigned, which is 2 * 2 = 4 (from the last element 2), not 16 (the last element that passed the filter).
This is a subtle and important distinction.
When to Use the Walrus Operator
The walrus operator shines in a few specific patterns:
# Pattern 1: While loop with function call
while chunk := file.read(1024):
process(chunk)
# Pattern 2: Avoid calling expensive function twice
results = [processed for x in data if (processed := expensive(x)) > 0]
# Pattern 3: Regex match and use
import re
if match := re.search(r"\d+", "Value: 42"):
print(f"Found: {match.group()}") # Found: 42
Best Practice
Use
:=only when it genuinely eliminates a redundant call or simplifies a loop condition. If a plain two-line assignment would be equally clear, use that instead. Clarity beats brevity.
Question 13 — Exception Handling Order Matters
The Question
try:
x = int("abc")
except Exception as e:
print("Caught Exception")
except ValueError as e:
print("Caught ValueError")
What Happens?
“Caught Exception” prints — not “Caught ValueError.”
Why Beginners Get It Wrong
Beginners often assume Python picks the “most specific” matching exception, similar to how method overloading works in other languages. Python doesn’t do that. It checks handlers from top to bottom and stops at the first match.
ValueError is a subclass of Exception. So except Exception catches ValueError before the except ValueError handler is ever reached.
In fact, Python will warn you that the except ValueError block is unreachable in modern IDEs.
The Correct Pattern
try:
x = int("abc")
except ValueError:
print("Caught ValueError") # Most specific first
except TypeError:
print("Caught TypeError")
except Exception as e:
print(f"Caught generic exception: {e}") # Most general last
Rule: Order exception handlers from most specific to most general.
Catching Multiple Exceptions in One Handler
try:
result = 10 / int(input("Enter divisor: "))
except (ValueError, ZeroDivisionError) as e:
print(f"Invalid input: {e}")
Question 14 — Modifying a List While Iterating Over It
The Question
numbers = [1, 2, 3, 4, 5, 6]
for n in numbers:
if n % 2 == 0:
numbers.remove(n)
print(numbers)
Expected Output
[1, 3, 5]
Actual Output
[1, 3, 5, 6] # Wait — 6 is still there?
Why This Happens
When you remove an element from a list during iteration, the internal index moves forward but the list gets shorter. Elements shift left to fill the gap. The iterator skips elements because the index it tracks no longer lines up with the same positions.
Here’s what happens step by step:
- Index 0:
n = 1, odd, keep. Move to index 1. - Index 1:
n = 2, even, remove. List becomes[1, 3, 4, 5, 6]. Move to index 2. - Index 2:
n = 4(because 3 slid to position 1, we skipped it). Remove. List becomes[1, 3, 5, 6]. Move to index 3. - Index 3:
n = 6. Remove…
Actually with [1, 2, 3, 4, 5, 6], the output depends on exact removal sequence. The point is: the results are unpredictable and wrong. Never rely on this behavior.
The Safe Approaches
# Option 1: Iterate over a copy, modify the original
for n in numbers[:]:
if n % 2 == 0:
numbers.remove(n)
# Option 2: List comprehension (most Pythonic)
numbers = [n for n in numbers if n % 2 != 0]
print(numbers) # [1, 3, 5] ✓
# Option 3: filter()
numbers = list(filter(lambda n: n % 2 != 0, numbers))
Quick Tip: The list comprehension approach is almost always the cleanest. It creates a new list rather than mutating the original, which avoids the iteration problem entirely.
Question 15 — Single-Element Tuple (The Trailing Comma)
Short, surprising, and tested in interviews more often than you’d expect.
The Question
t1 = (5)
t2 = (5,)
t3 = 5,
print(type(t1))
print(type(t2))
print(type(t3))
Actual Output
<class 'int'>
<class 'tuple'>
<class 'tuple'>
Why Beginners Get It Wrong
Beginners learn that tuples use parentheses (). So (5) should be a tuple, right?
Wrong. Parentheses alone don’t create a tuple. The comma does.
(5) is just 5 wrapped in parentheses — it’s an integer. (5,) is a one-element tuple. The trailing comma is what makes Python treat it as a tuple.
This also means t3 = 5, (no parentheses at all!) creates a valid tuple:
x = 1, 2, 3
print(type(x)) # <class 'tuple'>
print(x) # (1, 2, 3)
Common Real-World Mistake
# Trying to return a single-item tuple from a function
def get_item():
return ("hello") # Returns a string, not a tuple!
def get_item_correctly():
return ("hello",) # Returns ('hello',)
Best Practice
When you need a single-element tuple, always use the trailing comma: (value,).
Debugging These Tricky Python Questions
Understanding why something happens is one thing. Knowing how to discover it in your own code is another. Here are the tools Python gives you:
| Situation | Tool | Example |
|---|---|---|
| What type is this? | type(x) | type([]) → <class 'list'> |
| Is this the same object? | id(x) | id(a) == id(b) |
| What’s in scope right now? | locals(), globals() | print(locals()) |
| What does this function compile to? | dis.dis() | import dis; dis.dis(foo) |
| What are this function’s defaults? | func.__defaults__ | print(add_item.__defaults__) |
| Is this mutable? | isinstance() | isinstance(x, (list, dict)) |
Python 3.13 also introduced colorized tracebacks by default, making error messages significantly easier to read in the terminal. The improved REPL also offers better autocompletion and multi-line editing — making it easier to test these concepts interactively.
When a confusing output has you stuck, the step-by-step process in how to debug Python code effectively is exactly the systematic approach you need.
Building the Right Mental Models for Python
Think in Objects and References, Not Values
In most beginner tutorials, a variable is described as “a box that holds a value.” In Python, that’s not quite right. Variables are labels. Objects are the things. Multiple labels can point to the same object. When you copy a label, you don’t copy the object.
Know Your Mutables from Your Immutables
| Mutable (can be changed) | Immutable (cannot be changed) |
|---|---|
list | int, float, bool |
dict | str |
set | tuple |
| User-defined classes (usually) | frozenset |
When you “modify” an immutable object, Python actually creates a new one. When you “modify” a mutable object, Python changes it in place — and every reference to that object sees the change.
Practice With Real Code
Reading about Python gotchas is useful. Getting them wrong in your own code is how they really stick. Work through Python coding questions for practice to encounter these patterns in context. Or start with something fun like a simple beginner Python project — small projects surface these behaviors naturally.
Key Takeaways
Here’s a summary of every mental model from this article:
- Mutable default arguments — Python evaluates defaults once at function definition. Use
Noneas the default, create the mutable object inside the function. ==vsis—==compares values;iscompares identity. Useisonly forNonecomparisons.- LEGB scope rule — Any assignment in a function makes that variable local for the entire function. Use
globalornonlocalto reach outer scopes (sparingly). - List assignment is not a copy —
new = oldcreates two labels on one object. Use.copy()for shallow copies,copy.deepcopy()for nested structures. - Falsy values —
0,"",[],{},None,False,0.0,set(),()are all falsy."0"and[0]are truthy. - Lambda late binding — Closures capture variable names, not values. Use
i=idefault argument trick to capture current value. - Generators are one-time — Once exhausted, they produce nothing. Call the generator function again or convert to a list.
- Float
==is unreliable — Usemath.isclose()ordecimal.Decimalinstead. - Class vs. instance variables —
a.count += 1creates an instance attribute; it does not modify the class variable. UseCounter.count += 1to modify the class variable. is None, not== None— PEP 8 requiresis NoneforNonecomparisons.- Strings are immutable —
s += " world"creates a new string object. Use"".join()for repeated string building. - Walrus operator scope — Variables assigned with
:=inside comprehensions leak into the enclosing scope and hold the last assigned value, not the last filtered value. - Exception handler order — Most specific exception handler first, most general last.
- Don’t mutate while iterating — Use a list comprehension or iterate over a copy.
- Tuples need commas —
(5)is an integer.(5,)is a tuple.
Frequently Asked Questions
What are the most confusing Python concepts for beginners?
The top confusion points are: mutable default arguments (function defaults that persist across calls), the == vs is distinction (value vs. identity comparison), Python’s LEGB scope rule (which makes UnboundLocalError appear mysteriously), and late binding in closures (lambdas capturing variable names rather than values at creation time).
Why does my Python list change when I didn’t touch it?
This is almost always the reference trap. When you write new_list = my_list, both variables point to the same list object in memory. Modifying through either variable affects the same object. Use my_list.copy() to create an independent shallow copy, or copy.deepcopy(my_list) for nested structures.
What is the LEGB rule in Python?
LEGB stands for Local, Enclosing, Global, and Built-in. It’s the order Python searches for a variable name: first inside the current function (Local), then in any enclosing functions (Enclosing), then at module level (Global), then in Python’s built-ins (Built-in). The key gotcha: any assignment inside a function makes Python treat that variable as Local for the entire function, even lines before the assignment.
What is the difference between == and is in Python?
== calls an object’s __eq__ method to compare values. is checks whether two variables reference the exact same object in memory (compares id() values). Two separate lists with identical contents will be == but not is. Use is only for comparing against None, True, and False.
Why do all my lambda functions in a loop return the same value?
This is the late binding problem. Lambda functions (and closures in general) capture variable names, not their values at creation time. By the time any lambda is called, the loop variable holds its final value. Fix it with a default argument: lambda x, i=i: x * i.
Are there tricky Python questions asked in job interviews?
Yes — mutable default arguments, is vs ==, the LEGB scope rule, generator exhaustion, list reference vs. copy, and floating point comparison all appear regularly. See Top 20 Python Interview Questions for Beginners for a full interview-focused breakdown.
What is Python integer caching?
CPython (the standard Python implementation) caches integers from -5 to 256. Two variables assigned the same integer in this range will share the same object, making is return True. Integers outside this range get separate objects, so is returns False. This is an implementation detail — never write production code that relies on it.
How do I fix IndentationError in Python?
IndentationError usually means you’ve mixed tabs and spaces, or your block indentation is inconsistent. Python requires consistent indentation within each block. For a complete walkthrough, see How to Fix IndentationError in Python.
Conclusion
Python’s surprising behaviors aren’t random. Each one is the logical consequence of a design decision — Python’s object model, lazy evaluation, scope rules, and mutability system all make sense once you see the bigger picture.
The tricky Python questions that confuse beginners all come down to a handful of mental models:
- Variables are labels, not boxes. Assignment creates a reference, not a copy.
- Defaults are evaluated once. Don’t use mutable objects as default arguments.
- Closures capture names. Lambda functions see the variable at call time, not creation time.
==compares values;iscompares identity. ForNone, always useis.- Generators are consumed. Once exhausted, they need to be recreated.
Now go break things — intentionally. Open a Python shell and test each of these behaviors yourself. Run the code. Change it. See what happens. That active experimentation is how these mental models move from “things I read” to “things I know.”
When you’re ready to stress-test your understanding further, work through 50 Python coding questions for practice — many of them will trigger exactly the behaviors you’ve learned about here.
Recommended External Resources
- Python Official Documentation — Data Model The authoritative reference for how Python handles objects, attributes, and the object lifecycle.
- PEP 8 — Style Guide for Python Code Python’s official style guide, including the rules on
is Nonevs== Noneand other best practices referenced in this article. - PEP 572 — Assignment Expressions (Walrus Operator) The original proposal for the walrus operator, including the scoping rationale and the full design debate.
- Python Documentation — Built-in Types Complete reference for truthy/falsy values, mutable vs. immutable types, and all built-in type behaviors.
- Python Documentation — Floating Point Arithmetic: Issues and Limitations A clear explanation of why
0.1 + 0.2 != 0.3and what to do about it, straight from the official docs. - Python Common Gotchas — The Hitchhiker’s Guide to Python A well-maintained reference for mutable default arguments and closure late binding, with practical fixes.
- What’s New in Python 3.13 Official changelog for Python 3.13, including colorized error messages, improved REPL, and experimental free-threading and JIT compiler features.
