Skip to Content
Course content

454: Common Python Interview Questions and Gotchas

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've spent any time in technical interviews, you know there's a specific category of Python questions designed not to see if you can code, but to see if you've been "burned" by the language's quirks. I call these "gotchas." They aren't usually about complex algorithms; they're about the subtle ways Python handles memory and scope that defy your intuition.

To walk through these, let's build a simple TaskRegistry. The goal is a system where we can register tasks and create small "handler" functions to execute them. It sounds trivial, but it's the perfect playground for the three most common interview traps.

Setting up our Task Manager with a hidden bug

I'll start by writing a function to add a task to a list. I want it to be flexible, so if the user doesn't provide a list, I'll just start a new one. It feels natural to do this:

def add_task(task_name, task_list=[]):
    task_list.append(task_name)
    return task_list

# First call
print(add_task("Email Client")) # Expected: ['Email Client']
# Second call
print(add_task("Database Backup")) # Expected: ['Database Backup']

When I run this, I get ['Email Client'] followed by ['Email Client', 'Database Backup']. Wait, what? I called the function twice with no list provided, so I expected two separate lists. This is the most famous Python interview question: Mutable Default Arguments.

In Python, default arguments are evaluated once at definition time, not every time the function is called. That [] isn't a blueprint for a new list; it's a single list object created when the script starts. Every call to add_task that relies on the default is sharing that exact same list in memory. It's a memory leak waiting to happen in a production app.

Cleaning up the shared state

The professional way to handle this—and the answer the interviewer is looking for—is to use None as a sentinel value. I'll rewrite it to ensure a fresh list is created during the function's execution, not at its definition.

def add_task(task_name, task_list=None):
    if task_list is None:
        task_list = []
    task_list.append(task_name)
    return task_list

# Now it behaves:
print(add_task("Email Client"))   # ['Email Client']
print(add_task("Database Backup")) # ['Database Backup']

By moving the initialization inside the function body, we ensure that task_list = [] runs every time the function is invoked without an argument.

The late-binding trap in loop-generated functions

Now, let's say we want to create a set of "trigger" functions for our tasks. I want a list of functions where each function, when called, tells me which task ID it's associated with. I'll use a loop and a lambda to keep it concise.

task_ids = [101, 102, 103]
triggers = []

for tid in task_ids:
    triggers.append(lambda: f"Executing task {tid}")

# Let's test them
for t in triggers:
    print(t())

If you're guessing that this prints 101, 102, 103, you're falling for the same trap I did years ago. It actually prints "Executing task 103" three times. This is Late Binding.

The lambda doesn't capture the value of tid at the moment it's created; it captures the variable tid. By the time we actually call the functions, the loop has finished, and tid is left sitting at 103. Every single lambda is looking at the same variable in the outer scope.

Pinning the value to the local scope

To fix this, we need to force Python to bind the value at definition time. We can do this by passing tid as a default argument to the lambda. This feels a bit hacky, but it's the idiomatic way to solve the problem because default arguments are evaluated when the function is defined.

task_ids = [101, 102, 103]
triggers = []

for tid in task_ids:
    # By setting tid=tid, we create a local variable for the lambda
    triggers.append(lambda tid=tid: f"Executing task {tid}")

for t in triggers:
    print(t()) # Now it correctly prints 101, 102, 103

One last tip: if an interviewer asks you about is vs ==, just remember that == checks for equality (do they have the same value?), while is checks for identity (are they literally the same object in memory?). For integers, Python caches small numbers (usually -5 to 256), so a = 10; b = 10; a is b might be True, but a = 1000; b = 1000; a is b will be False. Never use is for value comparison.




📋 Practical Task

Refactoring a Glitchy Event Dispatcher

You've been handed a piece of legacy code for an Event Dispatcher. It has two major bugs: it's accidentally sharing a history log across different dispatcher instances, and its event handlers are all returning the value of the very last event registered.

Your Task: Fix the following code so that each Dispatcher has its own unique history and each handler returns its correct event name.

class EventDispatcher:
    def __init__(self, name, history=[]):
        self.name = name
        self.history = history

    def register_events(self, events):
        handlers = []
        for e in events:
            # BUG: Late binding issue here
            handlers.append(lambda: f"Event {e} triggered in {self.name}")
        return handlers

# Test Case
disp1 = EventDispatcher("AuthSystem")
disp2 = EventDispatcher("PaymentSystem")

disp1.register_events(["Login"])
disp2.register_events(["PaymentReceived"])

# This should print only ['PaymentReceived'], but currently prints ['Login', 'PaymentReceived']
print(f"Dispatcher 2 History: {disp2.history}") 

# These should print "Event Login..." and "Event PaymentReceived..." 
# but currently both print the last event.
h1 = disp1.register_events(["Login"])[0]
h2 = disp2.register_events(["PaymentReceived"])[0]
print(h1())
print(h2())
Rating
0 0

There are no comments for now.

to be the first to leave a comment.