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.
iscompares 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
ifandwhile. - 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.
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.
first, second = [], []
print(first == second) # True: equal values
print(first is second) # False: two objects03The 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
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
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 theadd_badcalls toadd_goodand see the shared state disappear. Finally, write a version whose default is the tuple()and which returnsbucket + (item,): tuples are immutable, so a default tuple is safe. Decide which version you would approve in a code review.
Code & diagrams
Predict each line before you run it. Then change `nested[0].append(99)` to `nested[0] = [99]` and see why `shallow` no longer changes.
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: TrueThe comprehension variable `n` does not leak out. In Python 2 it did.
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 definedBreak 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.
counter = 0
def bump():
counter += 1
return counter
print(bump())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
Is there a legitimate use of a mutable default?
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 256asTrueand257 is 257asFalse. This is an implementation detail and not part of the language guarantee, so no correct program may depend on it. Since Python 3.8, writingx is 5orx is "abc"even prints aSyntaxWarning("is" with a literal), because it is almost always a bug. - ▸
dataclassesandtypingmake the default-argument trap harder to hit: a dataclass field with a mutable default is rejected at class-creation time, and you must writefield(default_factory=list). Linters such as Ruff flag mutable defaults (rule B006 in the flake8-bugbear set). - ▸
Truthiness is a protocol:
bool(x)callsx.__bool__()if defined, otherwiselen(x) != 0if__len__is defined, otherwiseTrue. NumPy arrays raiseValueErrorforif array:when they hold more than one element, because the answer is ambiguous. Use.any()or.all().
Remember this
- 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 = amakes a second name for the same object. The precise interview answer is that variables are names bound to objects, sob = afollowed bya.append(4)shows up throughb. Immutability is shallow: a tuple cannot change which objects it holds, but a list inside it can still change. - 2
**
isversus==.**==asks "do these two objects have equal values?" and calls the special method__eq__(Topic 8.7).isasks "are these the very same object?" and compares identity (whatid()returns) without calling any method. Use==for values andisonly for singletons, objects of which there is only one, above allNone. Never useisto compare numbers or strings. CPython (the standard program that runs Python code) happens to cache small integers and some strings, which makesislook right until it silently is not. - 3
Default arguments are evaluated once, when the
defstatement 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 asbucket=[]is therefore one shared list for the life of the function, stored infn.__defaults__. The standard fix isbucket=Noneandif bucket is None: bucket = []inside the body. The same rule explains why a default liketime=now()is frozen at definition time. - 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 += 1inside a function raisesUnboundLocalErroreven though a globalcounterexists.globalandnonlocalare the explicit opt-outs. Reading a global needs no keyword; rebinding it does. - 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] * 2repeats one row twice. - 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
What is the difference between is and ==? When is is the right operator?
Why is def f(x, items=[]) a bug, and what is the standard fix? When is a mutable default actually useful?
Explain shallow copy versus deep copy with an example where the difference changes the result.
Why does counter += 1 raise UnboundLocalError inside a function when counter exists at module level?
Python is sometimes called pass-by-value and sometimes pass-by-reference. What is the accurate description?
Why can a tuple be a dict key when a list cannot? Is every tuple usable as a key?
Is Python a compiled or an interpreted language?
Practice
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.
Write unique_in_order(items) that returns the items with duplicates removed, keeping first-seen order, without using a default mutable argument.
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 Noneversus truthiness.**if items:is short and idiomatic for containers.if value is None:is exact when0or 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
UnboundLocalErrorand choose betweenglobal,nonlocaland passing values.Done when you can list the falsy values and name two surprising truthy ones.