Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~30 min· topic 1 of 10

Topic 17.1

Interview Deep Dive: Core Language Questions

In one line

The core-language questions that come up in almost every Python interview: mutability, is versus ==, default arguments, scope, copying, comprehensions and truthiness. For each one you get the precise answer and a program that proves it.

Think of it like this

Two keys to the same locker. If you and a friend each hold a key, you both open the same locker, so whatever one person puts inside, the other finds. A friend who copies the locker's contents into a new locker has a separate locker. In Python, a name is a key, an object is the locker, and most interview questions in this topic are really asking: how many keys are there, and how many lockers?

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Name
A label in your code, such as scores, that refers to an object. Assignment (=) attaches a name to an object.
Identity
Which exact object something is. is compares identity; id(x) shows it in CPython.
Mutable
Able to change in place after it is created, like a list.
Immutable
Unable to change after it is created, like a number, a string or a tuple.
Shallow copy
A new outer container whose contents are the same objects as the original's.
Deep copy
A copy of the container and, recursively, of everything inside it.
Falsy / truthy
Whether a value counts as false or true inside if and while.
LEGB
The order Python searches scopes: Local, Enclosing, Global, Built-in.

Step by step

01Names are keys, objects are lockers

Interviewers start here because every other answer builds on it. b = a does not copy a list. It attaches a second name to the same list. The list object records nothing about how many names point at it (apart from a reference count the interpreter keeps for itself).

list(a) is different: it builds a new list holding the same items. The first runnable example below prints a is b as True and a is c as False to prove it.

Names are keys, objects are lockersdiagram
Rendering diagram…

02is versus ==

Two separately built empty lists are equal (== is True) but are two objects (is is False). A reader who remembers only that is is faster than == will one day write if x is 256 and get a lucky True, then write the same with 257 and get a surprise. The rule never changes: **== for values, is for None, True, False and other deliberate singletons**.

x == None also works for normal objects, but a class can override __eq__ and answer True to anything. x is None cannot be fooled, and it is the form linters and reviewers expect.

main.pywhole filepython
first, second = [], []
print(first == second)   # True: equal values
print(first is second)   # False: two objects

03The default-argument trap, step by step

When Python runs def add_bad(item, bucket=[]), it builds one empty list right then and stores it on the function. Every call that omits bucket receives that same list. The first call appends 1 to it, the second call appends 2 to the same list, and both calls return the same object, which now holds [1, 2].

The first example's second half proves it by printing add_bad.__defaults__ after the calls. The fix uses None as a sentinel (a marker value that means "nothing was passed") and builds a fresh list inside the function.

04Why counter += 1 fails inside a function

Python compiles the whole function body before running any of it. Seeing counter += 1 (which includes an assignment), it marks counter as a local variable of that function. When the function runs, it reads the local counter first, finds it has no value yet, and raises UnboundLocalError.

Two fixes: declare global counter (rebind the module-level name) or, better, avoid shared mutable state by passing the value in and returning the new one. For a function nested in another function, nonlocal rebinds the enclosing variable, which is how a closure keeps private state.

05Shallow versus deep copy on nested data

nested = [[1, 2], [3]], then shallow = nested.copy(). The outer list is new, but shallow[0] and nested[0] are the same inner list. Appending to nested[0] changes what shallow shows. copy.deepcopy copies the inner lists too, so deep stays unchanged.

Deep copies cost time and memory, and fail or misbehave on objects that hold open files or locks. In real code, prefer building new immutable values (for example with dataclasses.replace) over copying large mutable trees.

06Truthiness: what counts as false

if items: is the idiomatic way to test for a non-empty list, and it works because an empty container is falsy. The risk is applying the same idiom to a value that can legitimately be zero. if not count: treats 0 and None the same; when they mean different things ("no data" versus "zero"), compare to None explicitly.

The second runnable example prints bool() of nine values, including "0" and [0], which both surprise people: a non-empty string is truthy whatever it contains, and a list holding a zero is still a non-empty list.

