Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes
Python

Python's Undocumented Behaviors: When Your Code Runs but Doesn't Do What You Expect

Explore Python's most surprising undocumented behaviors—from mutable default arguments to integer caching and late-binding closures—and learn how to avoid these common pitfalls in your code.

July 2026 8 min read 2 views 0 hearts

Have you ever written a piece of Python code, run it, and watched it execute perfectly—only to realize later that it did something completely different from what you intended? You're not alone. Python, despite its reputation for readability and simplicity, has a bunch of quirky behaviors that can trip up even experienced developers.

Let me walk you through some of the most common ones that I've encountered while working with Python over the years, especially while writing tutorials and debugging code here at PythonSkillset. These aren't bugs—they're features that might surprise you if you're not paying attention.

The Mutable Default Argument Trap

This is probably the most famous undocumented behavior in Python. Check out this simple code:

def add_to_list(item, my_list=[]):
    my_list.append(item)
    return my_list

If you call add_to_list(1) and then add_to_list(2), you'd expect to get [1] and then [2], right? Nope. You get [1] and then [1, 2].

The reason? In Python, default arguments are evaluated only once when the function is defined, not each time you call the function. So that empty list you defined as a default argument is the same list every single time. It's like setting a coffee mug on your desk and expecting it to be clean every morning—except someone keeps adding coffee to it and never washing it.

A simple fix: use None as default and create a new list inside the function.

def add_to_list(item, my_list=None):
    if my_list is None:
        my_list = []
    my_list.append(item)
    return my_list

Why is Can Be Tricky with Integers

Many Python developers know that is checks object identity, not equality. But here's where it gets weird:

a = 256
b = 256
print(a is b)  # True

c = 257
d = 257
print(c is d)  # False

The Python interpreter caches small integers (typically -5 to 256) for performance reasons. So when you assign two variables to 256, they actually point to the same object in memory. But go beyond that range, and Python creates separate objects.

This behavior isn't documented in the official Python docs—it's an implementation detail that could change. The lesson? Always use == for value comparison unless you specifically need to check if two variables reference the exact same object.

The += Surprise with Tuples

Here's another one that I see confuse developers all the time, especially when they're learning about mutable vs immutable objects:

tuple_a = (1, 2, [3, 4])
tuple_a[2] += [5, 6]

What do you expect to happen? Actually, don't run it—Python will throw a TypeError saying 'tuple' object does not support item assignment. But wait, if you check tuple_a after the error, you'll see it's been modified to (1, 2, [3, 4, 5, 6]).

The mutation happens because += first tries to assign the result back to the tuple element (which fails), but the in-place addition on the list already occurred. This is a classic example where Python's exception handling doesn't roll back partial mutations.

Class Variables vs Instance Variables

This one trips up a lot of people coming from other languages:

class Dog:
    tricks = []  # Class variable

    def add_trick(self, trick):
        self.tricks.append(trick)

If you create two Dog objects and call add_trick on one, both dogs will have that trick. Because tricks is a class variable shared across instances. The fix is to define tricks inside __init__:

class Dog:
    def __init__(self):
        self.tricks = []  # Instance variable

The Late Binding Closure Catch

Here's one that I see in almost every Python interview test:

funcs = []
for i in range(3):
    funcs.append(lambda: i)

for f in funcs:
    print(f())

Most beginners expect 0 1 2, but they get 2 2 2. Python's closures capture variables by reference, not by value. By the time the lambdas execute, the loop has finished and i is 2 for all of them.

The fix is to bind the current value:

funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)  # Default argument captures current i

Why This Matters for PythonSkillset Readers

Understanding these behaviors isn't just about passing a technical interview—it's about writing code that does what you actually expect. At PythonSkillset, we've seen countless debugging sessions where developers spent hours chasing bugs that turned out to be one of these undocumented behaviors.

The best approach is to: - Always test your assumptions with small code snippets - Use type hints and static analysis tools like mypy - Read the Python documentation carefully (especially for functions and classes you use frequently) - Write unit tests that cover edge cases

The Python community has learned to work around these quirks over time, but they still catch new developers off guard. And sometimes, even experienced programmers forget about them.

So next time your Python code runs without errors but gives unexpected results, take a step back and think—could one of these undocumented behaviors be the culprit? Most of the time, it probably is.

Comments

Questions, corrections, and tips stay visible for everyone reading this page.

0 in thread

Join the discussion

Shown next to your comment.

Up to 4,000 characters

No comments yet

Be the first to leave a note — it helps the next reader.