Python developer interview questions in 2026 range from a two-minute is-vs-== check to a full GIL internals grilling, depending on the loop and the level. Python usage among professional developers climbed about 7 percentage points in a single year, to 57.9%, according to the 2025 Stack Overflow Developer Survey, the sharpest one-year jump the survey has recorded in Python's own history. GitHub's numbers tell a similar story: Python overtook JavaScript as the most-used language on the platform in 2024, ending a run JavaScript had held for roughly a decade, with Jupyter Notebook usage alone jumping 92% that year on the back of AI and data science work. None of that tells you what actually gets asked in a Python developer interview, though, and the BLS still projects around 129,200 openings a year for software developers, QA analysts, and testers through 2034, so a lot of people are competing for the same roles.
Here's my honestly contrarian take: grinding LeetCode matters less for Python roles than it does for C++ or Java roles. Not because the algorithms differ, they don't, but because a good chunk of what trips up Python candidates isn't algorithmic at all. It's language behavior the algorithm doesn't care about, why def add_item(item, bucket=[]) breaks the second time you call it, why two equal-looking objects can fail an is check, why a generator that looks nearly identical to a list comprehension is the difference between a script that runs and one that gets OOM-killed on a big input. I could be wrong about the weighting here, teams and interviewers vary a lot, but it's the pattern we keep running into.
This page covers Python developer interview questions from junior screening rounds through staff-level systems questions: core types, the data structures underneath lists and dicts, OOP mechanics, decorators, generators, the GIL and Python's concurrency options, memory management, and the gotchas that still catch people who've written Python for years. Twenty-two questions in the main sections, mixed difficulty, plus two coding problems worked through with code, and a short FAQ block at the end.
Core Python and data types
Even senior candidates get a warm-up round on this, mostly because it's a fast filter for people who've only ever worked inside a framework without learning the language it's built on.
Easy questions
15== calls an object's __eq__ method and compares values. is compares identity, whether two names point at the exact same object in memory. Two separate lists with identical contents will be == but not is.
The gotcha interviewers like: CPython interns small integers from -5 to 256 and some string literals, so a = 256; b = 256; a is b can return True even though nothing in the language guarantees that. It's a CPython implementation detail, not a rule you can rely on. The safe habit is to only use is for None, True, and False checks, and == for everything else.
"If it walks like a duck and quacks like a duck" is the whole idea: Python doesn't check an object's declared type before calling a method on it, it just tries the call. If the object happens to have that method, the call works, regardless of what class the object actually is. Any object with a .read() method satisfies code written for a file-like object.
Interviewers usually follow up by asking about EAFP versus LBYL, "easier to ask forgiveness than permission" versus "look before you leap." Idiomatic Python leans EAFP, wrapping a call in try/except instead of pre-checking with hasattr(). Candidates coming from Java or C# sometimes default to LBYL out of habit, and that's a fair thing for an interviewer to point out.
A classmethod receives the class itself, cls, as its first argument, so it can read or modify class-level state and is commonly used for alternative constructors, something like Point.from_tuple(t). A staticmethod receives neither self nor cls, it's really a plain function that happens to live in the class's namespace for organizational reasons.
If a method doesn't touch instance state or class state at all, staticmethod is the more honest choice, and interviewers notice when candidates default to classmethod out of habit without a reason.
Dunder ("double underscore") methods like __init__, __eq__, __len__, and __repr__ let an object hook into Python's built-in syntax and functions instead of exposing custom method names for the same behavior.
__repr__ should return an unambiguous representation aimed at developers, ideally something you could paste back into a REPL to recreate the object, what a debugger or interactive session shows you. __str__ is the human-readable version aimed at end users, what print() displays. If a class only defines __repr__, Python falls back to it for both, which is a big part of why plenty of real codebases never bother defining __str__ at all.
A list comprehension builds the entire list in memory right away. A generator expression, same syntax with parentheses instead of brackets, produces values lazily, one at a time, only when something asks for the next one.
For a one-off pass over a huge file or an unbounded sequence, that's the whole ballgame: sum(x**2 for x in range(10_000_000)) never holds ten million integers in memory at once, while the list-comprehension version does. The tradeoff is real too, a generator can only be iterated once, and you can't index into it or check its length without consuming it.
def fib():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
gen = fib()
first_ten = [next(gen) for _ in range(10)]The natural follow-up: what breaks if you call list(fib()) directly? Nothing breaks technically, it just never returns, since the generator runs forever. You need something like itertools.islice(fib(), 10) or a manual counter to bound it.
copy.copy(), or a list slice like lst[:], creates a new outer container, but the elements inside still point at the same nested objects as the original. Mutate a nested list inside a shallow copy, and the original changes too. copy.deepcopy() recursively copies every nested object, so the two structures share nothing after that point.
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)
shallow[0].append(99)
print(original) # [[1, 2, 99], [3, 4]] <- original mutated
print(deep) # [[1, 2], [3, 4]] <- untouchedMost single technical rounds cover one to three coding problems plus a handful of core-language questions woven into follow-ups, not the full spread of Python developer interview questions covered on this page. A 45 to 60 minute round rarely has time for much more than that if the interviewer is leaving room to actually discuss your reasoning instead of just grading output.
No, not fully. You need working knowledge, why a dict lookup is fast, why the GIL affects threading, why shallow copies share nested objects, at a level you can explain out loud and reason from. Interviewers are checking understanding, not asking you to recite the CPython source line by line.
Rarely in 2026, and when it comes up it's usually one quick question confirming you know Python 2 reached end of life in January 2020 and that print became a function rather than a statement in Python 3. Interviewers are more likely to ask about newer additions, structural pattern matching with match/case since 3.10, or the free-threaded build in 3.13 and 3.14, than anything Python 2 specific.
It varies by company, and increasingly leans toward yes, especially at companies with large Python codebases where mypy or pyright run in CI. Adding basic type hints to a function signature during a live coding round signals that you write production code a certain way, but a missing type hint alone won't sink an otherwise-correct solution. If you're unsure, ask the interviewer directly, it's a fair question and shows judgment rather than guessing.
*args collects any extra positional arguments into a tuple, **kwargs collects extra keyword arguments into a dict. Neither name is special, the star is what matters, people just use args and kwargs by convention.
The ordering rule in a function signature is fixed: regular positional params, then *args, then keyword-only params, then **kwargs. You'll see this pattern constantly in decorators and in wrapper functions that need to forward whatever arguments the caller passed along to another function without knowing its exact signature ahead of time.
A tuple is hashable as long as everything inside it is hashable, so you can use a tuple as a dict key or put it in a set, a list can't do either because lists aren't hashable. This is why coordinate pairs, cache keys built from multiple arguments, or rows you want to deduplicate with a set usually end up as tuples.
Tuples also carry a "this is a fixed record" meaning in practice. When a function returns multiple values, Python packs them into a tuple behind the scenes, and unpacking with x, y = get_point() reads naturally because you already know there are exactly two fixed things coming back, not a variable-length collection.
A virtual environment is an isolated copy of the interpreter's site-packages directory, created with python -m venv or a tool like poetry, so the packages one project installs don't collide with the packages another project needs, even when they need different versions of the same library.
Without one, everything installs into the system Python, and eventually two projects need incompatible versions of the same dependency, or a pip install accidentally upgrades a package your OS itself relies on. Activating a venv just changes which python and pip binaries are first on your PATH, nothing more mysterious than that.
else runs only when the try block completes without raising anything. It exists so you can put code that should run on success outside the try itself, which matters because if that code also raised an exception, you wouldn't want it accidentally caught by the same except clauses meant for the try block.
finally runs no matter what, exception or not, caught or not, even if there's a return statement inside the try or an except block. It's the right place for cleanup that absolutely must happen either way, closing a file handle or releasing a lock, regardless of whether the operation succeeded.
Medium questions
25Once created, you can't change a string's or a tuple's contents in place. Every "modification", concatenation, slicing, uppercasing, produces a brand-new object instead.
Two consequences interviewers actually probe: building a string with += inside a loop is roughly O(n²), because every concatenation allocates a fresh string and copies everything that came before it; using ''.join(parts) instead is O(n). And tuples can be dict keys or set members precisely because they're hashable, hashing something that could change out from under you would break the hash table's whole premise, which is why lists can't be used the same way.
A dict is backed by a hash table. Each key gets hashed, and that hash maps to a slot in an underlying array. Collisions get resolved with open addressing, probing forward to the next available slot in a defined sequence, rather than chaining entries into linked lists the way some other languages do it.
Since Python 3.7, insertion order is also a guaranteed language feature for dicts, not just a CPython quirk anymore (it was a quirk before 3.7). The tradeoff for that O(1) average is resizing: once the table gets about two-thirds full, CPython allocates a larger table and rehashes every existing key into it, an O(n) operation that happens rarely enough that the average cost per insertion still works out to O(1). A follow-up worth knowing: a bad or malicious hash function can push every key into one bucket and degrade lookups to O(n), which is part of why Python randomizes string hashing by default.
The brute-force nested loop works but costs O(n²). A single pass using a dict that maps value to index gets it down to O(n) time and O(n) space: for each number, check whether its complement (target minus the number) has already been seen before adding the current number to the map.
def two_sum(nums, target):
seen = {}
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return []Interviewers want the O(n) version specifically, and a common follow-up is what happens with duplicate values in the list, or whether the function should return the first valid pair or all valid pairs.
A decorator is a function that takes a function and returns a function, usually a wrapped version of the original. Writing @my_decorator above def foo(): is syntactic sugar for foo = my_decorator(foo). The returned wrapper closes over the original function and typically calls it somewhere inside, plus does something before or after that call.
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def greet(name):
return f"Hello, {name}"Skipping functools.wraps is a small but real bug: without it, greet.__name__ becomes "wrapper" and the original docstring disappears, which breaks introspection tools and confuses anyone reading a traceback later.
Wrap the call with a timer, using time.perf_counter() rather than time.time(), since perf_counter is monotonic and meant specifically for measuring elapsed intervals.
import time
import functools
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapperA common follow-up: what changes if the decorator itself needs an argument, like @timed(threshold=0.5)? That requires one more level of nesting, a function that takes the decorator's arguments and returns the actual decorator, which then returns the wrapper. Candidates who can write this cleanly on a whiteboard without getting the nesting levels confused tend to stand out.
A closure is a function that remembers variables from its enclosing scope even after that outer function has already returned. In the timing example above, wrapper() references func from timed()'s scope. That's a closure, func stays alive as long as wrapper does, even though timed() finished executing long before wrapper() ever gets called.
Decorators lean on this constantly, the wrapper needs to hold onto the original function (and any decorator arguments) without the caller passing them explicitly on every invocation. One trap: if the inner function needs to modify a variable from the enclosing scope rather than just read it, plain assignment creates a new local variable instead. You need the nonlocal keyword to actually rebind the outer one.
An iterator is any object implementing __iter__ and __next__. A generator is a specific, convenient way to build one, any function containing a yield statement automatically becomes a generator function, and calling it returns a generator object that already implements the iterator protocol for you.
You could hand-write a class with a manual __next__ that raises StopIteration when it's done. Generators just save you that boilerplate, and let the function's local state, where it paused, what variables held what values, get suspended and resumed automatically between calls.
The Global Interpreter Lock is a single mutex in CPython that allows only one thread to execute Python bytecode at a time, even on a machine with a dozen cores. It exists mainly to protect CPython's reference-counting memory management. Without it, two threads incrementing the same object's refcount at the same instant could corrupt it, and adding fine-grained locks around every object would slow down ordinary single-threaded code quite a bit.
The tradeoff: CPU-bound multithreading in pure Python doesn't buy real parallelism, your threads take turns rather than running simultaneously. Worth knowing for 2026 interviews specifically: Python 3.13 shipped an experimental free-threaded build under PEP 703 that makes the GIL optional, and 3.14 promoted that support from experimental to officially supported under PEP 779. Early benchmarks show roughly a 4x speedup on CPU-bound multithreaded workloads, at a cost of 5 to 10% single-thread overhead and higher memory use. It's opt-in for now, not the default build, and CPython's own roadmap has it staying that way for at least another couple of releases.
Threading helps with I/O-bound work, network calls, file reads, database queries, because the GIL actually releases while a thread is blocked waiting on I/O, letting other threads run in the meantime. For CPU-bound work, heavy computation, image processing, number crunching, threading doesn't help much at all, because of the GIL.
Multiprocessing sidesteps the GIL entirely by spinning up separate OS processes, each with its own Python interpreter and its own memory space, so they genuinely run in parallel across cores. The cost is real: no shared memory by default, so you pay for inter-process communication (pickling data across process boundaries) and a heavier startup cost per worker than spinning up a thread.
Every object in CPython carries a reference count, incremented whenever something new points to it, decremented when a reference disappears. The instant that count hits zero, CPython deallocates the object immediately, no waiting on a GC pass. That handles the large majority of memory cleanup on its own.
The gap is reference cycles, two or more objects referencing each other, which never hit zero through refcounting alone, even after nothing outside the cycle can reach them anymore. For that, CPython runs a separate generational cyclic garbage collector (the gc module) that periodically scans for and reclaims unreachable cycles. Objects live in one of three generations, and younger generations get scanned far more often than older ones, on the assumption that most objects die young.
Default argument values get evaluated exactly once, when the function is defined, not fresh on every call. That empty list gets created a single time and then reused across every call that doesn't pass its own bucket explicitly. Call add_item(1) three times without an explicit bucket, and you'll end up with one shared list holding three items, not three separate lists with one item each.
def add_item(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucketThe fix is to use None as a sentinel and build the mutable object inside the function body. The same trap applies to dict and set defaults, and interviewers sometimes ask you to predict the buggy output before showing you the fix, which is a fair way to check whether you actually understand it or just recognize the pattern.
A single pass counts character frequency with a dict or Counter. A second pass, over the string in its original order this time, finds the first character whose count is exactly one. That's O(n) time, and O(1) extra space if you assume a bounded character set like ASCII.
from collections import Counter
def first_unique_char(s):
counts = Counter(s)
for i, ch in enumerate(s):
if counts[ch] == 1:
return i
return -1Candidates who reach for a nested loop, checking each character against the rest of the string directly, get a working O(n²) solution that invites an immediate follow-up about complexity. It's a good problem for gauging whether someone reaches for a hash map by instinct.
Without it, wrapping a function replaces its __name__, __doc__, and __module__ with the wrapper function's own, so every decorated function suddenly looks like it's named "wrapper" to anything that inspects it, including debuggers, help(), Sphinx, and frameworks like Flask that register routes using the function's __name__.
import functools
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapperfunctools.wraps copies that metadata from the original function onto the wrapper, so introspection tools still see the real function's identity. Forgetting it is a common source of confusing bugs when you stack several decorators and something downstream needs the original function's name.
A class-based context manager implements __enter__, which runs on entering the with block and can return a resource, and __exit__, which runs on exit and receives the exception type, value, and traceback if one occurred, returning True from __exit__ suppresses that exception. Use a class when the resource genuinely has state you want to track across multiple methods or reuse across many call sites.
from contextlib import contextmanager
@contextmanager
def timed_section(label):
start = time.time()
try:
yield
finally:
print(label, time.time() - start)contextlib.contextmanager lets you write the same idea as a generator with one yield, the code before the yield is __enter__, the code after it, inside a try/finally, is __exit__. It's the faster option for a quick one-off resource wrapper where a full class feels like overkill.
It lets you start with a simple public attribute and later add validation, a computed value, or logging, without breaking any code that's already accessing it with dot notation. Callers never have to know it changed from a stored value to a method call.
The usual pattern pairs a read property with a setter that validates input before storing it, or exposes a value that's actually computed from other attributes rather than stored at all. The real win over Java-style getters and setters written from day one is you don't have to guess upfront whether a field will ever need logic behind it, you can add that later with zero API change.
Normally every instance carries a __dict__ to hold its attributes, which costs real memory per instance and lets you bolt on arbitrary new attributes at runtime. Defining __slots__ = ('x', 'y') tells CPython to allocate fixed storage for exactly those names instead, cutting per-instance memory noticeably, which matters once you're creating millions of small objects, like rows parsed from a large file.
The tradeoffs: no __dict__ means no ad hoc attributes at runtime, multiple inheritance between two slotted classes with overlapping slot names breaks, and any library that expects to monkeypatch attributes onto your instances at runtime will fail with an AttributeError.
dataclass generates __init__, __repr__, and __eq__ from your type-annotated fields, so for a class that's mostly a data container, a config object, a response DTO, you stop hand writing constructor boilerplate. You still get to add regular methods on top, use frozen=True for an immutable and hashable version, and default_factory for mutable defaults like lists so you don't walk into the shared-default-argument trap.
It's not a substitute for a class with real invariants and behavior to protect, it's specifically for the case where a class's job is mostly to hold typed fields together.
It lets you assign and test a value in the same expression, which mostly helps inside a while loop condition, where you'd otherwise have to compute the value once before the loop and again at the end of every iteration just to check it.
while (chunk := file.read(8192)):
process(chunk)It also helps inside a comprehension when you want to filter on a value you also need in the output, without calling the same expensive function twice to get it. Outside those two cases it usually just makes code harder to skim, so it's not something to sprinkle everywhere.
For a comprehension using a lambda-equivalent expression, comprehensions usually edge out map with a lambda, because map with a lambda pays for a Python-level function call on every element, while the comprehension's expression gets evaluated inline by the bytecode. If you're mapping a built-in directly, like map(int, values), map often wins outright since there's no Python function call overhead at all.
In real code the performance gap is small next to readability. A comprehension reads left to right in the order you actually think about the problem, chained filter and lambda calls get hard to follow once you nest more than one.
It sets __cause__ on the new exception to the original one, so the traceback shows both, printing "the above exception was the direct cause of the following exception" instead of losing the original context entirely.
Even without from, Python still attaches the original exception as __context__ automatically if you raise a new one from inside an except block, but from is explicit about the causal relationship and changes the traceback wording accordingly. raise NewError(...) from None deliberately hides the original traceback, which is the right move when you don't want to leak internal exception details to a caller across an API boundary.
pickle.loads on untrusted input is a remote code execution risk, unpickling can invoke arbitrary constructors and __reduce__ methods, so if the data crosses a trust boundary, a network request, a file a user can modify, a queue fed by an external system, pickle is the wrong choice regardless of convenience.
json is safe there because it only ever produces plain data structures, though it can't natively represent datetimes, sets, or custom classes without writing encoders and decoders yourself. pickle is fine, often better, for internal caching where you control both ends and need to preserve real Python types, like sticking a trained model or a dataframe in Redis.
Since Python 3.3, a directory without __init__.py still works as a namespace package, import mypackage.module can still succeed, which trips people up because they assume __init__.py is mandatory for a directory to count as a package.
What you lose is a place to run package-level initialization code, control what a wildcard import exposes, or set __all__. Namespace packages also compute their __path__ differently if the same package name happens to be split across multiple directories on sys.path. In a normal project, keep the empty __init__.py, namespace packages mostly exist for plugin systems where several installed packages need to contribute to one shared top-level name.
Duck typing is fine when failing at the call site is acceptable, you get an AttributeError when a missing method finally gets called. An ABC lets you fail earlier, at instantiation, by declaring @abstractmethod entries a subclass must implement, and it writes the contract down explicitly in code rather than relying on convention everyone remembers.
It also gives you isinstance checks against a real interface instead of a concrete class, using .register() to declare a virtual subclass, or checking against collections.abc.Sequence to ask "does this behave like a list" without caring about its actual base class. Reach for one when building something other teams will implement against, skip it for a quick internal script where duck typing is simpler and faster to write.
A for loop over a list tracks a numeric index internally. When you call list.remove() or del inside the loop body, everything after the removed item shifts down one position, but the loop's index still just advances by one, so you silently skip whatever element just slid into the removed slot.
nums = [1, 2, 2, 3]
for n in nums:
if n == 2:
nums.remove(n)
print(nums)That code leaves a stray 2 behind, because after the first 2 is removed, the second 2 shifts into the index the loop is about to visit, but the loop's cursor has already moved past it. The fix is to iterate over a copy with nums[:], build a new filtered list with a comprehension instead of mutating in place, or iterate in reverse when deleting by index.
Python lists are dynamic arrays that over-allocate space. When you append and there's still room, that's a genuine O(1) write. When the underlying array is full, CPython allocates a new, bigger block and copies every existing element into it, an O(n) operation on that one call.
Because that resize happens infrequently, and each resize buys room for many future appends, the average cost per append across a long run of them works out to O(1), hence "amortized." list.insert(0, x) doesn't get this benefit at all, it's a genuine O(n) every single time, since every existing element has to shift over by one.
Hard questions
12When a class inherits from multiple parents that share a common ancestor, calling an inherited method can be ambiguous about which parent's version should run. Python resolves this with C3 linearization, an algorithm that guarantees every class in the hierarchy appears exactly once in the order, and that a class always comes before its own parents. You can inspect the result directly on any class with ClassName.__mro__.
The classic diamond, D inherits from B and C, and both B and C inherit from A, resolves to D, B, C, A under C3. A naive depth-first search without linearization would visit D, B, A, then C, A, hitting A twice and getting the priority order wrong. Interviewers who ask this are checking whether a candidate has actually looked under the hood of multiple inheritance, not just avoided it.
asyncio runs a single-threaded event loop that switches between coroutines cooperatively. A coroutine runs until it hits an await on something that would block, a network call, a sleep, at which point it yields control back to the event loop, which runs a different coroutine while the first one waits. There's no preemption and no thread-switching overhead, and you don't have to worry about two coroutines mutating shared state at the exact same instant, only one of them is ever actually running.
It's a strong fit for high-concurrency I/O-bound work, thousands of open connections on a single process, but it does nothing for CPU-bound work. A CPU-heavy coroutine that never hits an await will block the entire event loop until it finishes. Candidates who forget this, and mix in a blocking call like time.sleep() instead of asyncio.sleep() inside an async function, end up stalling every other coroutine, not just their own, which is exactly the kind of thing interviewers ask about to check real understanding versus memorized definitions.
A metaclass is the class of a class. type is the default metaclass for everything, so when you write class Foo(metaclass=Meta), Meta.__new__ and Meta.__init__ run to construct the Foo class object itself, before any instance of Foo exists. This is the mechanism Django's ORM and SQLAlchemy's declarative base use to scan a class body at class-creation time and turn plain-looking attributes into database columns.
For most cases people reach for a metaclass today, __init_subclass__, added in 3.6, does the same job with far less ceremony, it's just a classmethod that runs whenever a subclass is defined, no separate metaclass type required. A legitimate remaining reason to write a real metaclass is when you need to control the class's own __call__, so instantiating the class does something custom like enforcing a singleton or an interned-instance cache, or when you need __prepare__ to control the namespace before the class body even runs, neither of which __init_subclass__ can do.
A descriptor is any object with __get__, and optionally __set__ and __delete__, defined and assigned as a class attribute. When you access instance.attr, Python checks the class's __dict__ first, and if it finds a descriptor there, it calls its __get__ instead of just handing back a plain value. @property is itself implemented as a descriptor, it's a convenience wrapper around exactly this protocol for one attribute at a time.
class PositiveNumber:
def __set_name__(self, owner, name):
self.name = "_" + name
def __get__(self, obj, objtype=None):
return getattr(obj, self.name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.name} must be positive")
setattr(obj, self.name, value)
class Order:
quantity = PositiveNumber()Writing your own descriptor class matters when the same validation or caching logic needs to be reused across many attributes or many classes, a property has to be rewritten per attribute, a descriptor class is written once and instantiated wherever it's needed. Django's ORM fields and SQLAlchemy's columns are built on exactly this pattern.
Reference counting alone can't reclaim objects that reference each other in a cycle, a parent holding a child and the child holding a back-reference to the parent, because neither object's count ever drops to zero even after nothing else in the program can reach either one. CPython's cyclic garbage collector, the gc module, periodically walks container objects, lists, dicts, class instances, anything that can hold references to other objects, looking for exactly these unreachable cycles and collecting them.
It's generational: most objects die young, so new objects start in generation 0 and get scanned most often, objects that survive a few passes get promoted to generation 1 and then 2, which get scanned far less often, this keeps the collector from re-walking long-lived objects on every pass. gc.get_count() shows you the current generation counts, gc.collect() forces a full pass, and weakref lets you hold a reference to an object without adding to its refcount at all, which is how you build a cache that shouldn't keep its entries alive forever.
threading.local() keys its storage by OS thread, but asyncio typically runs everything on a single thread with many coroutines interleaved on one event loop, there's no thread boundary separating them, so every coroutine would read and write the exact same thread-local value.
contextvars.ContextVar solves this by attaching a value to a logical context that gets copied whenever a new asyncio Task is created, so each Task, even sharing the same OS thread, gets its own isolated view of the variable, and setting it inside one task never leaks into a sibling task running concurrently. It's the mechanism behind propagating something like a request ID or an active transaction across an async call chain, doing for task-based concurrency what a thread-local does for thread-based concurrency.
First rule out allocator fragmentation, plenty of small allocations can make RSS climb even when the objects behind them are being freed correctly, so confirm the growth is genuine Python-level object growth before assuming a leak. tracemalloc, built into the standard library, lets you take two heap snapshots hours apart and diff them with snapshot.compare_to(), which shows the exact line of code responsible for the growth in live objects.
If it's reference cycles piling up, gc.get_objects() together with objgraph.show_backrefs() lets you visualize what's still holding a reference to instances that should have died. In practice the usual culprits are a module-level dict used as an unbounded ad hoc cache, closures capturing a large object in a callback that's registered once and never removed, or a requests.Session or connection pool being recreated on every call instead of reused. Fix the actual retained reference first, calling gc.collect() manually only helps if the leak is genuinely cyclic garbage, not a live reference you forgot to release.
gather runs a list of awaitables concurrently and returns their results in the same order you passed them in, once every one of them has finished, so you wait for the slowest task before getting anything back. as_completed also runs them concurrently but yields each result as soon as that individual task finishes, in whatever order they actually complete, which is better when you want to start processing early results while slower ones are still running.
The exception gotcha with gather: by default, if one awaitable raises, gather immediately propagates that exception, but it doesn't cancel the others, they keep running in the background, and if nothing ever awaits them again you can get "Task exception was never retrieved" warnings later, or a leaked task nobody's tracking anymore. Passing return_exceptions=True changes gather so a failure comes back as the exception object inside the results list instead of being raised immediately, which is usually what you want when calling several independent services and one failing shouldn't hide whether the rest succeeded.
fork duplicates the current process's memory as-is, copy-on-write on Linux so it's cheap, and the child inherits everything already imported or opened in the parent, including a half-open database connection or a logging handler pointed at a file, which can cause subtle bugs if that inherited state isn't safe to share across processes.
spawn starts a brand new interpreter process and re-imports your module from scratch, so the child only gets what you explicitly pass to the target function. It's slower to start but far more predictable, and it's the only option on Windows and the default on macOS since Python 3.8, since fork was made non-default there after it proved unsafe with certain system frameworks. The practical result: multiprocessing code that works fine under fork on Linux can silently break on macOS or Windows because spawn requires everything passed to a worker to be picklable, and any side effect that ran once at import time in the parent won't have happened in a freshly spawned child.
When module A imports module B, and B imports A, Python starts executing A top to bottom, hits the import of B partway through, and starts executing B. If B tries to import a name from A before A has finished defining it, that name simply doesn't exist yet in A's half-built namespace, so you get an ImportError or AttributeError depending on exactly what's being pulled and when.
sys.modules caches a module by name the moment it starts executing, not once it finishes, so the second import of A, triggered from inside B, returns the incomplete module object already sitting in the cache instead of re-running it, which is why the failure depends on import order rather than failing the same way every time. The clean fix is almost always restructuring, moving whatever both modules need into a third module they both import from. When that's not practical right away, moving the import inside the function that actually needs it defers the lookup until both modules are fully loaded, which works but is a workaround, not a design.
PEP 703 added an experimental CPython build, first shipped in 3.13, that can run without the Global Interpreter Lock at all, meaning true parallel execution of Python bytecode across threads on multiple cores, something the standard build has never allowed for CPU-bound work.
It's opt-in, you have to install the free-threaded variant explicitly, python3.13t, the default build still ships with the GIL because removing it isn't free. Per-object locking to keep reference counts and dict or list internals thread safe adds real overhead to single-threaded performance, and a large share of the C extension ecosystem, numpy included, needs updates before it's safe to run without GIL protection. For most teams this means multiprocessing and asyncio remain the right tools for concurrency exactly as before, free-threaded builds matter today mostly to extension authors testing compatibility and to pure-Python, CPU-bound workloads with no C extension dependencies, which is a narrower slice of real code than it sounds.
Start with cProfile for a coarse pass, python -m cProfile -s cumulative script.py, or wrap the suspect function with cProfile.Profile() inside a running service. It shows which functions consume the most cumulative time and how often they're called, often enough on its own to spot an N+1 query or an accidentally quadratic loop.
If the hot spot is inside one function and you need line-by-line detail, line_profiler with @profile and kernprof shows time spent per line, useful when a single function has a suspicious loop or a lot of string concatenation. For a live production process you can't restart with instrumentation, py-spy attaches to a running PID with no code changes at all, py-spy top gives a live view like top for Python call stacks, py-spy dump gives a one-shot stack trace of every thread, which is the tool for something hanging or spinning in production right now. Once you know the actual hot line, check whether it's an algorithmic problem, like an "in" check against a list instead of a set, before reaching for micro-optimizations, that's almost always the bigger win.
Across the Python-focused mock interviews we run at LastRoundAI, the question that ends a session early isn't usually a hard algorithm. It's a GIL or concurrency follow-up: a candidate correctly explains what the GIL is, then can't say why asyncio doesn't help a CPU-bound function, or why a multiprocessing worker needs picklable arguments. The first answer is memorized. The follow-up needs actual understanding, and that gap is where sessions stall.
The second pattern: candidates who've mostly worked inside a framework, Django, FastAPI, pandas, sometimes struggle with the core-language questions specifically because the framework hid the underlying behavior from them. Someone who's spent two years writing Django views may never have hit the mutable-default-argument bug directly, since the framework's own view functions rarely take mutable defaults. That doesn't make them a weaker engineer. It means the interview is testing something a little different from what their day job actually tests.

