Skip to content
Preptima

Python fundamentals

The answers a Python interviewer works through before the hard questions start: the data model, scope and closures, the built-in structures, generators, the interpreter lock, memory, and the packaging and typing that dominate real work. Sixty items, sixteen worked through with code or a diagram.

60 questions

Go deeper on Python
Standard Sheet View

The data model

What does assignment actually do in Python?

It binds a name to an object; it never copies. b = a makes a second name pointing at the same object, so if that object is mutable, changing it through either name is visible through both. This is the single sentence that explains aliasing bugs, mutable defaults, and why passing a list into a function lets the function change your list. Rebinding is different from mutating: b = [1, 2] points b somewhere new and leaves a alone, while b.append(3) changes the object both names share.

Show me the mutable default argument trap.

The default is evaluated once, when the function is defined, not each time it is called. So a mutable default is shared by every call that does not supply one.

def add_item(item, basket=[]):        # the list is created ONCE, at def time
    basket.append(item)
    return basket

add_item("apple")     # ['apple']
add_item("pear")      # ['apple', 'pear']  - the same list, still there
add_item("fig", [])   # ['fig']            - supplying one avoids it

You can see the shared object directly, which is what turns this from a rule into an explanation:

print(add_item.__defaults__)   # (['apple', 'pear'],) - it lives on the function

The fix is to default to None and build the real default inside:

def add_item(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket

This is not a wart to memorise so much as a consequence of the first answer above: def is a statement that executes, the default expression is evaluated at that moment, and the result is stored on the function object. The same mechanism catches people with datetime.now() as a default, which freezes the timestamp at import time and produces a value that is quietly hours or days old.

What is the difference between `is` and `==`?

is compares identity — whether two names refer to the same object — while == compares value, by calling __eq__. Use is only for singletons, which in practice means None, True and False. It appears to work for small integers and short strings because CPython interns them, so x is 256 may be true and x is 257 false, which is a caching detail rather than a language rule. That inconsistency makes it worse than if it never worked, because the bug survives testing with small values.

What is truthiness, and where does it surprise people?

Any object can be used in a boolean context, and the rules come from __bool__ or, failing that, __len__. So empty containers, empty strings, zero, and None are all false. The surprise is that this collapses distinctions you may care about: if not count is true for both zero and None, and if not items cannot distinguish an empty list from a missing one. When absence and emptiness mean different things, test is None explicitly rather than relying on truthiness.

What is the difference between a shallow and a deep copy?

A shallow copy creates a new outer object whose elements are the same objects as the original's, so mutating a nested item is visible through both. A deep copy recursively copies everything, so the two are fully independent. list(x), x[:] and copy.copy(x) are all shallow. The practical rule is that a shallow copy is enough when the contents are immutable, and dangerous when they are not — a list of lists copied shallowly shares every inner list.

Why should you not use a mutable object as a dictionary key?

Because dictionary lookup uses the key's hash, and a mutable object's hash would have to change when it changes, which would make the entry unfindable. Python enforces this by making lists, dicts and sets unhashable, so the failure is a clear TypeError at insertion rather than a silent lookup miss later. Tuples are hashable only if everything inside them is, which is why a tuple containing a list cannot be a key either.

What does `__slots__` do?

It replaces an instance's per-object __dict__ with a fixed set of descriptors, so attributes are stored in a compact array rather than a dictionary. That saves a substantial amount of memory per instance and speeds up attribute access slightly, which matters when you have millions of small objects. The cost is that you can no longer add attributes not declared in the slots, and multiple inheritance with slots is awkward. It is an optimisation to apply after measurement, not a default.

Show me how mutability makes a function's contract ambiguous.

Passing a mutable object gives the callee the ability to change your data, and nothing in the signature tells you whether it will.

def normalise(tags):
    tags.sort()                    # mutates the caller's list
    return [t.strip().lower() for t in tags]

original = ["  Beta", "Alpha"]
cleaned = normalise(original)
print(original)   # ['  Beta', 'Alpha'] reordered - the caller did not ask for this

The caller wanted a cleaned copy and also got their own list reordered. Nothing raised, and the effect only shows up wherever original is used next — often in a different module.

Two conventions avoid it. Either do not mutate an argument at all and return a new object, which is the default expectation for anything named like a transformation:

def normalise(tags):
    return sorted(t.strip().lower() for t in tags)   # caller's list untouched

Or mutate deliberately, return None, and name the function so it is obvious — which is exactly the convention the standard library follows. sorted returns a new list; list.sort mutates and returns None. That returning of None is not an oversight, it is a signal: a function that returns nothing is telling you its effect was on the argument.