December Code

Python functions interview questions, with answers

Functions are where Python interview questions stop being about syntax. Defining one takes a line; the questions are about what the interpreter does with it — when a default value is created, which variable a name refers to, what a closure remembers, and what is really passed when you call it. Most of them are asked as a few lines of code and 'what does this print?', so every answer below comes with code you can run, and every output shown was produced by running it.

The questions follow the order interviewers tend to build them up in: arguments, then defaults, then scope, then closures and decorators. Then take the free Python diagnostic — ten questions across every Python topic in the bank — to see which of these you can explain but not yet predict.

The questions, with answers

  1. 1.What kinds of parameters can a Python function take?

    In short: Five kinds, in this order: positional-only before a /, ordinary positional-or-keyword, *args for extra positional values, keyword-only after the *, and **kwargs for extra keywords.

    A signature reads left to right. Parameters before a / can only be passed by position; ordinary parameters can be passed either way; *args gathers any extra positional arguments into a tuple; parameters after *args, or after a bare *, must be passed by keyword; and **kwargs gathers any extra keyword arguments into a dict. Any of them except *args and **kwargs can have a default. Interviewers ask this to see whether you can read a real signature. sorted is declared as sorted(iterable, /, *, key=None, reverse=False), and the / and * explain why sorted(xs, len) is a TypeError while sorted(xs, key=len) works. One subtlety in the example below: because it also has **kw, the call f(a=1, ...) does not complain about a keyword — a=1 is collected into kw — and the TypeError is for the missing positional a.

    def f(a, /, b, *args, c, **kw):
        return a, b, args, c, kw
    
    print(f(1, 2, 3, 4, c=5, d=6))
    # (1, 2, (3, 4), 5, {'d': 6})
    f(a=1, b=2, c=3)  # TypeError
  2. 2.Does Python pass arguments by value or by reference?

    In short: Neither, exactly: the parameter is bound to the same object the caller passed, so a function can change a mutable argument in place but cannot rebind the caller's variable.

    Calling grow(nums) makes the parameter lst refer to the same list object as nums; nothing is copied. What happens next depends on the operation. Mutating the object, as lst.append(4) does, is visible to the caller, because there is only one list. Assigning to the parameter, as lst = [0] does, only points the local name at a new object; the caller's variable still refers to the original. That is also why the same body behaves differently for an int: ints are immutable, so every change to one is a rebinding. The accepted names are call by object reference or call by sharing; answering 'by reference' alone invites the follow-up about rebinding.

    def grow(lst):
        lst.append(4)
        lst = [0]
    
    nums = [1, 2, 3]
    grow(nums)
    print(nums)  # [1, 2, 3, 4]
  3. 3.Why is a mutable default argument a bug, and how do you fix it?

    In short: A default is evaluated once, when def runs, so every call that omits the argument shares one object; default to None and create the object inside the function.

    def is an executable statement. When it runs, Python evaluates each default value once and stores the result on the function object, where f.__defaults__ shows it. An immutable default such as 0 or None is harmless, but a list, dict or set default is a single object shared by every call that leaves the argument out, so a change made in one call is still there in the next. The fix is a sentinel: default to None and create a fresh object inside the function when the argument is None, as below. The same rule explains a quieter bug: a default of datetime.now() is fixed when the def runs, usually at import, not at each call.

    def log(msg, seen=None):
        if seen is None:
            seen = []
        seen.append(msg)
        return seen
    
    print(log("a"), log("b"))
    # ['a'] ['b']
  4. 4.How does Python decide which variable a name refers to?

    In short: By the LEGB rule — local, enclosing function, global, built-in — with one twist: a name assigned anywhere in a function is local throughout that function.

    Reading a name searches four scopes in order: the current function's locals, the locals of any enclosing functions, the module's globals, and finally the built-ins such as len and print. The twist is that the compiler decides which names are local before the function runs. If a function assigns to a name anywhere in its body, even on its last line, that name is local for the whole function, and reading it before the assignment raises UnboundLocalError instead of falling back to the global, as below. The same rule explains why writing list = [1, 2] inside a function breaks every call to list() in that function: the built-in is shadowed.

    total = 0
    
    def add(x):
        print(total)
        total = x
    
    add(5)  # UnboundLocalError
  5. 5.What do the global and nonlocal statements do, and when do you need them?

    In short: They let a function reassign a name outside itself — global the module's variable, nonlocal the nearest enclosing function's — and reading either kind needs neither statement.

    Assignment inside a function creates a local variable by default, so to reassign an outer one you have to say which you mean. global x makes every use of x in the function refer to the module-level x, creating it if needed. nonlocal x refers to x in the nearest enclosing function; it cannot reach a module-level variable, and it is a SyntaxError when no enclosing function has an x. Mutating an outer object, such as appending to an outer list, needs neither, because it is not an assignment to the name. In practice nonlocal appears in closures that keep state, like the running average below, while global is usually a sign the state belongs in a class or a return value.

    def averager():
        total, n = 0, 0
        def add(x):
            nonlocal total, n
            total += x
            n += 1
            return total / n
        return add
    
    avg = averager()
    avg(10)
    print(avg(20))  # 15.0
  6. 6.What is a closure, and why do functions created in a loop all see the last value?

    In short: A closure is a function that keeps the variables of the scope it was defined in; it keeps the variable, not its value, so functions made in a loop read the final value.

    When an inner function uses a variable of an enclosing scope, Python keeps that variable alive in a cell after the enclosing function has returned; the averager above works that way, and f.__closure__ shows the cells. A cell holds the variable, not a snapshot, so its value is looked up when the inner function is called. Functions created in a loop therefore share one variable and all see whatever it held last: the classic late-binding question. Two fixes capture the value at creation. A default argument, as in lambda x, k=k: x * k, is evaluated when each lambda is made, and functools.partial binds the value explicitly.

    fs = []
    for k in (2, 10):
        fs.append(lambda x: x * k)
    print([f(3) for f in fs])
    # [30, 30]
    
    fixed = [lambda x, k=k: x * k
             for k in (2, 10)]
    print([f(3) for f in fixed])
    # [6, 30]
  7. 7.What is a decorator, and why should its wrapper use functools.wraps?

    In short: A decorator takes a function and returns a replacement for it; functools.wraps copies the original's name and docstring onto the wrapper so it still looks like the original.

    Writing @shout above def greet is shorthand for greet = shout(greet), run once when the def executes. A decorator usually defines a wrapper that accepts *args and **kwargs, does something before or after calling the original, and returns a result. Without functools.wraps the decorated function's __name__ becomes 'wrapper' and its docstring disappears, which confuses logging, debugging and any tool that reads them; with it, those attributes are copied across and the original stays reachable as __wrapped__. The standard library ships ready-made ones: functools.lru_cache memoises a function, which is top-down dynamic programming in one line. The usual follow-up is a decorator that takes arguments, such as @retry(3): that is a function returning a decorator, so it needs three levels of nested functions.

    import functools
    
    def shout(fn):
        @functools.wraps(fn)
        def wrapper(*a, **kw):
            out = fn(*a, **kw)
            return out.upper()
        return wrapper
    
    @shout
    def greet(name):
        return "hi " + name
    
    print(greet("asha"))  # HI ASHA
    print(greet.__name__)  # greet
  8. 8.When should you use a lambda instead of a def?

    In short: Use a lambda for a short, single-expression function passed straight to another function, such as a sort key; use def for anything reused, named, or longer than one expression.

    A lambda is an expression that creates a function whose body is a single expression, returned automatically. It cannot contain statements such as return, try, a loop or an ordinary assignment, and its __name__ is '<lambda>', which makes tracebacks less helpful. That makes it right for throwaway functions passed as arguments, such as the key for sorted, max or min, and wrong for anything reused. Assigning a lambda to a name, as in square = lambda x: x * x, gains nothing over def, and PEP 8 advises against it. Both kinds produce ordinary function objects that can be stored, passed and returned, which is what an interviewer means by functions being first-class.

    words = ["pear", "fig", "kiwi"]
    print(sorted(words, key=len))
    # ['fig', 'pear', 'kiwi']
    print(sorted(words,
          key=lambda w: w[-1]))
    # ['fig', 'kiwi', 'pear']

How the diagnostic asks it

One question from the Python bank, exactly as a sitting would show it. The bank has 5 on functions & scope and 30 across Python.

Functions & Scope · mediumPY-011

What does this code print?

def add(item, bucket=[]):
    bucket.append(item)
    return bucket

add(1)
print(add(2))
  1. 1[2]
  2. 2[1]
  3. 3[2, 1]
  4. 4[1, 2]correct

A default value is evaluated once, when the def statement runs, so every call that omits bucket shares the same list. The first call appends 1 to it and the second appends 2, so the second call returns [1, 2]. [2] assumes a fresh list per call, [1] ignores the second append, and [2, 1] reverses the order of the appends. The standard fix is bucket=None and bucket = [] inside the function when it is None.

Measure it

Reading answers tells you what’s true. A diagnostic tells you what you get wrong.

10 Python questions across its topics, easy to hard, about fifteen minutes. You get a readiness figure with the arithmetic shown, the topics you missed named, and a practice set sized for today. Free: 1 diagnostic a month and 15 problems a day. No card.

What the readiness test measures · how the score is computed

By Harshit · updated