← Blog

Interview prep

Python Interview Questions: Mutability, Generators, the GIL and asyncio

The Python questions interviewers keep asking, with short answers, code we actually ran on Python 3.13, and the trade-offs that separate good answers from recited ones.

Matthew M

15 min read

A calm, softly lit desk with a closed laptop and a notebook, shallow depth of field, no text visible.

The Python interview questions that come up most are about mutability, == versus is, generators, closures, the GIL and asyncio. A strong answer states the rule in one sentence, shows a tiny example, and names the trade-off. This guide works through each one. Every code sample was run on Python 3.13, and we say where 3.14 changes the answer.

Version note first. Unless a section says otherwise, everything below is true for CPython 3.13, the reference implementation most companies run. Where a feature needs 3.11 or later we say so, because interviewers sometimes ask what you would do on an older runtime. Output lines in this post are what the code printed when we ran it.

What is the difference between mutable and immutable types in Python?

Immutable objects cannot change after creation: int, float, str, bytes, tuple and frozenset. Mutable objects can change in place: list, dict, set and most user-defined classes. The practical consequence is aliasing. Two names can point at one mutable object, so a change through one name is visible through the other.

The follow-up is almost always the mutable default argument. Consider def add_bad(x, bucket=[]): bucket.append(x); return bucket. Calling add_bad(1) and then add_bad(2) prints [1, 2] both times, because the default list is created once, when the def statement runs, and then shared by every call. The fix is a None sentinel: def add_ok(x, bucket=None), then if bucket is None: bucket = []. Calling add_ok(1) and add_ok(2) prints [1] and [2].

Two traps to mention if you have time. First, a tuple is immutable but its elements may not be. With t = ([1], 2), t[0].append(9) works and t becomes ([1, 9], 2), while t[1] += 1 raises TypeError. Second, augmented assignment on a tuple element is worse than it looks: for a = ([1], 2), the statement a[0] += [5] raises TypeError and still mutates the list, leaving ([1, 5], 2). The list was extended in place, then the attempt to rebind the tuple slot failed.

The trade-off to name: immutability buys safety and hashability, so tuples and frozensets can be dict keys, while mutability buys speed for in-place updates. Interviewers like hearing that you choose tuples for fixed records and lists for collections you grow.

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

A shallow copy builds a new outer container but reuses the same inner objects. A deep copy recursively copies everything it can reach. The clean demonstration: orig = {'a': [1, 2]}, then shallow = orig.copy() and deep = copy.deepcopy(orig). After orig['a'].append(3), shallow shows {'a': [1, 2, 3]} and deep still shows {'a': [1, 2]}.

The version of this question that catches people is building a grid. grid = [[0] * 3] * 3 looks right, but the outer multiplication repeats a reference to one inner list three times. Setting grid[0][0] = 1 prints [[1, 0, 0], [1, 0, 0], [1, 0, 0]]. The correct form is [[0] * 3 for _ in range(3)], which makes three separate rows; the same assignment then prints [[1, 0, 0], [0, 0, 0], [0, 0, 0]]. This bug shows up constantly in dynamic programming and matrix problems, so say out loud which form you are writing.

Trade-off: deepcopy is correct for nested data but slower, and it can surprise you with objects that hold file handles or locks. For plain data, many engineers prefer building new structures explicitly over reaching for deepcopy.

What is the difference between == and is?

== asks whether two objects are equal by value, using __eq__. is asks whether two names refer to the exact same object, which is an identity check. With x = [1] and y = [1], x == y is True and x is y is False.

Use is for singletons, meaning None, True and False, as in if value is None. Do not use is to compare numbers or strings. CPython happens to cache small integers and some strings, so 'is' can look correct in a quick test and then fail on larger values. That caching is an implementation detail, not a language promise, and saying so is the signal the interviewer wants.

How do dicts and sets work, and what do they cost?

Both are hash tables. A key must be hashable, which in practice means immutable. Looking up, inserting and deleting are average O(1) with a worst case of O(n) when many keys collide. Writing {[1]: 2} raises TypeError: unhashable type: 'list'.

Since Python 3.7, dict preserves insertion order as a language guarantee. Sets do not. A fun follow-up: {1: 'a', True: 'b'} prints {1: 'b'}, because True == 1 and hash(True) == hash(1), so the second pair overwrites the value while the first key survives.

The complexity question underneath this: x in some_list is O(n), x in some_set is average O(1). If a solution checks membership inside a loop, converting the list to a set first is often the whole optimisation. Similarly, list.append is amortised O(1) but list.pop(0) and list.insert(0, x) are O(n); collections.deque gives O(1) at both ends. Mention deque whenever you write a BFS queue.

  • list: ordered, mutable, append O(1) amortised, membership O(n).
  • tuple: ordered, immutable, hashable if its items are.
  • dict: insertion-ordered, average O(1) lookup by key.
  • set: unordered, average O(1) membership, no duplicates.
  • deque: O(1) append and pop at both ends, O(n) indexing in the middle.

What is a generator, and when would you use one instead of a list?

