Tricky Python questions explained visually

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 use None and 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 valuesCheck object identity
Compare lists, strings, dictsCheck if something is None
Most real-world codeRarely — 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 ==. Reserve is exclusively for None, True, and False comparisons (though even True/False comparisons 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:

LetterScopeExample
LLocalInside the current function
EEnclosingOuter function (for nested functions)
GGlobalModule-level variables
BBuilt-inPython’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), use copy.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

ValueTypeFalsy?
Falsebool✅ Yes
NoneNoneType✅ Yes
0int✅ Yes
0.0float✅ Yes
0jcomplex✅ 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 with if 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. Use math.isclose() for general purposes. Use decimal.Decimal when 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 None should always be done with is or is 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:

  1. Index 0: n = 1, odd, keep. Move to index 1.
  2. Index 1: n = 2, even, remove. List becomes [1, 3, 4, 5, 6]. Move to index 2.
  3. Index 2: n = 4 (because 3 slid to position 1, we skipped it). Remove. List becomes [1, 3, 5, 6]. Move to index 3.
  4. 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:

SituationToolExample
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)
listint, float, bool
dictstr
settuple
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:

  1. Mutable default arguments — Python evaluates defaults once at function definition. Use None as the default, create the mutable object inside the function.
  2. == vs is== compares values; is compares identity. Use is only for None comparisons.
  3. LEGB scope rule — Any assignment in a function makes that variable local for the entire function. Use global or nonlocal to reach outer scopes (sparingly).
  4. List assignment is not a copynew = old creates two labels on one object. Use .copy() for shallow copies, copy.deepcopy() for nested structures.
  5. Falsy values0, "", [], {}, None, False, 0.0, set(), () are all falsy. "0" and [0] are truthy.
  6. Lambda late binding — Closures capture variable names, not values. Use i=i default argument trick to capture current value.
  7. Generators are one-time — Once exhausted, they produce nothing. Call the generator function again or convert to a list.
  8. Float == is unreliable — Use math.isclose() or decimal.Decimal instead.
  9. Class vs. instance variablesa.count += 1 creates an instance attribute; it does not modify the class variable. Use Counter.count += 1 to modify the class variable.
  10. is None, not == None — PEP 8 requires is None for None comparisons.
  11. Strings are immutables += " world" creates a new string object. Use "".join() for repeated string building.
  12. Walrus operator scope — Variables assigned with := inside comprehensions leak into the enclosing scope and hold the last assigned value, not the last filtered value.
  13. Exception handler order — Most specific exception handler first, most general last.
  14. Don’t mutate while iterating — Use a list comprehension or iterate over a copy.
  15. 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; is compares identity. For None, always use is.
  • 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

  1. Python Official Documentation — Data Model The authoritative reference for how Python handles objects, attributes, and the object lifecycle.
  2. PEP 8 — Style Guide for Python Code Python’s official style guide, including the rules on is None vs == None and other best practices referenced in this article.
  3. PEP 572 — Assignment Expressions (Walrus Operator) The original proposal for the walrus operator, including the scoping rationale and the full design debate.
  4. Python Documentation — Built-in Types Complete reference for truthy/falsy values, mutable vs. immutable types, and all built-in type behaviors.
  5. Python Documentation — Floating Point Arithmetic: Issues and Limitations A clear explanation of why 0.1 + 0.2 != 0.3 and what to do about it, straight from the official docs.
  6. Python Common Gotchas — The Hitchhiker’s Guide to Python A well-maintained reference for mutable default arguments and closure late binding, with practical fixes.
  7. 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *