Ch. 08

Python

Python interviews in one place: data types and mutability, comprehensions, generators, decorators, OOP, the GIL, async and the standard library.

20 interview questions0 quiz questions0 notes
your progress0%

Top 20 Python interview questions most asked first

  1. 1.What is the difference between a list and a tuple in Python? When would you use each?easy

    Both are ordered sequences that support indexing, slicing and iteration, and both can hold mixed types. The core difference is mutability:

    • A list is mutable: you can append, remove and sort it in place. It's a dynamic array that over-allocates, so append is amortized O(1).
    • A tuple is immutable once created. Because of that it's hashable (as long as all its elements are hashable), so it can be a dict key or a set member, and it's slightly smaller and cheaper to create.

    There's also a convention: a list is a variable-length collection of similar things ([user1, user2, ...]), while a tuple is a fixed-size record where position has meaning ((lat, lon)). That's why functions return multiple values as tuples.

    One gotcha: tuple immutability is shallow. In t = ([1], 2) you can't reassign t[0], but t[0].append(3) works, and hash(t) raises TypeError because the list inside is unhashable.

    What interviewers listen for
    • List is mutable; tuple is immutable
    • Tuples are hashable if all their elements are
    • Tuples can be dict keys and set members
    • List for collections, tuple for fixed-position records
    • Tuple immutability is shallow

    Likely follow-up: How do you create a one-element tuple? · Why is ([1], 2) not usable as a dict key?

  2. 2.What are mutable and immutable types in Python? Give examples and explain why the distinction matters.easy

    A mutable object can be changed in place after it's created; an immutable one can't, so every "change" produces a new object.

    • Immutable: int, float, complex, bool, str, bytes, tuple, frozenset, None
    • Mutable: list, dict, set, bytearray, and most instances of user-defined classes

    It matters because Python variables are just names bound to objects. If two names refer to the same list, a mutation through one is visible through the other. With immutable types that can't happen: s += "!" on a string builds a new string and rebinds s, while lst += [1] extends the existing list in place, so every alias sees it.

    Immutability is also what makes built-in objects safely hashable, which is why dict keys and set members are things like strings, numbers and tuples, never lists. And it's the root cause of the mutable-default-argument bug and of functions that surprise callers by mutating their arguments.

    What interviewers listen for
    • Mutable objects change in place; immutable ones create new objects
    • Immutable: int, float, str, bytes, tuple, frozenset
    • Mutable: list, dict, set, bytearray, most instances
    • Aliased names all see a mutation
    • Hashability (dict keys, sets) relies on immutability

    Likely follow-up: What does += do differently for a list and a string? · Can a tuple ever change?

  3. 3.What is a decorator in Python? Write a simple one and explain why functools.wraps is used.mid

    A decorator is a callable that takes a function and returns a replacement, usually a wrapper that adds behavior before or after the original call. The @timed syntax is just sugar for slow = timed(slow), applied once when the function is defined.

    The wrapper accepts *args, **kwargs so it works with any signature, calls the original, and returns its result. Forgetting that return is a classic bug: the decorated function silently returns None.

    functools.wraps copies the original's metadata (__name__, __qualname__, __doc__, __module__ and a few more), merges its __dict__, and sets __wrapped__ pointing to the original. Without it every decorated function reports its name as wrapper, which hurts logging, debugging, generated docs and any tool that introspects functions.

    Common uses: logging, timing, caching (functools.lru_cache), retries, authorization checks and route registration in web frameworks. Stacked decorators apply bottom-up: the one closest to the def wraps first.

    import functools, time
    
    def timed(func):
        @functools.wraps(func)            # copy __name__, __doc__ etc., set __wrapped__
        def wrapper(*args, **kwargs):
            start = time.perf_counter()
            result = func(*args, **kwargs)
            print(f"{func.__name__}: {time.perf_counter() - start:.4f}s")
            return result
        return wrapper
    
    @timed                                # same as: slow = timed(slow)
    def slow(n):
        return sum(range(n))
    What interviewers listen for
    • Takes a function, returns a replacement callable
    • @dec is sugar for f = dec(f) at definition time
    • Wrapper uses *args, **kwargs and returns the result
    • functools.wraps preserves name, docstring, sets __wrapped__
    • Stacked decorators apply bottom-up

    Likely follow-up: How would you write a decorator that takes arguments? · How do you decorate a method, or a class?

  4. 4.What is a generator, and how does yield work? Why would you use one instead of returning a list?mid

    A generator function is any function that contains yield. Calling it doesn't run the body; it returns a generator object, which is an iterator. Each next() resumes execution until the next yield, hands that value out, and freezes the frame (local variables and position) until the next request. When the function returns, the generator raises StopIteration, which for loops handle for you.

    Why use one:

    • Lazy evaluation: values are produced on demand, so memory stays flat even for huge or infinite sequences, like streaming lines from a large file.
    • Pipelines: chain generators so each item flows through every stage one at a time.
    • Simpler iterators: no class with __iter__ and __next__ needed.

    Trade-offs: a generator is single-pass (once exhausted it stays empty), with no len() and no indexing. Generators also support send(), throw() and close(), the machinery Python's coroutines were originally built on.

    def countdown(n):
        print("starting")
        while n > 0:
            yield n            # pause here and hand n to the caller
            n -= 1
    
    gen = countdown(3)         # nothing printed yet: the body hasn't started
    print(next(gen))           # prints "starting", then 3
    print(list(gen))           # [2, 1]
    print(next(gen, "done"))   # done (the generator is exhausted)
    What interviewers listen for
    • Calling a generator function returns an iterator, runs nothing
    • yield produces a value and suspends the frame
    • Lazy: constant memory, works for infinite streams
    • Raises StopIteration when the function returns
    • Single-pass: exhausted generators stay empty

    Likely follow-up: What does yield from do? · How is a generator different from a generator expression?

  5. 5.What is wrong with using a mutable object like [] as a default argument, and how do you fix it?easy

    Default values are evaluated once, when the def statement executes, not on every call. The default list is stored on the function object (you can inspect it in add_item.__defaults__), so every call that omits bucket mutates that same list and state leaks from one call to the next.

    The standard fix is a None sentinel: default to None and create a fresh object inside the function. If None is a meaningful value for callers, use a private sentinel such as _MISSING = object() and compare with is.

    The same applies to any mutable default: dicts, sets, or class instances. Immutable defaults like 0, "" or () are safe because they can't be modified.

    Tooling helps here: linters such as Pylint (and Ruff with its bugbear rules) warn about mutable defaults, and dataclasses refuses a bare [] field default with a ValueError, forcing you to write field(default_factory=list).

    def add_item(item, bucket=[]):     # [] is created once, when def runs
        bucket.append(item)
        return bucket
    
    print(add_item(1))   # [1]
    print(add_item(2))   # [1, 2]  <- the same list again!
    
    def add_item(item, bucket=None):   # the fix: a None sentinel
        if bucket is None:
            bucket = []                # fresh list on every call
        bucket.append(item)
        return bucket
    What interviewers listen for
    • Defaults are evaluated once, at function definition
    • The same object is shared across calls
    • Fix: default to None, create inside the function
    • Use a private object() sentinel if None is valid
    • Dataclasses require field(default_factory=list)

    Likely follow-up: Where does Python store default values? · Is it ever useful to rely on this behaviour?

  6. 6.What is the difference between is and == in Python?easy

    == checks equality: it calls __eq__, so two different objects with the same value compare equal. is checks identity: whether both operands are the very same object, equivalent to id(a) == id(b) while both are alive.

    So [1, 2] == [1, 2] is True, but [1, 2] is [1, 2] is False because those are two separate lists.

    Rules of thumb:

    • Use is / is not for singletons: None, and sentinel objects you create yourself. PEP 8 says comparisons to None should always use is or is not.
    • Use == for everything else, including numbers and strings.

    Never rely on is for ints or strings. CPython caches small integers and interns some strings, so is sometimes happens to return True, but that's an implementation detail. Since Python 3.8, x is 5 even triggers a SyntaxWarning. Also, __eq__ can be overridden to return anything, while is can't be overridden at all.

    What interviewers listen for
    • == compares values via __eq__
    • is compares object identity
    • Use is for None and sentinels
    • Small-int and string caching are implementation details
    • is cannot be overridden; __eq__ can

    Likely follow-up: Why might a is b be True for 256 but not for 257? · Why is x == None discouraged?

  7. 7.What do *args and **kwargs mean in a function definition and in a function call?easy

    In a definition, *args collects extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dict. The names are only convention; the * and ** are what matter.

    The parameter order is: regular parameters, *args, keyword-only parameters, then **kwargs. Anything after *args (like sep above) can only be passed by keyword. A bare * forces keyword-only parameters without collecting extras, and / (Python 3.8+) marks the parameters before it as positional-only, e.g. def f(a, b, /, *, key).

    At a call site the same operators work in reverse: *iterable spreads items into positional arguments and **mapping spreads into keyword arguments.

    The classic real use is forwarding everything unchanged with func(*args, **kwargs), as decorators and wrapper functions do, or passing extra options up the chain with super().__init__(**kwargs).

    def log(level, *args, sep=" ", **kwargs):
        print(level, args, sep, kwargs)
    
    log("INFO", 1, 2, sep="|", user="ana")
    # INFO (1, 2) | {'user': 'ana'}
    
    nums = [1, 2]
    opts = {"sep": "-"}
    log("DEBUG", *nums, **opts)     # unpacking at the call site
    # DEBUG (1, 2) - {}
    What interviewers listen for
    • *args is a tuple of extra positional arguments
    • **kwargs is a dict of extra keyword arguments
    • Parameters after *args or bare * are keyword-only
    • / marks positional-only parameters (3.8+)
    • At call sites, * and ** unpack

    Likely follow-up: What error do you get when passing the same argument twice? · Why would an API use positional-only parameters?

  8. 8.What is the Global Interpreter Lock (GIL), and how does it affect multithreaded Python programs?mid

    The GIL is a mutex in CPython that allows only one thread at a time to execute Python bytecode in an interpreter. It exists mainly because CPython manages memory with reference counting, and one global lock made refcount updates and the C API thread-safe cheaply, which also made C extensions easy to write.

    Consequences:

    • CPU-bound pure-Python code doesn't get faster with threads. Use multiprocessing or ProcessPoolExecutor, or move the hot loop into C extensions such as NumPy that release the GIL.
    • I/O-bound code still benefits from threads, because the GIL is released while a thread waits on sockets, files or time.sleep().
    • The GIL doesn't make your code thread-safe. counter += 1 is several bytecode steps and isn't atomic, so shared state still needs a threading.Lock.

    It's a CPython implementation detail, not a language rule. CPython 3.13 introduced an experimental free-threaded build without the GIL, and 3.14 made it officially supported, but the default build still has a GIL.

    What interviewers listen for
    • One thread runs Python bytecode at a time per interpreter
    • Exists to protect reference counting and the C API
    • Threads help I/O-bound work, not CPU-bound work
    • CPU-bound: use processes or GIL-releasing C extensions
    • The GIL does not make your code thread-safe

    Likely follow-up: Why is counter += 1 not thread-safe even with the GIL? · What is free-threaded Python?

  9. 9.What is the difference between assignment, a shallow copy and a deep copy?mid

    Assignment (b = a) copies nothing; it just adds a second name for the same object. A shallow copy creates a new outer container but fills it with references to the same inner objects. A deep copy recursively copies everything, so the result shares no mutable state with the original.

    Ways to shallow-copy: copy.copy(x), lst[:], list(lst), lst.copy(), dict(d), d.copy(), {**d}. For a deep copy, copy.deepcopy(x).

    In the example, appending to orig[0] shows up in the shallow copy because both outer lists point at the same inner list.

    Details worth knowing: deepcopy keeps a memo dict, so shared references and cycles are reproduced rather than recursing forever. Classes can customize copying with __copy__ and __deepcopy__. Immutable objects like ints and strings aren't actually duplicated, since sharing them is safe. Deep copies are slow and can copy far more than you meant to, so a shallow copy plus rebuilding only the nested part you change is often better.

    import copy
    
    orig = [[1, 2], [3, 4]]
    alias = orig                     # no copy at all: same object
    shallow = copy.copy(orig)        # also orig[:], list(orig), orig.copy()
    deep = copy.deepcopy(orig)
    
    orig[0].append(99)
    print(alias is orig)   # True
    print(shallow)         # [[1, 2, 99], [3, 4]]: inner lists are shared
    print(deep)            # [[1, 2], [3, 4]]: fully independent
    What interviewers listen for
    • Assignment creates an alias, not a copy
    • Shallow copy: new container, shared inner objects
    • Deep copy: recursively copies all nested objects
    • deepcopy uses a memo to handle cycles
    • Customize with __copy__ / __deepcopy__

    Likely follow-up: Does slicing a list create a deep or shallow copy? · When would deepcopy be a bad idea?

  10. 10.What is the difference between a list comprehension and a generator expression? When would you use each?easy

    A list comprehension [x * x for x in data] runs immediately and builds the whole list in memory. A generator expression (x * x for x in data) looks almost identical but returns a lazy iterator that computes one item at a time as it's consumed.

    Choose based on how the result is used:

    • Need to index it, take len(), iterate it several times, or keep it around: use a list.
    • Just feeding it once into sum(), any(), max(), "".join() or a for loop: use a generator. Memory stays constant and any() can stop early. As the sole argument you can drop the extra parentheses: sum(x * x for x in data).

    For a million items the list takes megabytes, while the generator object is a couple of hundred bytes. For small inputs a list comprehension can be slightly faster.

    Gotchas: a generator is exhausted after one pass. In Python 3 both forms have their own scope, so the loop variable doesn't leak into the surrounding code. Set and dict comprehensions are eager, like list comprehensions.

    What interviewers listen for
    • List comprehension is eager and builds everything
    • Generator expression is lazy, one item at a time
    • Generators use constant memory, but only one pass
    • Use generators for one-shot sum, any, join
    • Loop variables do not leak in Python 3

    Likely follow-up: Why does iterating a generator expression a second time give nothing? · Is there a tuple comprehension?

  11. 11.What are the main built-in data types in Python?easy

    Grouped by category:

    • Numeric: int (arbitrary precision, never overflows), float (a C double, IEEE 754), complex, and bool, which is a subclass of int (True + True == 2).
    • Text and binary: str (immutable Unicode text), bytes (immutable) and bytearray (mutable).
    • Sequences: list (mutable), tuple (immutable) and range (a lazy arithmetic sequence).
    • Sets: set (mutable) and frozenset (immutable and hashable).
    • Mapping: dict, which preserves insertion order.
    • None, the single instance of NoneType.

    Worth adding in an interview: Python is dynamically but strongly typed, so "1" + 1 raises TypeError instead of coercing. Everything is an object, including functions and classes. type(x) gives the exact type, while isinstance(x, T) also accepts subclasses and is usually what you want. And 0.1 + 0.2 == 0.3 is False because floats are binary fractions; use math.isclose or decimal.Decimal when it matters.

    What interviewers listen for
    • Numeric: int (unbounded), float, complex, bool
    • Text and bytes: str, bytes, bytearray
    • Containers: list, tuple, range, set, frozenset, dict
    • Dynamically but strongly typed
    • Prefer isinstance over type(x) == checks

    Likely follow-up: Why is bool a subclass of int? · How would you represent money precisely?

  12. 12.Is Python pass-by-value or pass-by-reference?easy

    Strictly, neither. Python uses what's usually called pass by object reference (or "call by sharing"): the parameter becomes a new local name bound to the same object the caller passed. Nothing is copied, but the name itself belongs to the function.

    That produces two different outcomes:

    • Mutating the object (items.append(4)) is visible to the caller, because both names point at one list.
    • Rebinding the parameter (items = [0], or n += 1 on an int) only changes what the local name points to. The caller's variable is untouched.

    So it behaves like pass by value for immutable types (you can't change an int in place anyway) and like pass by reference for mutable ones, but it's one consistent rule. If a function must not affect the caller's data, copy it first or, better, return a new object. By convention, functions that mutate in place, like list.sort(), return None to make that obvious.

    def modify(items, n):
        items.append(4)      # mutates the caller's list
        items = [0]          # rebinds the local name only
        n += 1               # int is immutable: new object, local name only
    
    nums, count = [1, 2, 3], 10
    modify(nums, count)
    print(nums, count)       # [1, 2, 3, 4] 10
    What interviewers listen for
    • Parameters are new names bound to the same objects
    • Mutating an argument is visible to the caller
    • Rebinding a parameter never affects the caller
    • Immutable arguments therefore behave like pass-by-value
    • In-place mutators conventionally return None

    Likely follow-up: How would you stop a function from modifying a list you pass in?

  13. 13.Explain the difference between @staticmethod, @classmethod and @property, with a use case for each.mid

    All three change how a function defined in a class is accessed.

    • A regular method receives the instance as self.
    • @classmethod receives the class as cls, whether it's called on the class or on an instance. The main use is alternative constructors such as dict.fromkeys or datetime.fromtimestamp. Because it builds via cls, calling it on a subclass returns a subclass instance.
    • @staticmethod receives nothing implicitly. It's a plain function that lives in the class namespace because it belongs there logically. If it doesn't really relate to the class, a module-level function is often cleaner.
    • @property turns a method into a computed attribute read without parentheses (t.kelvin). Adding a @kelvin.setter lets you validate assignments. You can start with a plain attribute and add logic later without changing the public API, which is why Python code doesn't need Java-style getters and setters.

    Under the hood, all three are implemented as descriptors.

    class Temperature:
        def __init__(self, celsius):
            self.celsius = celsius
        @classmethod
        def from_fahrenheit(cls, f):     # alternative constructor: receives the class
            return cls((f - 32) * 5 / 9)
        @staticmethod
        def is_valid(c):                 # plain function in the class namespace
            return c >= -273.15
        @property
        def kelvin(self):                # computed attribute, read without ()
            return self.celsius + 273.15
    
    print(Temperature.from_fahrenheit(212).kelvin)   # 373.15
    What interviewers listen for
    • classmethod gets cls; used for alternative constructors
    • classmethod respects subclasses via cls
    • staticmethod gets no implicit first argument
    • property exposes a computed attribute without parentheses
    • Property setters add validation without API changes

    Likely follow-up: How do you make a read-only property? · Can a classmethod be called on an instance?

  14. 14.What is a context manager, and how does the with statement work under the hood?mid

    A context manager sets something up on entry to a with block and reliably tears it down on exit, even if the block raises. The protocol is two methods:

    • __enter__(self) runs first; its return value is bound to the as target.
    • __exit__(self, exc_type, exc, tb) always runs at the end. If the block raised, the exception details are passed in; otherwise all three are None. Returning a truthy value suppresses the exception, so normally you return None or False.

    It's essentially a reusable try/finally. Standard examples: open() closes the file, a threading.Lock is released, a database transaction commits or rolls back, decimal.localcontext() restores the previous precision.

    For simple cases @contextlib.contextmanager lets you write one as a generator instead of a class. One with can manage several at once (with open(a) as f, open(b) as g:), and async code uses async with with __aenter__/__aexit__.

    import time
    
    class Timer:
        def __enter__(self):
            self.start = time.perf_counter()
            return self                        # bound to the "as" target
        def __exit__(self, exc_type, exc, tb):
            self.elapsed = time.perf_counter() - self.start
            return False                       # don't swallow exceptions
    
    with Timer() as t:
        sum(range(10**6))
    print(f"took {t.elapsed:.3f}s")
    What interviewers listen for
    • __enter__ sets up; its return value binds to as
    • __exit__ always runs, receiving exception details
    • Truthy return from __exit__ suppresses the exception
    • A reusable, safer try/finally
    • contextlib.contextmanager builds one from a generator

    Likely follow-up: How would you write the same timer with contextlib? · What is contextlib.ExitStack for?

  15. 15.Compare threading, multiprocessing and asyncio. How do you decide which one to use?mid

    They're three ways to do more than one thing at a time:

    • threading: OS threads in one process, sharing memory. With the GIL only one runs Python code at a time, so threads suit I/O-bound work (network calls, disk, subprocesses) where they mostly wait. Sharing data is easy, but you need locks and race conditions are easy to create.
    • multiprocessing: separate processes, each with its own interpreter and GIL, giving true parallelism for CPU-bound work. The cost is higher startup time and memory, and data must be pickled to cross process boundaries.
    • asyncio: one thread running an event loop that switches between coroutines at each await. It handles thousands of concurrent I/O-bound tasks cheaply, but libraries must be async-aware, and one blocking call stalls the whole loop.

    concurrent.futures puts ThreadPoolExecutor and ProcessPoolExecutor behind one API. My rule of thumb: CPU-bound, use processes; many concurrent network requests, use asyncio; some blocking I/O or non-async libraries, use a thread pool.

    What interviewers listen for
    • Threads: shared memory, good for I/O-bound work
    • Processes: true parallelism for CPU-bound work
    • Processes cost startup, memory and pickling
    • asyncio: single-threaded event loop, cooperative await points
    • Blocking calls freeze the asyncio loop

    Likely follow-up: How do you call blocking code from asyncio? · Does free-threaded Python change this advice?

  16. 16.How is a Python dict implemented, and why does it preserve insertion order?hard

    A dict is a hash table. To store a key, Python calls hash(key) and uses the hash to pick a slot; on lookup it hashes again, finds the slot, and confirms the match with ==. Collisions are resolved with open addressing: CPython probes other slots in a pseudo-random sequence derived from the hash. That gives average O(1) get, set and delete, with a rare O(n) worst case.

    Since CPython 3.6 the layout is compact: a sparse array of small indices points into a dense array of (hash, key, value) entries stored in insertion order. That saved memory and made ordering a side effect, which Python 3.7 turned into a language guarantee. Iteration is fast because it just walks the dense array.

    The table is resized when it gets about two-thirds full, so inserts stay amortized O(1). Consequences: keys must be hashable with consistent __hash__ and __eq__, mutating a key's hash-relevant state breaks lookups, and adding or removing keys while iterating raises RuntimeError.

    What interviewers listen for
    • Hash table: hash picks the slot, == confirms
    • Open addressing with pseudo-random probing
    • Average O(1) lookup, insert and delete
    • Compact layout since 3.6; order guaranteed since 3.7
    • Keys must be hashable; no resizing during iteration

    Likely follow-up: Why can two objects with equal hashes both be keys? · Is OrderedDict still useful?

  17. 17.What is the difference between __str__ and __repr__?easy

    Both return a string representation, but for different audiences:

    • __repr__ is for developers: unambiguous, ideally valid Python that would recreate the object, like datetime.date(2024, 1, 15). It's what the REPL and debuggers show.
    • __str__ is for end users: readable, like 2024-01-15. It's what print(), str() and f-strings use.

    If a class defines only __repr__, str() falls back to it, so __repr__ is the one to always implement. The reverse isn't true: with only __str__, the REPL still shows the default <__main__.Point object at 0x...>.

    Two details interviewers like: containers use the repr of their elements, so print([d]) shows [datetime.date(2024, 1, 15)] rather than the friendly form. And in f-strings, !r forces the repr, so f"{name!r}" puts quotes around a string, which is handy in log and error messages.

    What interviewers listen for
    • __repr__: unambiguous, for developers, ideally recreates the object
    • __str__: readable, for end users
    • str() falls back to __repr__, not vice versa
    • Containers show the repr of their elements
    • !r in f-strings forces repr

    Likely follow-up: What does print() call on a list of objects?

  18. 18.How do you choose between a list, tuple, set and dict? What are the time complexities of their common operations?easy

    Choose by what you need to do with the data:

    • list: ordered, mutable, allows duplicates. Indexing and append are O(1), but x in lst, remove and insert(0, x) are O(n). The default for sequences you'll grow or reorder.
    • tuple: ordered and immutable. Use it for fixed records and as dict keys or set members.
    • set: unordered, unique, hashable elements only. Membership, add and remove are O(1) on average, plus fast union, intersection and difference. Use it for deduplication and "have I seen this?" checks.
    • dict: key-to-value mapping with O(1) average lookup, insertion order preserved, hashable keys. Use it for lookup by key, counting and grouping.

    The most common performance bug I look for is membership testing against a list inside a loop, which quietly turns O(n) code into O(n²). Converting the list to a set once fixes it. And remember that {} is an empty dict; an empty set is set().

    What interviewers listen for
    • List: ordered, mutable; in is O(n)
    • Tuple: immutable, hashable records
    • Set: unique elements, O(1) average membership
    • Dict: O(1) average key lookup, insertion-ordered
    • {} is a dict; use set() for empty set

    Likely follow-up: What is the complexity of list.pop(0), and what would you use instead?

  19. 19.Explain the LEGB rule. When do you need global or nonlocal?mid

    Python resolves a name by searching four scopes in order, LEGB: Local (the current function), Enclosing (outer functions, for nested functions), Global (the module's top level) and Built-in (len, print, Exception...).

    The key rule is that scope is decided at compile time: if a function assigns to a name anywhere in its body, that name is local for the whole function. That's why print(x) followed by x = 1 inside a function raises UnboundLocalError, even when a global x exists.

    To assign to an outer name you must declare it. global x rebinds the module-level name; nonlocal x rebinds the name in the nearest enclosing function. You need neither to read an outer variable or to mutate an outer object (items.append(1)), only to rebind the name.

    Heavy use of global is a code smell; returning values or using a class is usually cleaner. Also note that a class body is not an enclosing scope for its methods.

    x = "global"
    
    def outer():
        x = "enclosing"
        def inner():
            nonlocal x          # rebind outer's x instead of creating a local
            x = "changed"
        inner()
        return x
    
    print(outer())   # changed
    print(x)         # global
    What interviewers listen for
    • Lookup order: Local, Enclosing, Global, Built-in
    • Any assignment makes a name local for the whole function
    • Read-before-assign raises UnboundLocalError
    • global rebinds module names; nonlocal rebinds enclosing ones
    • Only rebinding needs a declaration, not reading or mutating

    Likely follow-up: Why is a class body not an enclosing scope for its methods?

  20. 20.What is a closure in Python? Why does creating lambdas in a loop often give surprising results?mid

    A closure is a function that remembers variables from the scope where it was defined, even after that scope has finished. make_multiplier(2) returns a function that still sees k; CPython keeps it alive in a cell object, visible via double.__closure__.

    Closures capture variables, not values. Python uses late binding: the variable is looked up when the inner function is called. In the loop, all three lambdas close over the same i, and by the time they run the loop has finished with i == 2, so you get [2, 2, 2].

    Fixes, each capturing the value at creation time:

    • a default argument: lambda i=i: i
    • functools.partial(func, i)
    • a factory function that takes i as a parameter, like make_multiplier

    Closures underpin decorators, function factories and callbacks. To reassign a captured variable, such as a counter, the inner function needs nonlocal.

    def make_multiplier(k):
        return lambda x: x * k          # closes over k
    double = make_multiplier(2)
    print(double(5))                    # 10
    
    funcs = [lambda: i for i in range(3)]
    print([f() for f in funcs])         # [2, 2, 2]: i is looked up at call time
    
    funcs = [lambda i=i: i for i in range(3)]   # default arg binds the value now
    print([f() for f in funcs])         # [0, 1, 2]
    What interviewers listen for
    • Inner function keeps access to enclosing variables
    • Captures variables, not values: late binding
    • Loop lambdas all see the final loop value
    • Fix with default arg, partial, or a factory
    • nonlocal needed to reassign a captured variable

    Likely follow-up: How would you build a counter with a closure? · How are closures related to decorators?

esc