Try it yourself

  1. 1

    Predict, then run

    Before pressing Run on the first example, write down what you expect for lines 1, 3 and 6 of its output. Then run it. For every line you got wrong, say in one sentence which rule (names, shallow copy, immutability) explains the real output.

  2. 2

    Fix the shared default

    In the second example, call add_bad(3) a third time and predict the printed list before you run it. Then change the add_bad calls to add_good and see the shared state disappear. Finally, write a version whose default is the tuple () and which returns bucket + (item,): tuples are immutable, so a default tuple is safe. Decide which version you would approve in a code review.

Code & diagrams

Identity, mutation and copies New tab

Predict each line before you run it. Then change `nested[0].append(99)` to `nested[0] = [99]` and see why `shallow` no longer changes.

Sign in to run this example in your browser.

Expected output

a == c: True | a is c: False | a is b: True
b sees the change: [1, 2, 3, 4]
c does not: [1, 2, 3]
shallow: [[1, 2, 99], [3]]
deep:    [[1, 2], [3]]
TypeError: 'str' object does not support item assignment
tuple holding a list: (1, [2, 3, 4])
x is None: True | x == None: True
Defaults, scope and truthiness New tab

The comprehension variable `n` does not leak out. In Python 2 it did.

Sign in to run this example in your browser.

Expected output

[1, 2] [1, 2]
stored default: ([1, 2],)
[1] [2]
UnboundLocalError: cannot access local variable 'counter' where it is not associated with a value
nonlocal counter: 2
[False, False, False, False, False, False, True, True, True]
[0, 1, 4]
NameError: name 'n' is not defined
How a name, an object and a copy relatediagram
Rendering diagram…

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Rebind a global inside a function

Write a function that does counter += 1 on a module-level variable, without a global line, and call it.

main.pywhole filepython
counter = 0


def bump():
    counter += 1
    return counter


print(bump())
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 9, in <module>
print(bump())
~~~~^^
File "main.py", line 5, in bump
counter += 1
^^^^^^^
UnboundLocalError: cannot access local variable 'counter' where it is not associated with a value

Myth vs fact

Myth

is is just a faster ==.

Fact

They answer different questions. is is identity, == is equality of value. Using is on numbers or strings works only by accident of caching, which is an implementation detail of CPython.

Myth

Tuples are always safe because they are immutable.

Fact

A tuple cannot change which objects it holds, but a mutable object inside it can still change. Also, a tuple is only hashable (usable as a dict key, Topic 4.5) if everything inside it is hashable.

Myth

Python passes arguments by value (or by reference).

Fact

It passes the reference to the object, by value: the function's parameter becomes a new name for the caller's object. Mutating the object is visible to the caller. Rebinding the parameter name is not.

Myth

list.copy() makes an independent copy.

Fact

Only of the outer list. Anything mutable inside is still shared.

Interview problem

The problem

Review this code: the task list that remembers too much

A colleague's function adds a task to a list and returns it. In production, tasks from one user's request show up in another user's list. The code is: def add_task(task, tasks=[]): tasks.append(task); return tasks, called as add_task('a') and later add_task('b'). Explain the bug, fix it, and say how to prevent it in future.

You're given

  • Explain exactly why state leaks between calls.
  • Give a fix that keeps the same calling convention.
  • Say how you would stop this class of bug from reaching production.

The interviewer follows up

01

Is there a legitimate use of a mutable default?

02

Does the same trap exist for default values like datetime.now()?

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    CPython keeps a pool of small integers (from -5 to 256) and may intern some strings. The second example in Topic 17.2 shows 256 is 256 as True and 257 is 257 as False. This is an implementation detail and not part of the language guarantee, so no correct program may depend on it. Since Python 3.8, writing x is 5 or x is "abc" even prints a SyntaxWarning ("is" with a literal), because it is almost always a bug.

  • ▸

    dataclasses and typing make the default-argument trap harder to hit: a dataclass field with a mutable default is rejected at class-creation time, and you must write field(default_factory=list). Linters such as Ruff flag mutable defaults (rule B006 in the flake8-bugbear set).

  • ▸

    Truthiness is a protocol: bool(x) calls x.__bool__() if defined, otherwise len(x) != 0 if __len__ is defined, otherwise True. NumPy arrays raise ValueError for if array: when they hold more than one element, because the answer is ambiguous. Use .any() or .all().