A generator is an iterator produced by a function that uses yield, or by a generator expression. It computes values one at a time on demand and pauses between them, so memory use stays flat no matter how many items it will eventually produce.

Two facts earn marks. First, generators are single-use. After g = squares(4), list(g) returns [0, 1, 4, 9] and a second list(g) returns []. Second, the memory difference is real: in our run, sys.getsizeof of a generator expression over a million numbers was a few hundred bytes at most, while the equivalent list needed over 8 MB just for its pointer array.

Reach for a generator when you stream a large file, process a pipeline of transformations, or might stop early. Reach for a list when you need len(), indexing, sorting or several passes over the data. A common interview task is to write a generator that yields lines matching a pattern from a file too big to load; the trade-off to state is that you cannot go back, so you either re-open the file or store what you need.

If asked about yield from or send(): yield from delegates to a sub-iterator, and send() pushes a value into a paused generator. Both are rarely needed in application code, and honest candidates say so.

What is a closure, and what is late binding?

A closure is an inner function that remembers variables from the enclosing scope after the outer function has returned. The classic example is a counter: define n = 0 in the outer function, declare nonlocal n in the inner one, increment it, and return the inner function. After c = counter() and one call, the next c() returns 2. Without nonlocal, the assignment would create a new local and raise UnboundLocalError.

Late binding is the trap. fs = [lambda: i for i in range(3)] followed by [f() for f in fs] gives [2, 2, 2], because every lambda looks up i when it runs, and by then the loop has finished with i equal to 2. Binding the current value as a default argument fixes it: fs = [lambda i=i: i for i in range(3)] gives [0, 1, 2]. The same issue exists in JavaScript with var, which is why it comes up in both languages.

Decorators are closures with a nicer syntax. One detail separates good answers: wrap the inner function with functools.wraps so the decorated function keeps its __name__ and docstring. In our check, a wrapped hello still printed hello and doc. Without wraps it would report wrapper.

Can you memoise a function, and what are the limits?

Yes. functools.cache (3.9 and later) or functools.lru_cache stores results by argument. With @functools.cache on a recursive Fibonacci, fib(80) returns 23416728348467685 instantly, where the plain version would take far longer than any interview. Arguments must be hashable, so passing a list fails. The cache also grows without bound under @cache, so for long-running services use lru_cache(maxsize=...) and accept that cached objects stay alive. Recursion depth is separate: CPython's default limit is about 1,000 frames, so deep memoised recursion may still need an iterative rewrite.

What is the GIL, and does it mean threads are useless?

The Global Interpreter Lock is a mutex in CPython that lets only one thread execute Python bytecode at a time. It exists because CPython's memory management, reference counting, is not thread-safe on its own, and one big lock was the simplest safe answer. The lock is released during blocking I/O and by many C extensions, so threads still help when your program mostly waits.

The rule of thumb interviewers want: threads for I/O-bound work, processes for CPU-bound work, asyncio for many concurrent I/O waits. We checked the CPU-bound half on a laptop with four calls of a pure-Python loop. Running them sequentially took about 0.73 seconds, a ThreadPoolExecutor with four workers took about 0.71 seconds, and a ProcessPoolExecutor took about 0.31 seconds. Your numbers will differ, but the shape holds: threads gave no speed-up, processes did. Four threads sleeping 0.2 seconds each finished in about 0.20 seconds total, which is the I/O half.

The GIL does not make your code thread-safe. n += 1 compiles to separate load, add and store steps, and the interpreter can switch threads between them, so concurrent increments can be lost. Individual operations such as list.append happen to be safe in CPython, but relying on that is fragile. Use threading.Lock or queue.Queue for shared state.

What changed with free-threaded Python?

PEP 703 added an optional build of CPython without the GIL. In 3.13 it was experimental, and in 3.14 it is officially supported but still not the default build. You can check at runtime: on 3.13, sys._is_gil_enabled() returns True on a normal build (it did on ours) and False on a free-threaded one. Sysconfig's Py_GIL_DISABLED variable tells you which kind of build you have.

The honest answer about the future: free-threading lets CPU-bound threads run in parallel, but it needs thread-safe extension modules, costs some single-thread speed, and ecosystem support is still maturing. A good line is that for an interview you should know the GIL deeply and know that it is optional now, but that process pools remain the portable answer today.

When would you use asyncio instead of threads?

asyncio runs many coroutines on one thread. A coroutine yields control at each await, so one event loop can juggle thousands of waiting network calls with far less overhead than thousands of threads. It does not help CPU-bound work, and a single blocking call stalls everything.

Show the speed-up concretely. With async def fetch(name, delay): await asyncio.sleep(delay), awaiting three fetches of 0.2 seconds one after another took 0.6 seconds, and asyncio.gather on the same three took 0.2 seconds. The common mistake is writing await in a for loop when the calls are independent.

