Common Python Mistakes Every Beginner Makes (And How to Fix Them)
Python is famous for being beginner-friendly, and that reputation is well-earned. But 'easy to start' doesn't mean 'impossible to mess up.' In fact, Python's clean syntax can lull you into a false sense of security — you write code that looks perfectly sensible, hit run, and get results that are completely wrong with no error message to warn you. These silent bugs are the most dangerous kind, because you don't even know something went wrong.
Most Python mistakes aren't random. They cluster around a handful of concepts that trip up beginners and even intermediate developers: how Python handles mutable objects, how it compares values versus identities, how indentation creates scope, and how default arguments are evaluated. Each of these is a 'gotcha' built into the language's design — not bugs in Python, but features that behave differently from what newcomers expect.
By the end of this article you'll be able to look at a piece of Python code and immediately spot these hidden traps. You'll understand WHY each mistake happens — not just what the fix is — so you can reason about new code confidently. We'll use real runnable examples, show you the exact wrong output and then the corrected version, and finish with the interview questions recruiters love to ask about exactly this stuff.
The Mutable Default Argument Trap — Python's Most Surprising Bug
Here's one that catches almost everyone. When you define a function with a default argument like def add_item(item, cart=[]), you might think Python creates a fresh empty list every time the function is called without a second argument. It doesn't. Python creates that default list exactly once — when the function is defined — and reuses the same list object every single time.
Think of it like a sticky notepad on your desk. You write a reminder on it, tear off the note, but the impression is still on the next page. Each function call writes on the same pad. This is because in Python, default argument values are stored as part of the function object itself, not re-evaluated on each call.
This is one of the most common Python bugs in production code. The fix is simple but important: use None as the default value, then create a new list inside the function body if the caller didn't provide one. This ensures every call that needs a fresh list actually gets one.
# ============================================================ # WRONG VERSION — using a mutable list as a default argument # ============================================================ def add_to_cart_broken(item, cart=[]): # Python creates this [] ONCE when the function is defined # Every call that omits 'cart' shares the SAME list object cart.append(item) # We're modifying the one shared list return cart print("--- Broken version ---") first_order = add_to_cart_broken("apple") print(f"First order: {first_order}") # Looks fine so far second_order = add_to_cart_broken("banana") print(f"Second order: {second_order}") # Wait — why is apple here?! print(f"First order now: {first_order}") # Even first_order changed! # ============================================================ # CORRECT VERSION — use None as the sentinel, build inside # ============================================================ def add_to_cart(item, cart=None): # None signals "no cart was provided" — a safe, immutable default if cart is None: cart = [] # Create a BRAND NEW list for this call only cart.append(item) return cart print("\n--- Fixed version ---") first_order = add_to_cart("apple") print(f"First order: {first_order}") # ['apple'] second_order = add_to_cart("banana") print(f"Second order: {second_order}") # ['banana'] — clean! print(f"First order still: {first_order}") # ['apple'] — untouched
First order: ['apple']
Second order: ['apple', 'banana']
First order now: ['apple', 'banana']
--- Fixed version ---
First order: ['apple']
Second order: ['banana']
First order still: ['apple']
== vs is — Equality Versus Identity (They Are Not the Same Thing)
Imagine twins who look identical. If you ask 'do they look the same?' the answer is yes. But if you ask 'are they the same person?' the answer is no. That's exactly the difference between == and is in Python.
== checks if two values are equal — like asking 'do these two things look the same?'. is checks if two variables point to the exact same object in memory — 'are these two variables the same physical thing?'. For beginners, these feel interchangeable. They're not, and using is where you mean == creates silent bugs that are very hard to track down.
The most dangerous version of this mistake is checking if some_variable is 'hello' instead of if some_variable == 'hello'. For small integers (-5 to 256) and short strings, Python caches the objects — so is accidentally works. But for larger values or strings built at runtime, it breaks without warning. Always use == for value comparison. Reserve is exclusively for checking against None, True, and False.
# ============================================================ # Demonstrating == (equality) vs is (identity) # ============================================================ # --- Lists: same contents, different objects --- shopping_list_a = ["milk", "eggs", "bread"] shopping_list_b = ["milk", "eggs", "bread"] print("--- List comparison ---") print(shopping_list_a == shopping_list_b) # True — same CONTENTS print(shopping_list_a is shopping_list_b) # False — different OBJECTS in memory # --- Integers: Python's small integer cache can mislead you --- small_number_a = 42 small_number_b = 42 print("\n--- Small integer (cached by Python) ---") print(small_number_a == small_number_b) # True print(small_number_a is small_number_b) # True — only because Python CACHES -5 to 256 big_number_a = 10000 big_number_b = 10000 print("\n--- Large integer (NOT cached) ---") print(big_number_a == big_number_b) # True print(big_number_a is big_number_b) # False — now they ARE different objects! # --- The correct pattern: use 'is' ONLY for None checks --- user_input = None if user_input is None: # CORRECT — this is the right use of 'is' print("\nNo input received — please enter a value") if user_input == None: # WORKS but considered bad style in Python print("This also works, but PEP8 prefers 'is None'") # --- String comparison: never use 'is' for strings you build --- first_name = "Alice" search_term = "".join(["Al", "ice"]) # Built at runtime print("\n--- Runtime string comparison ---") print(first_name == search_term) # True — values match print(first_name is search_term) # False — different objects (usually)
True
False
--- Small integer (cached by Python) ---
True
True
--- Large integer (NOT cached) ---
True
False
No input received — please enter a value
This also works, but PEP8 prefers 'is None'
--- Runtime string comparison ---
True
False
Modifying a List While Iterating Over It — The Vanishing Items Bug
Picture a queue of people at a coffee shop. You're going through the line one by one, and as you skip someone you don't like, you push everyone behind them one step forward. Now the person who was second is in first position — and you've already moved past first position. You just skipped someone without realising it.
That's exactly what happens when you remove items from a Python list while looping over it with a for loop. Python tracks your position in the list by index. When you delete an element, every element after it shifts one position to the left. Python moves forward anyway — skipping the element that just slid into the deleted slot. Items silently disappear from your processing.
The clean fix is to iterate over a copy of the list using list[:] or list(original), or better yet, use a list comprehension to build a new filtered list. List comprehensions aren't just stylistically preferred — they're actually the safest, most readable solution to this exact problem.
# ============================================================ # WRONG — removing items from a list while looping over it # ============================================================ temperature_readings = [22, -5, 30, -1, 18, -8, 25] print(f"Original readings: {temperature_readings}") for reading in temperature_readings: if reading < 0: # Try to remove all negative (faulty) readings temperature_readings.remove(reading) # This shifts the list! # Notice -1 survived! It slid into the slot where -5 was removed, # and Python's iterator had already moved past that index. print(f"After (broken) cleanup: {temperature_readings}") print("Notice -1 was NOT removed — it slipped through!") # ============================================================ # CORRECT approach 1 — iterate over a COPY of the list # ============================================================ temperature_readings = [22, -5, 30, -1, 18, -8, 25] # Reset for reading in temperature_readings[:]: # [:] creates a shallow copy if reading < 0: temperature_readings.remove(reading) # Safe — we're iterating the copy print(f"\nAfter (copy) cleanup: {temperature_readings}") # ============================================================ # CORRECT approach 2 — list comprehension (cleanest, most Pythonic) # ============================================================ temperature_readings = [22, -5, 30, -1, 18, -8, 25] # Reset # Build a NEW list containing only valid readings valid_readings = [r for r in temperature_readings if r >= 0] print(f"Valid readings (list comprehension): {valid_readings}") # This is the preferred approach — readable, safe, and efficient
After (broken) cleanup: [22, 30, -1, 18, 25]
Notice -1 was NOT removed — it slipped through!
After (copy) cleanup: [22, 30, 18, 25]
Valid readings (list comprehension): [22, 30, 18, 25]
Misunderstanding How Python Copies Objects — Shallow vs Deep Copy
Imagine photocopying a folder. A shallow copy gives you a new folder with photocopies of the cover pages, but the pages inside still reference the originals — change a page inside, and both folders show the change. A deep copy photocopies every single page, so the two folders are completely independent.
In Python, when you assign one list to another variable with new_list = old_list, you haven't copied anything at all. You've given the same list a second name. Both variables point to the identical object. Change one and you change both.
Even new_list = old_list[:] only creates a shallow copy — which is fine for a flat list of numbers or strings, but fails for a list of lists. The nested inner lists are still shared. For true independence, you need copy.deepcopy() from Python's built-in copy module. Knowing when to use each is a sign of genuine Python understanding.
import copy # Python's built-in module for copying objects # ============================================================ # MISTAKE 1 — assignment is NOT a copy # ============================================================ original_scores = [95, 87, 76, 88] aliased_scores = original_scores # This is NOT a copy — same object! aliased_scores.append(100) # Modifying through the alias... print(f"Original (should be unchanged): {original_scores}") # Surprise — original changed too! # ============================================================ # SHALLOW COPY — fine for flat structures # ============================================================ original_scores = [95, 87, 76, 88] # Reset shallow_copy = original_scores[:] # Creates a new list... shallow_copy.append(100) # This only affects shallow_copy print(f"\nOriginal after shallow copy append: {original_scores}") print(f"Shallow copy: {shallow_copy}") # ============================================================ # WHERE SHALLOW COPY FAILS — nested lists # ============================================================ classroom_grades = [[90, 85, 88], [72, 78, 80], [95, 91, 93]] shallow_classroom = classroom_grades[:] # Shallow copy of outer list # Modifying a nested list through the copy... shallow_classroom[0].append(99) # This changes the SHARED inner list! print(f"\nOriginal classroom (should be unchanged):") print(classroom_grades) # The inner list changed — shallow copy problem! # ============================================================ # DEEP COPY — full independence at every level # ============================================================ classroom_grades = [[90, 85, 88], [72, 78, 80], [95, 91, 93]] # Reset deep_classroom = copy.deepcopy(classroom_grades) # Copies everything recursively deep_classroom[0].append(99) # Modifying the deep copy's inner list print(f"\nOriginal after deep copy modification:") print(classroom_grades) # Unchanged! print(f"Deep copy:") print(deep_classroom) # Only the copy changed
Original after shallow copy append: [95, 87, 76, 88]
Shallow copy: [95, 87, 76, 88, 100]
Original classroom (should be unchanged):
[[90, 85, 88, 99], [72, 78, 80], [95, 91, 93]]
Original after deep copy modification:
[[90, 85, 88], [72, 78, 80], [95, 91, 93]]
Deep copy:
[[90, 85, 88, 99], [72, 78, 80], [95, 91, 93]]
| Concept | What It Does | When to Use It | Common Mistake |
|---|---|---|---|
| == (equality) | Compares values of two objects | Comparing strings, numbers, lists by content | Using it to check None — works but use 'is None' instead |
| is (identity) | Checks if two variables point to the same object | Checking for None, True, False only | Using 'is' for string or number comparisons — silently unreliable |
| = (assignment) | Makes a second name for the same object — no copy | Intentional aliasing / references | Thinking it creates a copy — both names change together |
| list[:] shallow copy | New list, same inner objects shared | Flat lists of strings, numbers, tuples | Using it on nested lists — inner objects still shared |
| copy.deepcopy() | Fully independent copy at all levels | Nested mutable structures like lists of lists | Using it everywhere — slow and memory-heavy when not needed |
| Mutable default arg | Default list/dict created once at definition time | Never — always use None as default instead | Passing [] or {} as default — all calls share the same object |
🎯 Key Takeaways
- Mutable defaults (lists, dicts) in function signatures are created ONCE at definition time — use
Noneas your default and create the object inside the function body to avoid shared-state bugs. ==checks if two values are the same;ischecks if they're the same object in memory. Useisonly withNone,True, andFalse— never for comparing strings or numbers.- Removing elements from a list while iterating over it skips items silently — iterate over a copy (
my_list[:]) or use a list comprehension to build a filtered new list instead. - Assignment (
=) never copies an object — it just creates a second name pointing to the same thing. For real independence, use[:]for flat lists orcopy.deepcopy()for nested mutable structures.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using a mutable default argument (like
def fn(items=[])) — Every call that skips the argument adds to the SAME list, so data bleeds between calls. Fix: UseNoneas the default and create the list inside the function withif items is None: items = []. - ✕Mistake 2: Using
isto compare strings or numbers — For values like large integers or runtime-built strings,isreturnsFalseeven when the values are equal, because they're separate objects in memory. Fix: Always use==for value comparisons; reserveisstrictly forNone,True, andFalse. - ✕Mistake 3: Removing items from a list while iterating over it with a
forloop — Python tracks position by index; deleting an element shifts everything left, causing elements to be silently skipped. Fix: Iterate over a copy (for item in my_list[:]) or build a new list using a list comprehension (filtered = [x for x in my_list if condition]).
Interview Questions on This Topic
- QWhat is a mutable default argument in Python, and why is it considered dangerous? Can you show me a broken example and then fix it?
- QWhat's the difference between `==` and `is` in Python? Can you give me a case where they give different results for what looks like the same data?
- QIf I do `list_b = list_a` and then append to `list_b`, does `list_a` change? What about `list_b = list_a[:]`? What about a list of lists — is `[:]` still safe then?
Frequently Asked Questions
Why does Python allow mutable default arguments if they cause bugs?
It's not a bug in Python — it's a deliberate design choice. Default argument values are evaluated once when the function object is created, which is efficient and consistent. The language trusts you to know this rule. Once you do, you can actually use it intentionally to cache state between calls (though that's rare). The fix — using None — is a universally accepted Python convention.
Is it ever correct to use `is` for comparing strings in Python?
Almost never. Python interns (caches) some strings — particularly short ones that look like identifiers — so is may work for those, but it's undefined behaviour you can't rely on. String interning depends on the Python implementation and context. Always use == for string comparisons. The only time is is appropriate is when checking against the singletons None, True, and False.
What's the simplest way to know whether to use shallow or deep copy?
Ask yourself: does my data structure contain any nested mutable objects (like a list of lists, or a list of dicts)? If the answer is no — it's a flat list of numbers, strings, or tuples — [:] or .copy() is fine. If yes, reach for copy.deepcopy(). When in doubt, deepcopy is the safe choice, just be aware it's slower for large or deeply nested structures.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.