Python Interview Questions
Real Python interview questions and answers covering fundamentals, data structures, concurrency and decorators.
A curated set of Python interview questions, ordered roughly from fundamentals to more advanced — the kind you'll actually be asked in real screens and on-sites.
Language fundamentals
Q: What's the difference between is and ==?
== checks for value equality — whether two objects contain the same data (it calls __eq__ under the hood). is checks for identity — whether two names refer to the exact same object in memory. Two separately-created lists with identical contents are == but not is. Interviewers often follow up asking why a is b can be True for small integers or short strings — that's CPython caching small ints (-5 to 256) and interned string literals as an implementation detail, not something you should ever rely on.
Q: What is the mutable default argument gotcha?
Default argument values are evaluated exactly once, when the function is defined — not on each call. So def f(items=[]): reuses the same list object across every call that doesn't pass its own, silently accumulating state between unrelated calls. The fix is to default to None and create the mutable object ([], {}) inside the function body instead.
Q: What's the difference between a shallow copy and a deep copy?
copy.copy() (shallow) creates a new outer object but still references the same nested objects inside it — mutating a nested list inside a shallow copy affects the original too. copy.deepcopy() recursively copies every nested object, producing a fully independent structure. Simple assignment (b = a) doesn't copy at all — it just gives a second name to the same object.
Data structures
Q: When would you choose a generator over a list?
When you don't need all the values in memory at once — processing a huge file line by line, streaming API results, or an effectively infinite sequence. A generator computes each value lazily on demand (via yield), using a small constant amount of memory regardless of how many values it eventually produces, at the cost of only being iterable once.
Q: Why can't you use a list as a dictionary key? Dictionary keys must be hashable, meaning their hash value can never change over their lifetime — a requirement that guarantees the key can always be found again in the same bucket. Lists are mutable, so their contents (and therefore their hash) could change after insertion, which would silently break the dictionary's internal lookup structure. Tuples, strings, and numbers are hashable (as long as a tuple's own contents are also all hashable) and are valid dict keys.
Concurrency
Q: What is the GIL, and why does it exist?
The Global Interpreter Lock is a mutex in CPython that ensures only one thread executes Python bytecode at a time, even on a multi-core CPU. It exists because CPython uses simple reference counting for memory management, and the GIL makes that reference counting thread-safe without requiring fine-grained locks throughout the interpreter. The practical consequence: threading doesn't speed up CPU-bound pure-Python code, but it does help I/O-bound code, since a thread releases the GIL while blocked waiting on network or disk I/O. True CPU parallelism requires multiprocessing (separate processes, each with its own GIL) or a C-extension library that releases the GIL during heavy computation (like NumPy).
Functions and decorators
Q: What does a decorator actually do?
A decorator is a function that takes another function as input and returns a new function (usually a wrapper) that adds behavior around the original — logging, timing, caching, retries, or access checks — without changing the original function's source code. @my_decorator above a function definition is syntactic sugar for func = my_decorator(func). Using functools.wraps inside the wrapper preserves the original function's name and docstring, which debugging tools and documentation generators rely on.
Error handling
Q: What's the difference between except's else clause and code placed after the entire try/except statement?
else only runs if the try block raised no exception, and it isn't covered by the except clauses above it — an exception raised inside else propagates as a new, unhandled exception rather than being caught by those clauses. Code placed after the whole try/except statement runs unconditionally once the statement finishes, regardless of whether an exception occurred and was handled.
Q: Why use raise NewError(...) from original_error instead of just raise NewError(...) inside an except block?
Both raise the new exception, but from explicitly records the causal relationship, so the printed traceback clearly shows "the above exception was the direct cause of the following exception," including both tracebacks. A plain raise inside an except block still shows the original exception in the traceback by default, but from makes that link deliberate and explicit rather than incidental — and raise NewError(...) from None is the opposite case, explicitly suppressing the original traceback when it's genuinely irrelevant.
Files, modules, and packaging
Q: What does if __name__ == "__main__": actually check?
Python sets a module's built-in __name__ variable to "__main__" only when that file is executed directly, and to the module's own name when it's imported from elsewhere. Guarding a script's top-level "do the work" logic with this check lets the same file act as both a standalone runnable script and a safely importable library — an import elsewhere won't re-trigger that logic.
Q: Why does Python's with statement matter specifically for file handling?
with open(...) as f: guarantees f.close() runs when the block exits, even if an exception is raised while reading or writing — a bare f = open(...) followed by f.close() skips that close entirely if something in between raises, leaking the file handle. open() works with with because it returns a context manager, implementing __enter__/__exit__, the same protocol used for database connections, locks, and other resources needing guaranteed cleanup.
Testing and tooling
Q: How does pytest discover which functions are tests, and why does that matter?
By convention: files named test_*.py or *_test.py, containing functions named test_* (or methods on a Test*-named class) — no test registration or base class required. It matters because a misnamed file or function is silently skipped by pytest's default discovery with no error raised at all, which is a common, easy-to-miss mistake when a new test doesn't seem to run.
Q: What's the point of a virtual environment, and what actually goes wrong without one?
It gives each project its own isolated copy of installed packages, separate from the system Python and from every other project's environment. Without one, installing packages globally means two projects needing conflicting versions of the same dependency (say, django==4.2 vs django==5.1) can't both work on the same machine at once — installing one version for one project silently breaks the other.