Structured concurrency is the modern answer (Python 3.11 and later). async with asyncio.TaskGroup() as tg lets you create tasks that are all awaited when the block exits. If one fails, the others are cancelled and the errors arrive as an ExceptionGroup, which you catch with except*. In our run, a task raising ValueError('bad') beside a slow sibling produced the group ['bad'] and cancelled the sibling. async with asyncio.timeout(0.1) wraps a block with a deadline and raises TimeoutError, which we saw fire as expected.

For code that cannot be made async, asyncio.to_thread(func, *args) (3.9 and later) pushes a blocking call onto a worker thread. Two to_thread(time.sleep, 0.2) calls gathered together finished in about 0.2 seconds. Trade-off: to_thread keeps the event loop responsive, but it is still subject to the GIL for CPU work, so heavy computation belongs in a process pool.

  • Many slow network calls, one process: asyncio.
  • A few blocking libraries you cannot change: threads, or asyncio.to_thread.
  • Pure computation across cores: multiprocessing or ProcessPoolExecutor.
  • Simple scripts: do not add concurrency at all.

How does Python manage memory?

CPython uses reference counting as its primary mechanism: each object tracks how many references point at it and is freed when the count hits zero. Reference counting cannot reclaim cycles, such as two objects pointing at each other, so a generational cycle collector runs periodically to find and free them. The gc module lets you inspect and tune it.

Useful follow-ups: weak references (weakref) let you observe an object without keeping it alive; __slots__ removes the per-instance dict to save memory on millions of small objects; and memory leaks in Python are usually objects held by a long-lived container, such as an ever-growing module-level cache, rather than lost cycles.

How does sorting work, and what does stable mean?

sorted() and list.sort() use Timsort, run in O(n log n) in the worst case and are stable, meaning equal keys keep their original order. With people = [('bo', 30), ('al', 25), ('cy', 30)], sorting by age descending gives bo, cy, al. bo still comes before cy, which is the stability guarantee. Use key= functions rather than comparison functions, and use functools.cmp_to_key only when you truly need a pairwise comparison. Sorting by tuple keys, such as key=lambda p: (-p[1], p[0]), handles tie-breaks in one pass.

Which standard-library tools should you know cold?

Coding rounds reward candidates who reach for the right built-in instead of rebuilding it. Five are worth having in your fingers. Counter counts items: Counter('banana').most_common(2) returns [('a', 3), ('n', 2)]. defaultdict removes key-existence checks: with d = defaultdict(list), d['a'].append(1) just works and gives {'a': [1]}. heapq is a min-heap on a plain list: after heapq.heapify(h) on [5, 1, 3], heappop returns 1, and heapq.nlargest(2, [5, 1, 3]) returns [5, 3]. bisect does binary search on a sorted list: bisect_left([1, 3, 5], 4) returns 2, the insertion point. deque, covered earlier, is the queue.

Say the cost when you use them. heappush and heappop are O(log n). bisect is O(log n) to find the spot, but inserting into a list afterwards is O(n). Counter is just a dict underneath. Knowing that heapq has no max-heap, and that the usual trick is pushing negated values, is a small detail that interviewers notice.

How should you talk through these answers in a live coding round?

Interviewers rarely care that you can recite the GIL definition. They care that you can notice when it matters while writing code. Say what you are choosing and why as you type: 'I will use a set here because I check membership in a loop', or 'I am copying with a comprehension so the rows are independent'. That narration is the skill, and it is hard to build by reading alone.

Practice it out loud. The post on narrating a live coding problem under a timer walks through a repeatable structure. URHIRED AI has a Live coding surface and mock sessions for rehearsing exactly this; you speak or type every answer yourself. See the use cases for how the surfaces differ.

Where to go next

Frequently asked questions

What Python interview questions are asked most often?

The most common are mutable versus immutable types, the mutable default argument bug, shallow versus deep copy, == versus is, generators, decorators and closures, the GIL, and threads versus processes versus asyncio. Expect a short code sample for each, and expect a follow-up asking for the trade-off, not just the definition.

Is the Python GIL being removed?

It is becoming optional, not disappearing. PEP 703 added a free-threaded build of CPython. It was experimental in 3.13 and officially supported in 3.14, but the standard build still has the GIL. Ecosystem support is still maturing, so for CPU-bound work, process pools remain the portable choice.

Should I use threads, processes or asyncio in Python?

Use asyncio for many concurrent network waits, threads for a few blocking I/O calls you cannot rewrite, and processes for CPU-bound work that needs multiple cores. In a simple measurement on a laptop, four CPU-bound calls took about the same time sequentially or in threads, and under half the time in processes.

Why does a mutable default argument misbehave in Python?

The default value is evaluated once, when the function is defined, not on each call. A default list is therefore shared by every call that omits it, so appends accumulate. Use None as the default and create a fresh list inside the function body when the argument is None.

Which Python version should I prepare for?

Prepare for Python 3.11 or later, which is what most current job posts and codebases target. Know the 3.11 features that come up in interviews: TaskGroup, asyncio.timeout and except*. Also know that 3.13 and 3.14 introduced the optional free-threaded build, even if you will never use it at work.

← All posts

Ready for the live round?

Create a free account, then download the desktop app.

No credit card required·Works with all meeting platforms·macOS and Windows