Introduction
A bug that fails loudly is better than one that fails silently because at least you know there’s a problem!
The only thing worse than a hater is a person who pretends to love you, right? Well, let's apply the same logic to Python code. Imagine writing Python code and expecting it to do one thing, and it ends up doing something else. Now, that is a dangerous bug to have. Why is it dangerous? It is dangerous because this type of bug doesn’t crash the program but produces incorrect results, which can go unnoticed for a long time. At least when code doesn’t work, you know something is wrong and can fix it. This happens to everyone sometimes (even experienced developers sometimes). For example, have you ever tried to create a new list only to find that you're modifying the same list repeatedly instead? This unexpected behavior is because of "hidden pitfalls" lurking in the language. If one does not understand these hidden pitfalls, then they are bound to fall victim to these bugs.
A bug that fails loudly is better than one that fails silently because at least you know there’s a problem. In this article, we are going to explore some of the practices that can lead to unpredictable results (silent bugs) and how you can avoid them.
1. Stop Making Shallow Copies of Mutable Objects
Creating copies of objects is a very common practice when writing Python code. Usually, copies are made because you want to ensure that changes in the copy don't affect the original, and vice versa. However, this is not the case when you make a shallow copy of data structures like lists or dictionaries that contain mutable objects (nested lists, etc.) within them. Let's see the following example:
In this code, you can see that changes made to the shallow_copy's nested list are also reflected in the original list. So, if your intention is to create a copy of a list so that changes made to the copy do not affect the original, then do not create a shallow copy. This unpredictable behavior is because the shallow copy only creates a new reference for the top-level object, but nested mutable objects are still referenced by both the original and the copy. Changing the nested list through one reference (in shallow_copy) affects the other reference (in list_of_numbers).
How can you avoid this situation? Instead of a shallow copy, create a deep copy. A deep copy breaks all references to the original and creates a new object. This means that changes made to the nested objects in the original or copied object will not affect each other. See the example below:
In this example, now that we have created a deep copy, the changes that we have made to the deep copy have no effect on the original list.
So, if you are learning Python, knowing the difference between shallow copy and deep copy will ensure that you choose the appropriate method based on your specific requirements.
Tackle Data Analysis Projects with Confidence
Start your 2025 strong by acquiring the skills required to tackle data analysis projects with Python. Learn the important Python libraries used in data analysis. This book gives you the hands-on learning experience that you need to take your skills to the next level. Start your 50-day journey now to start 2025 strong. 50 Days of Data Analysis with Python.
2. Avoid Unintended Side Effects with Default Arguments
Another common pitfall in Python involves mutable default arguments in function definitions. In the example below, we have defined a function with a default argument, which is an empty list. This leads to unpredictable behavior.
Have you noticed what is happening here? Each subsequent call to the function without explicitly providing a my_list argument will reuse this same list object, leading to unexpected results. In this code, when we call the function for the first time without providing an argument for the my_list variable, the function appends 1 to my_list and returns my_list. The result1 variable now contains [1].
When we call the function the second time, it appends 2 to the same list (now [1, 2]) and returns it. When we call the function the third time, it appends 3 to the same list (now [1, 2, 3]) and returns it. So, each subsequent call to append_to_list modifies the same default list, which accumulates elements from previous calls This is unexpected behavior if you assume that each call to the append_to_list function starts with an empty list.
The reason for this behavior is that the default value for the my_list variable is created only once, at the time the function is defined. This default list is then used and modified every time the function is called without explicitly passing a value for my_list.
How can you avoid this behavior? To avoid this unexpected behavior, you can pass an immutable object as a default argument. For example, you can pass "None" as the default argument and only initialize the empty list inside the function when the default argument is None. This will ensure that each call to the function works with a distinct list, preventing the issue of shared references. See the modified code below:
Did you notice the difference? With this modification, each call to append_to_list starts with a new empty list. The unexpected accumulation of elements has been eliminated. When we call the function, we start with an empty list.
3. Stop Confusing the "is" Operator with the Equality Operator (==)
Let's say you have two objects and you want to know if they are equal. You may make the mistake of using the "is" operator. This may have unexpected results. See below:
The "is" operator here is returning that the two numbers are equal. This appears to be correct because both variables have a value of 5. Now let's see what happens when we change the data to a list data type:
Here, we are getting "The lists are not equal," even though the lists have the same values. This is inconsistent with the results in the previous example. You would have expected the lists to be equal, too.
The unexpected results are because of the behavior of the "is" operator. The "is" operator is not comparing if the two values are equal. The "is" operator checks for object identity. It determines whether both operands refer to the exact same object in memory. In the first example, we get "The numbers are equal" because the two integers are the same object in memory. This is because in Python, if you create two immutable objects with the same value (usually small values), they might actually point to the same object in memory. In the second example, we get "The lists are not equal," because the two lists (mutable objects) are not the same object in memory even though they have the same elements.
To avoid this behavior, use the equality operator (==) to check if objects are equal. Let's replace the "is" operator with the equality operator (==) in the two examples above and see what happens: First, we compare the two integers:
You can see here that using the equality (==) operator we get "The numbers are equal." Let's now use the operator with the two list objects:
You can see that using the equality operator produces consistent results. We avoid the silent bugs. So, use the equality operator (==) to compare if two objects have the same value. And use the "is" operator to check if two variables point to the exact same object in memory.
Final Thoughts
These are just some of the few mistakes that you can make when writing Python code. The key takeaway is that the more you write Python code, the more you will become aware of these pitfalls that are lurking in the language. By being aware of these pitfalls, you can write more reliable and predictable Python code. Thanks for reading.