Remember this

  1. 1

    Mutable and immutable. An object is mutable if it can change in place (list, dict, set, and most objects made from your own classes, Topic 8.1) and immutable if it cannot (int, float, str, tuple, frozenset, bytes). Assignment never copies: b = a makes a second name for the same object. The precise interview answer is that variables are names bound to objects, so b = a followed by a.append(4) shows up through b. Immutability is shallow: a tuple cannot change which objects it holds, but a list inside it can still change.

  2. 2

    **is versus ==.** == asks "do these two objects have equal values?" and calls the special method __eq__ (Topic 8.7). is asks "are these the very same object?" and compares identity (what id() returns) without calling any method. Use == for values and is only for singletons, objects of which there is only one, above all None. Never use is to compare numbers or strings. CPython (the standard program that runs Python code) happens to cache small integers and some strings, which makes is look right until it silently is not.

  3. 3

    Default arguments are evaluated once, when the def statement runs, not on each call. Recall from Topic 5.2: a default is the value a parameter gets when the caller leaves it out. A mutable default such as bucket=[] is therefore one shared list for the life of the function, stored in fn.__defaults__. The standard fix is bucket=None and if bucket is None: bucket = [] inside the body. The same rule explains why a default like time=now() is frozen at definition time.

  4. 4

    Scope follows LEGB (Topic 5.5). Scope means where a name can be seen. Python looks a name up in the Local scope, then any Enclosing function, then the Global (module) scope, then Built-ins. The compiler decides, before running, that a name assigned anywhere in a function is local to that whole function. That is why counter += 1 inside a function raises UnboundLocalError even though a global counter exists. global and nonlocal are the explicit opt-outs. Reading a global needs no keyword; rebinding it does.

  5. 5

    Copying has three levels. Assignment copies nothing. A shallow copy (list(a), a.copy(), a[:], copy.copy(a)) makes a new outer container but shares the inner objects. A deep copy (copy.deepcopy(a)) copies the whole tree and handles cycles (objects that refer back to each other). The classic trap is a list of lists: a shallow copy shares the rows, and [[0] * 3] * 2 repeats one row twice.

  6. 6

    Truthiness and comprehensions. These values are falsy: None, False, zero of any numeric type (0, 0.0, 0j), and empty containers and strings ("", [], (), {}, set(), range(0)). Everything else is truthy, including the string "0" and the list [0]. An object of your own class is truthy unless the class defines the special methods __bool__ or __len__ (Topic 8.7). A comprehension has its own scope, so its loop variable does not leak into the surrounding code.

Explain it without notes

01

What is the difference between is and ==? When is is the right operator?

02

Why is def f(x, items=[]) a bug, and what is the standard fix? When is a mutable default actually useful?

03

Explain shallow copy versus deep copy with an example where the difference changes the result.

04

Why does counter += 1 raise UnboundLocalError inside a function when counter exists at module level?

05

Python is sometimes called pass-by-value and sometimes pass-by-reference. What is the accurate description?

06

Why can a tuple be a dict key when a list cannot? Is every tuple usable as a key?

07

Is Python a compiled or an interpreted language?

Practice

01

Predict the output, then run it: a = [1, 2]; b = a; b += [3]; c = a; c = c + [4]; print(a, b, c). Explain why += and + behave differently on lists.

02

Write unique_in_order(items) that returns the items with duplicates removed, keeping first-seen order, without using a default mutable argument.

03

Write a function make_counter() that returns a function; each call to the returned function gives 1, 2, 3, and so on, with no global variable.

Trade-offs

  • ↔

    Deep copy versus sharing. Deep copies protect you from accidental sharing but cost time and memory. Sharing immutable data is free and safe; copy only when you must mutate.

  • ↔

    **Explicit is None versus truthiness.** if items: is short and idiomatic for containers. if value is None: is exact when 0 or empty is a valid answer. Pick the one that matches what you mean.

Done when you can

  • Done when you can explain names, objects and identity using the locker analogy.

  • Done when you can say, with proof, why add(x, bucket=[]) leaks state and write the fix.

  • Done when you can predict a shallow-versus-deep copy result on nested lists.

  • Done when you can explain UnboundLocalError and choose between global, nonlocal and passing values.

  • Done when you can list the falsy values and name two surprising truthy ones.