DEV Community

Cover image for Python: while...else and Other Crimes Against Intuition
Jean-Luc Martel
Jean-Luc Martel

Posted on

Python: while...else and Other Crimes Against Intuition

We've already explored some of PHP's more questionable life choices and JavaScript's remarkable ability to make perfectly valid code look like a practical joke.

Now it's Python's turn.

Python is particularly interesting because it has become the language of choice for data science, machine learning, and AI worlds.

And understandably so.

It's readable. It's expressive. Its ecosystem is extraordinary. You can go from a CSV file to a predictive model in 5 lines of code.

But Python has a few surprises of its own.

And when you're working with data, some of those surprises can produce results that are plausible, reproducible, and completely wrong.

Which is arguably worse than code that simply crashes.

Here are five of my favorites.

1. One Change to a Matrix Changes Every Row

Let's start with something familiar to anyone who works with tabular data.

You need a 3×3 matrix initialized to zero.

Easy:

matrix = [[0] * 3] * 3

print(matrix)
Enter fullscreen mode Exit fullscreen mode

Result:

[[0, 0, 0],
 [0, 0, 0],
 [0, 0, 0]]
Enter fullscreen mode Exit fullscreen mode

Perfect.

Now let's change one value.

matrix[0][0] = 99

print(matrix)
Enter fullscreen mode Exit fullscreen mode

You'd expect:

[[99, 0, 0],
 [ 0, 0, 0],
 [ 0, 0, 0]]
Enter fullscreen mode Exit fullscreen mode

Instead:

[[99, 0, 0],
 [99, 0, 0],
 [99, 0, 0]]
Enter fullscreen mode Exit fullscreen mode

Wait. What?

You modified one cell, and an entire column changed.

The explanation is simple, but rather unpleasant.

When Python evaluates:

[[0] * 3] * 3
Enter fullscreen mode Exit fullscreen mode

It doesn't create three independent inner lists.

It creates one list and repeats the reference to that list three times.

All three rows are actually the same object.

You can prove it:

print(matrix[0] is matrix[1])  # True
Enter fullscreen mode Exit fullscreen mode

The fix:

matrix = [[0] * 3 for _ in range(3)]
Enter fullscreen mode Exit fullscreen mode

Now each row is independent.

To be clear, this is a native Python list problem, not how NumPy array construction works. But it's exactly the kind of mistake that can creep into preprocessing code, custom numerical algorithms, or quick exploratory scripts.

The lesson: Repeating a list doesn't necessarily create independent copies. Sometimes it just gives the same object several different places to hide.

2. Your Function Remembers Things You Never Asked It to Remember

Suppose you're collecting results from an experiment.

You write a little helper:

def record_result(result, history=[]):
    history.append(result)
    return history
Enter fullscreen mode Exit fullscreen mode

Looks perfectly reasonable.

Now you run two experiments:

print(record_result("Experiment A"))
print(record_result("Experiment B"))
Enter fullscreen mode Exit fullscreen mode

Output:

['Experiment A']
['Experiment A', 'Experiment B']
Enter fullscreen mode Exit fullscreen mode

Maybe that's what you wanted.

But what if the second experiment was supposed to have its own history?

Too bad.

Python has been maintaining a shared list behind your back.

The culprit is the default argument:

history=[]
Enter fullscreen mode Exit fullscreen mode

In Python, default argument values are evaluated when the function is defined, not every time the function is called.

That list is created once and reused across calls that omit the argument.

Which means your convenient little helper has accidentally acquired state.

In a data science workflow, that could mean results from one experiment leaking into another.

The solution is the familiar None pattern:

def record_result(result, history=None):
    if history is None:
        history = []

    history.append(result)
    return history
Enter fullscreen mode Exit fullscreen mode

Now every call without an explicit history gets a fresh list.

The lesson: Python's default arguments aren't necessarily defaults in the sense you expect. Mutable defaults can become accidental storage devices.

