Python Interview Questions & Answers
Python interview questions spanning core language features, data structures, and idiomatic patterns used across backend, scripting, and data roles.
18 Questions
~27 min read
Beginner: 3
Intermediate: 10
Advanced: 5
Coding 4
Lists are mutable and defined with square brackets; tuples are immutable and defined with parentheses.
Detailed Answer
Because tuples can't be changed after creation, they're slightly more memory-efficient and can be used as dictionary keys or set members (lists cannot, since they're unhashable). Lists support in-place operations like append, remove, and sort. Use a tuple to represent a fixed, small collection of related values (like coordinates), and a list when the collection needs to grow, shrink, or be reordered.
A list is an ordered sequence of values accessed by integer index; a dictionary is a collection of key-value pairs accessed by key, offering fast average O(1) lookup.
Detailed Answer
Use a list when order matters and you access items positionally or iterate through all of them. Use a dictionary when you need fast lookup by a meaningful key (e.g. mapping a user id to a user object) rather than searching through a list linearly.
A list comprehension builds a new list from an iterable in a single, concise expression (e.g. [x * 2 for x in nums if x > 0]); avoid them when the logic becomes too complex to read at a glance.
Detailed Answer
List comprehensions are generally faster and more idiomatic than an equivalent for-loop with .append() calls, and they read well for simple transform/filter operations. Once you need multiple nested loops, complex conditional branches, or side effects, a comprehension becomes hard to read, and a regular loop (or breaking the logic into a named function) communicates intent more clearly.
Best Practices
Keep comprehensions to one or two clauses; if it needs a third nested loop or multiple conditions to express, write a regular loop instead.
Common Mistakes
Nesting multiple loops and conditions into a single comprehension until it becomes unreadable, trading clarity for a small (and often negligible) performance gain.
Use try/except to catch specific exception types, and finally for cleanup code that must run whether or not an exception occurred.
Detailed Answer
Catching a specific exception type (e.g. `except ValueError:`) rather than a bare `except:` avoids silently swallowing unrelated bugs like a KeyboardInterrupt or a typo causing a NameError. The finally block always executes -- even if the try block returns or the except block raises a new exception -- making it the right place for resource cleanup that isn't already handled by a context manager (with statement).
Best Practices
Prefer a context manager (with open(...) as f:) over manual try/finally for resource cleanup wherever one is available -- it's more concise and less error-prone.
Common Mistakes
Using a bare except: clause, which also catches things like SystemExit and KeyboardInterrupt and can hide real bugs.
Conceptual 10
The GIL is a mutex that allows only one thread to execute Python bytecode at a time in CPython, which means CPU-bound multithreaded code doesn't get true parallelism, though I/O-bound threads still benefit.
Detailed Answer
Because only one thread can hold the GIL at once, spinning up multiple threads to speed up a CPU-heavy computation (like number crunching) typically doesn't help and can even hurt due to context-switching overhead. I/O-bound work (network calls, file I/O) still benefits from threading because the GIL is released while waiting on I/O. For true CPU parallelism, Python code typically uses multiprocessing (separate processes, each with its own GIL) instead of threading.
Best Practices
Use multiprocessing (or a C-extension library that releases the GIL, like NumPy for heavy math) for CPU-bound parallelism; use threading or asyncio for I/O-bound concurrency.
Common Mistakes
Expecting threading to speed up a CPU-bound loop and being surprised it doesn't get faster (or gets slower) due to the GIL.
A generator uses yield to produce values one at a time, lazily, without holding the whole sequence in memory; a regular function that builds and returns a list computes and stores every value up front.
Detailed Answer
Calling a generator function returns a generator object immediately without running any of the function body -- each call to next() (or each iteration step) resumes execution until the next yield. This makes generators far more memory-efficient for large or infinite sequences, since only one value needs to exist in memory at a time, at the cost of being single-pass (you can't rewind or index into it like a list).
Best Practices
Use a generator (or a generator expression) when processing a large sequence you'll only iterate once, to avoid holding the entire dataset in memory.
Common Mistakes
Converting a generator to a list just to check its length or index into it, which defeats the whole memory benefit of using a generator in the first place.
A decorator is a function that wraps another function to extend or modify its behavior without changing its source code, applied with the @decorator_name syntax.
Detailed Answer
A decorator takes a function as input and returns a new function (usually one that calls the original, plus extra behavior before/after). `@my_decorator` above a function definition is equivalent to writing `my_func = my_decorator(my_func)`. Common real-world uses include logging, timing, caching (functools.lru_cache), and access control -- letting you add that behavior to any function with a single line rather than duplicating the logic.
Best Practices
Use functools.wraps inside your decorator so the wrapped function keeps its original name and docstring for debugging and introspection.
Common Mistakes
Writing a decorator without functools.wraps, which silently replaces the wrapped function's __name__ and docstring, making debugging and tooling (like help()) confusing.
A mutable default argument (like a list or dict) is created once, at function definition time, and shared across every call that doesn't pass its own value -- leading to unexpected state leaking between calls.
Detailed Answer
`def add_item(item, items=[]): items.append(item); return items` looks fine on the surface, but because the default list is created exactly once when the function is defined, every call that relies on the default keeps appending to the same shared list rather than starting fresh. The standard fix is to default to None and create a new list inside the function body if it wasn't provided.
Best Practices
Default mutable arguments to None, and create the actual mutable object inside the function body: `def add_item(item, items=None): items = items if items is not None else []`.
Common Mistakes
Using a mutable object (list, dict, set) as a default argument value directly, expecting a fresh one on every call.
A shallow copy duplicates the outer container but still references the same nested objects; a deep copy recursively duplicates everything, so nested objects are fully independent.
Detailed Answer
`copy.copy()` creates a shallow copy -- for a list of lists, the outer list is new, but the inner lists are the same objects, so mutating a nested list through the copy also affects the original. `copy.deepcopy()` recursively copies every nested object, producing a fully independent structure at the cost of more time and memory.
Common Mistakes
Assuming a shallow copy (or simple slicing like list[:]) fully isolates nested mutable data, then being surprised that mutating a nested list in the 'copy' also changed the original.
An instance method takes self and operates on a specific object; a classmethod takes cls and operates on the class itself (often used for alternate constructors); a staticmethod takes neither and is just a regular function namespaced inside the class.
Detailed Answer
A classmethod (decorated with @classmethod) is commonly used for factory methods that build an instance in an alternate way, e.g. `Point.from_tuple((x, y))`, and it receives the class itself so subclasses inherit the correct behavior automatically. A staticmethod doesn't need access to the instance or the class at all -- it's grouped inside the class purely for organizational/namespacing reasons.
*args collects any extra positional arguments into a tuple; **kwargs collects any extra keyword arguments into a dictionary.
Detailed Answer
`def f(*args, **kwargs)` lets a function accept an arbitrary number of positional and keyword arguments -- args becomes a tuple of the positional ones, kwargs becomes a dict of the keyword ones. This is commonly used for wrapper/decorator functions that need to forward whatever arguments they receive to another function without knowing its exact signature in advance.
CPython primarily uses reference counting -- an object is freed as soon as its reference count drops to zero -- supplemented by a cyclic garbage collector that detects and cleans up reference cycles reference counting alone can't free.
Detailed Answer
Every Python object tracks how many references point to it; when that count reaches zero, CPython deallocates it immediately, which is why memory is usually freed promptly and predictably. However, two objects that reference each other (a cycle) never naturally reach a zero count on their own, so CPython also runs a separate generational cyclic garbage collector periodically to detect and clean up these unreachable cycles.
Common Mistakes
Relying on __del__ methods to run at a precise, predictable time -- with reference cycles, __del__ execution can be delayed until the cyclic collector runs, not immediately when you expect.
== compares value equality; is compares object identity (whether both names refer to the exact same object in memory).
Detailed Answer
`a == b` calls `__eq__` and checks whether the two objects are considered equal by value. `a is b` checks whether `a` and `b` are literally the same object (same id()). Small integers and interned strings can appear to be `is`-equal due to CPython's internal caching, which is an implementation detail you shouldn't rely on -- `is` should be reserved for identity checks like `x is None`.
Best Practices
Use `is None` (not `== None`) for None checks -- it's both the idiomatic style and avoids relying on a custom __eq__ implementation.
Common Mistakes
Using `is` to compare two separately-created strings or numbers for equality and getting inconsistent results depending on CPython's internal small-object caching.
Python passes object references by value -- the reference itself is copied, but it points to the same underlying object, so mutating a mutable argument inside a function affects the caller's object.
Detailed Answer
Reassigning a parameter inside a function (`x = something_else`) only rebinds the local name and doesn't affect the caller's variable, since that just changes what the local reference points to. But calling a mutating method on that same object (like `my_list.append(x)`) does affect the caller's object, because both names still reference the identical object in memory. This dual behavior is why Python's model is often described as 'pass by object reference' rather than strictly pass-by-value or pass-by-reference.
Common Mistakes
Expecting reassigning a mutable parameter inside a function to change the caller's variable, when only in-place mutation (not reassignment) is visible to the caller.
Architecture 1
Multithreading suits I/O-bound work under the GIL's constraints; multiprocessing gives true parallelism for CPU-bound work by using separate processes; asyncio gives cooperative concurrency for I/O-bound work within a single thread, without OS thread overhead.
Detailed Answer
Threads share memory and are relatively cheap to create but are limited by the GIL for CPU-bound work. Processes each get their own Python interpreter and GIL, achieving genuine parallel CPU execution at the cost of higher memory usage and needing explicit inter-process communication. asyncio uses a single-threaded event loop with cooperative multitasking (via async/await) -- excellent for handling many concurrent I/O-bound operations (like thousands of open network connections) without the overhead of one OS thread per connection, but it requires the whole call chain to be async-aware.
Common Mistakes
Reaching for multiprocessing for an I/O-bound problem (like many concurrent HTTP requests), paying process-startup and IPC overhead for a problem asyncio or threading would solve more cheaply.
Performance 1
Use a profiler (cProfile, or line_profiler for line-level detail) to find where time is actually spent, then target that specific bottleneck rather than guessing.
Detailed Answer
cProfile gives a function-level breakdown of call counts and cumulative time, which usually reveals whether the bottleneck is an algorithmic issue (e.g. an O(n^2) loop that should be O(n) with a set/dict lookup), excessive object creation, or a slow external call (like a database query in a loop). Once the actual hotspot is identified, fixes range from switching data structures, vectorizing with NumPy for numeric work, caching repeated computations (functools.lru_cache), or batching I/O calls.
Best Practices
Profile before optimizing -- intuition about what's 'probably slow' in Python is frequently wrong, especially around string operations and attribute lookups.
Common Mistakes
Micro-optimizing code that profiling never actually flagged as a bottleneck, while the real hot path (often a database call or a nested loop) goes untouched.
Behavioral 1
A strong answer identifies the actual duplication or complexity, extracts it into a well-named function/class/module, and verifies existing behavior didn't change (ideally backed by tests).
Detailed Answer
Interviewers listen for a concrete example: recognizing the same logic copy-pasted in a few places (or a function doing too many unrelated things), extracting a shared helper or splitting responsibilities into smaller functions/classes, and confirming nothing broke -- either via existing tests or by adding tests first if none existed. Mentioning readability and future maintainability as the motivation (not just 'it looked ugly') signals good engineering judgment.
Scenario-Based 1
Stream the file line-by-line (or in fixed-size chunks) using a generator-based approach instead of reading the whole file into memory at once.
Detailed Answer
Opening the file with a `with open(path) as f:` and iterating `for line in f:` reads one line at a time under the hood rather than loading the entire file, keeping memory usage flat regardless of file size. For binary or non-line-delimited data, reading fixed-size chunks (`f.read(chunk_size)`) in a loop achieves the same effect. If the processing itself needs to build an aggregate result, use streaming/incremental aggregation (running totals, a generator pipeline) rather than collecting every intermediate value into a list first.
Best Practices
Process data as a stream (generators, line-by-line iteration) rather than materializing the whole dataset into a list or DataFrame when the input might be arbitrarily large.
No questions match your filters.