Python
Python interviews in one place: data types and mutability, comprehensions, generators, decorators, OOP, the GIL, async and the standard library.
Top 20 Python interview questions most asked first
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
appendis 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 reassignt[0], butt[0].append(3)works, andhash(t)raisesTypeErrorbecause 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?- A list is mutable: you can append, remove and sort it in place. It's a dynamic array that over-allocates, so
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 rebindss, whilelst += [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?- Immutable:
3.What is a decorator in Python? Write a simple one and explain why
functools.wrapsis used.midA 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
@timedsyntax is just sugar forslow = timed(slow), applied once when the function is defined.The wrapper accepts
*args, **kwargsso it works with any signature, calls the original, and returns its result. Forgetting thatreturnis a classic bug: the decorated function silently returnsNone.functools.wrapscopies 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 aswrapper, 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 thedefwraps 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
@decis sugar forf = dec(f)at definition time- Wrapper uses
*args, **kwargsand returns the result functools.wrapspreserves 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.What is a generator, and how does
yieldwork? Why would you use one instead of returning a list?midA generator function is any function that contains
yield. Calling it doesn't run the body; it returns a generator object, which is an iterator. Eachnext()resumes execution until the nextyield, hands that value out, and freezes the frame (local variables and position) until the next request. When the function returns, the generator raisesStopIteration, whichforloops 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 supportsend(),throw()andclose(), 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
yieldproduces a value and suspends the frame- Lazy: constant memory, works for infinite streams
- Raises
StopIterationwhen the function returns - Single-pass: exhausted generators stay empty
Likely follow-up: What does
yield fromdo? · How is a generator different from a generator expression?5.What is wrong with using a mutable object like
[]as a default argument, and how do you fix it?easyDefault values are evaluated once, when the
defstatement executes, not on every call. The default list is stored on the function object (you can inspect it inadd_item.__defaults__), so every call that omitsbucketmutates that same list and state leaks from one call to the next.The standard fix is a
Nonesentinel: default toNoneand create a fresh object inside the function. IfNoneis a meaningful value for callers, use a private sentinel such as_MISSING = object()and compare withis.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
dataclassesrefuses a bare[]field default with aValueError, forcing you to writefield(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 bucketWhat 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 ifNoneis 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.What is the difference between
isand==in Python?easy==checks equality: it calls__eq__, so two different objects with the same value compare equal.ischecks identity: whether both operands are the very same object, equivalent toid(a) == id(b)while both are alive.So
[1, 2] == [1, 2]isTrue, but[1, 2] is [1, 2]isFalsebecause those are two separate lists.Rules of thumb:
- Use
is/is notfor singletons:None, and sentinel objects you create yourself. PEP 8 says comparisons toNoneshould always useisoris not. - Use
==for everything else, including numbers and strings.
Never rely on
isfor ints or strings. CPython caches small integers and interns some strings, soissometimes happens to returnTrue, but that's an implementation detail. Since Python 3.8,x is 5even triggers aSyntaxWarning. Also,__eq__can be overridden to return anything, whileiscan't be overridden at all.What interviewers listen for==compares values via__eq__iscompares object identity- Use
isforNoneand sentinels - Small-int and string caching are implementation details
iscannot be overridden;__eq__can
Likely follow-up: Why might
a is bbeTruefor256but not for257? · Why isx == Nonediscouraged?- Use
7.What do
*argsand**kwargsmean in a function definition and in a function call?easyIn a definition,
*argscollects extra positional arguments into a tuple, and**kwargscollects 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(likesepabove) 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:
*iterablespreads items into positional arguments and**mappingspreads 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 withsuper().__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*argsis a tuple of extra positional arguments**kwargsis a dict of extra keyword arguments- Parameters after
*argsor 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.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
multiprocessingorProcessPoolExecutor, 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 += 1is several bytecode steps and isn't atomic, so shared state still needs athreading.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 += 1not thread-safe even with the GIL? · What is free-threaded Python?- CPU-bound pure-Python code doesn't get faster with threads. Use
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:
deepcopykeeps 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 independentWhat interviewers listen for- Assignment creates an alias, not a copy
- Shallow copy: new container, shared inner objects
- Deep copy: recursively copies all nested objects
deepcopyuses a memo to handle cycles- Customize with
__copy__/__deepcopy__
Likely follow-up: Does slicing a list create a deep or shallow copy? · When would
deepcopybe a bad idea?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 aforloop: use a generator. Memory stays constant andany()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?
- Need to index it, take
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, andbool, which is a subclass ofint(True + True == 2). - Text and binary:
str(immutable Unicode text),bytes(immutable) andbytearray(mutable). - Sequences:
list(mutable),tuple(immutable) andrange(a lazy arithmetic sequence). - Sets:
set(mutable) andfrozenset(immutable and hashable). - Mapping:
dict, which preserves insertion order. None, the single instance ofNoneType.
Worth adding in an interview: Python is dynamically but strongly typed, so
"1" + 1raisesTypeErrorinstead of coercing. Everything is an object, including functions and classes.type(x)gives the exact type, whileisinstance(x, T)also accepts subclasses and is usually what you want. And0.1 + 0.2 == 0.3isFalsebecause floats are binary fractions; usemath.iscloseordecimal.Decimalwhen 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
isinstanceovertype(x) ==checks
Likely follow-up: Why is
boola subclass ofint? · How would you represent money precisely?- Numeric:
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], orn += 1on 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(), returnNoneto 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] 10What 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?
- Mutating the object (
13.Explain the difference between
@staticmethod,@classmethodand@property, with a use case for each.midAll three change how a function defined in a class is accessed.
- A regular method receives the instance as
self. @classmethodreceives the class ascls, whether it's called on the class or on an instance. The main use is alternative constructors such asdict.fromkeysordatetime.fromtimestamp. Because it builds viacls, calling it on a subclass returns a subclass instance.@staticmethodreceives 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.@propertyturns a method into a computed attribute read without parentheses (t.kelvin). Adding a@kelvin.setterlets 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.15What interviewers listen forclassmethodgetscls; used for alternative constructorsclassmethodrespects subclasses viaclsstaticmethodgets no implicit first argumentpropertyexposes 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?
- A regular method receives the instance as
14.What is a context manager, and how does the
withstatement work under the hood?midA context manager sets something up on entry to a
withblock 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 theastarget.__exit__(self, exc_type, exc, tb)always runs at the end. If the block raised, the exception details are passed in; otherwise all three areNone. Returning a truthy value suppresses the exception, so normally you returnNoneorFalse.
It's essentially a reusable
try/finally. Standard examples:open()closes the file, athreading.Lockis released, a database transaction commits or rolls back,decimal.localcontext()restores the previous precision.For simple cases
@contextlib.contextmanagerlets you write one as a generator instead of a class. Onewithcan manage several at once (with open(a) as f, open(b) as g:), and async code usesasync withwith__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 toas__exit__always runs, receiving exception details- Truthy return from
__exit__suppresses the exception - A reusable, safer
try/finally contextlib.contextmanagerbuilds one from a generator
Likely follow-up: How would you write the same timer with
contextlib? · What iscontextlib.ExitStackfor?15.Compare
threading,multiprocessingandasyncio. How do you decide which one to use?midThey'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 eachawait. 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.futuresputsThreadPoolExecutorandProcessPoolExecutorbehind 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
awaitpoints - 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.How is a Python
dictimplemented, and why does it preserve insertion order?hardA 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 raisesRuntimeError.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
OrderedDictstill useful?- Hash table: hash picks the slot,
17.What is the difference between
__str__and__repr__?easyBoth return a string representation, but for different audiences:
__repr__is for developers: unambiguous, ideally valid Python that would recreate the object, likedatetime.date(2024, 1, 15). It's what the REPL and debuggers show.__str__is for end users: readable, like2024-01-15. It's whatprint(),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
reprof their elements, soprint([d])shows[datetime.date(2024, 1, 15)]rather than the friendly form. And in f-strings,!rforces the repr, sof"{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 usersstr()falls back to__repr__, not vice versa- Containers show the
reprof their elements !rin f-strings forcesrepr
Likely follow-up: What does
print()call on a list of objects?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
appendare O(1), butx in lst,removeandinsert(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 isset().What interviewers listen for- List: ordered, mutable;
inis O(n) - Tuple: immutable, hashable records
- Set: unique elements, O(1) average membership
- Dict: O(1) average key lookup, insertion-ordered
{}is a dict; useset()for empty set
Likely follow-up: What is the complexity of
list.pop(0), and what would you use instead?- list: ordered, mutable, allows duplicates. Indexing and
19.Explain the LEGB rule. When do you need
globalornonlocal?midPython 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 byx = 1inside a function raisesUnboundLocalError, even when a globalxexists.To assign to an outer name you must declare it.
global xrebinds the module-level name;nonlocal xrebinds 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
globalis 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) # globalWhat 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 globalrebinds module names;nonlocalrebinds 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.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 seesk; CPython keeps it alive in a cell object, visible viadouble.__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 withi == 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
ias a parameter, likemake_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 nonlocalneeded to reassign a captured variable
Likely follow-up: How would you build a counter with a closure? · How are closures related to decorators?
- a default argument:
No questions match that filter.