3. True, 1, and 1.0 Are the Same Dictionary Key

Here's an especially entertaining one for anyone working with categorical data.

Consider:

labels = {
    True: "boolean",
    1: "integer",
    1.0: "float"
}

print(labels)
Enter fullscreen mode Exit fullscreen mode

Three keys, three types.

You might reasonably expect three entries.

Instead:

{True: 'float'}
Enter fullscreen mode Exit fullscreen mode

One entry.

The last value wins.

Why?

Because Python considers all three keys equal:

True == 1      # True
1 == 1.0       # True
True == 1.0    # True
Enter fullscreen mode Exit fullscreen mode

And their hashes are equal, too.

Even more interesting:

isinstance(True, int)  # True
Enter fullscreen mode Exit fullscreen mode

Yes, bool is actually a subclass of int in Python.

So dictionaries treat these values as equivalent keys.

The first key object remains, but its value gets overwritten twice.

This can matter when you're mixing boolean indicators with numeric category identifiers.

If your categories need to distinguish between a boolean True and the integer 1, a plain dictionary won't do it automatically.

The lesson: In Python, equality and identity are different concepts, and dictionaries care about equality and hashing, not whether two keys have different types.

4. Python Has a while...else. Because Apparently if Wasn't Enough.

Most programmers are familiar with if...else.

Some languages have try...else.

Python went full "hold my beer" and decided that loops should have an else too.

Yes, really.

while False:
    print("This will never run")
else:
    print("But this will!")
Enter fullscreen mode Exit fullscreen mode

Output:

But this will!
Enter fullscreen mode Exit fullscreen mode

Okay, that's a little unusual, but not completely insane.

Now try this:

x = 0

while x < 3:
    print(x)
    x += 1
else:
    print("Loop finished!")
Enter fullscreen mode Exit fullscreen mode

Output:

0
1
2
Loop finished!
Enter fullscreen mode Exit fullscreen mode

Wait. What?

The while condition was "true", the loop executed, and somehow the else block still ran.

Welcome to Python.

Here's the secret: a loop's else clause doesn't mean what you'd normally expect from the word "else."

It actually means something closer to "the loop completed without encountering a break."

Consider:

x = 0

while x < 3:
    if x == 1:
        break
    x += 1
else:
    print("Finished!")
Enter fullscreen mode Exit fullscreen mode

This time, the else block doesn't execute.

Because the loop ended with break.

Which means the else runs when the loop terminates normally, including when it never runs at all, but not when a break interrupts it.

To be fair, there's a legitimate use case here. When searching for something in a loop, you can use break when you find it and else when the search finishes without finding anything.

But you have to admit that else is a rather creative choice of keyword.

Python's own documentation describes the behavior by comparing loop else clauses to the else in a try statement.

The lesson: In Python, else doesn't always mean "otherwise." Sometimes it means "congratulations, nobody broke out of the loop."

5. Python Objects Can Share a Secret You Never Intended Them to Share

Let's get into object-oriented programming.

Suppose you're tracking machine learning experiments. Each experiment has a name and a collection of metrics.

You write a simple class:

class Experiment:
    metrics = []

    def __init__(self, name):
        self.name = name

    def add_metric(self, metric):
        self.metrics.append(metric)
Enter fullscreen mode Exit fullscreen mode

Looks reasonable enough.

Now let's create two independent experiments.

experiment_a = Experiment("Model A")
experiment_b = Experiment("Model B")

experiment_a.add_metric(0.95)

print(experiment_a.metrics)
print(experiment_b.metrics)
Enter fullscreen mode Exit fullscreen mode

Output:

[0.95]
[0.95]
Enter fullscreen mode Exit fullscreen mode

Excuse me?

You added a metric to Model A, and Model B somehow knows about it.

The two experiments are completely separate instances.

Different names. Different objects.

But apparently, they have a shared memory.

Here's what's happening.

In Python, attributes declared directly inside a class body are class attributes, not instance attributes.

So this:

class Experiment:
    metrics = []
Enter fullscreen mode Exit fullscreen mode

Creates one list shared by every instance of Experiment, unless an instance overrides the attribute.

When Python evaluates:

self.metrics.append(metric)
Enter fullscreen mode Exit fullscreen mode

It looks for metrics on the instance.

Doesn't find it.

Looks on the class.

Finds the shared list.

And modifies it.

Now here's where things get even more interesting.

Suppose you do this:

experiment_a.metrics = [0.99]

print(experiment_a.metrics)  # [0.99]
print(experiment_b.metrics)  # [0.95]
Enter fullscreen mode Exit fullscreen mode

Suddenly, the experiments are independent!

Why?

Because assigning to experiment_a.metrics creates an instance attribute that shadows the class attribute.

But calling .append() on the original list modifies the shared class attribute.

So two operations that look like they're manipulating the same property can behave very differently.

The correct implementation is:

class Experiment:
    def __init__(self, name):
        self.name = name
        self.metrics = []

    def add_metric(self, metric):
        self.metrics.append(metric)
Enter fullscreen mode Exit fullscreen mode

Now every experiment gets its own metrics list.

And this isn't limited to experimental metrics. The same problem can appear in cached results, configuration objects, event handlers, and any class that accidentally shares mutable state between instances.

The lesson: In Python, where you declare an attribute matters. Put mutable per-instance state in __init__, unless you genuinely want every object to share it.

Because nothing inspires confidence in your machine learning results quite like discovering that two supposedly independent experiments have been sharing their data all along.

6. Python Throws an Exception. Your Data Changes Anyway.

I saved my favorite for last.

Because this one challenges a fairly reasonable assumption:

If an operation fails, surely it didn't complete?

Meet Python.

data = ([1, 2],)

try:
    data[0] += [3]
except TypeError:
    print("Operation failed!")

print(data)
Enter fullscreen mode Exit fullscreen mode

Output:

Operation failed!
([1, 2, 3],)
Enter fullscreen mode Exit fullscreen mode

Let's review what just happened.

Python threw an exception.

We caught it.

And the data changed anyway.

The operation failed successfully.

Here's why.

data is a tuple containing a list.

Tuples are immutable, meaning you can't replace their elements.

But the objects inside a tuple can still be mutable.

When Python executes:

data[0] += [3]
Enter fullscreen mode Exit fullscreen mode

Two things happen.

First, the list is modified in place. The value 3 is appended.

Then Python attempts to assign the result back into the tuple.

That second step fails because tuples don't support item assignment.

But the first step already happened.

And Python doesn't automatically roll it back.

So you get an exception AND modified data.

To avoid this particular problem, you can mutate the contained list explicitly when that's what you intend:

data[0].append(3)
Enter fullscreen mode Exit fullscreen mode

Or use immutable contents if you need the entire structure to remain unchanged.

The truly wonderful detail is that this behavior is documented in the official Python FAQ.

It's not a bug.

It's a consequence of Python's object model and augmented assignment semantics.

The lesson: Exceptions do not imply atomicity. A failed operation can leave behind side effects, and those side effects can matter enormously in data-processing pipelines.

Beautiful Is Better Than Ugly. Usually.

Python's famous Zen includes principles like:

  • Beautiful is better than ugly.
  • Explicit is better than implicit.
  • Simple is better than complex.

And Python generally does an admirable job of living up to them.

But as we've seen, simple syntax doesn't always imply simple behavior.

The most dangerous programming errors aren't necessarily the ones that crash.

They're the ones that quietly give you the wrong answer.

And in data science, that wrong answer might come with a beautiful chart and a very convincing confidence interval.


This is part of an ongoing series exploring the stranger corners of familiar programming languages. We've already visited PHP and JavaScript. C# and Perl are still waiting.

Perl, naturally, is preparing something involving regex.

Top comments (0)