← All interview questions

Top Python Interview Questions & Answers

Python interviews mix language-specific gotchas (mutability, GIL) with general programming fundamentals. These questions cover what shows up most frequently across backend and data roles.

Take Practice Interview now
Speak your answers out loud and get a scored report in minutes — free trial included.
Start Free →

1. What is the Global Interpreter Lock (GIL) and why does it matter?

The GIL is a mutex in CPython that allows only one thread to execute Python bytecode at a time, even on multi-core machines. This means CPU-bound multi-threaded code doesn't get true parallelism — for CPU-bound work, multiprocessing (separate processes, separate memory) is used instead. The GIL doesn't hurt I/O-bound concurrency much, since threads release it during I/O waits, which is why threading still works well for network/file-bound tasks. Worth knowing for interviews: CPython 3.13 introduced an experimental free-threaded build (PEP 703) that can disable the GIL, but it's not the default yet and most production code still runs under the classic GIL-enabled interpreter.

2. What is the difference between a list and a tuple?

Lists are mutable (you can append, remove, or modify elements) and are typically used for homogeneous collections that change over time. Tuples are immutable — once created, their contents can't change — and are often used for fixed collections or as dictionary keys/set members, which requires hashability that mutable lists don't have. Because tuples are immutable, they're also slightly more memory-efficient and marginally faster to create than lists. A common convention is to use tuples for heterogeneous, fixed-structure data (like a coordinate pair) and lists for homogeneous, growable sequences.

3. Explain Python's mutable default argument pitfall.

Default argument values are evaluated once, at function definition time, not on every call. If you use a mutable default like def f(x, items=[]), that same list object is reused across every call that doesn't pass items explicitly, so mutations persist between calls in surprising ways. The standard fix is to default to None and create a new mutable object inside the function body, e.g. if items is None: items = []. This pitfall applies to any mutable default — lists, dicts, sets, or custom mutable objects — not just lists.

4. What is a decorator in Python?

A decorator is a function that wraps another function (or class) to extend its behavior without modifying its source code, using the @decorator syntax as sugar for func = decorator(func). Common uses include logging, timing, access control, and caching (functools.lru_cache is a built-in example) — the decorator receives the original function, typically returns a new function that adds behavior before/after calling the original. Well-written decorators use functools.wraps to preserve the wrapped function's name and docstring, which otherwise get overwritten by the wrapper. Decorators can also take their own arguments by adding an extra layer of nested functions.

5. What is the difference between deep copy and shallow copy?

A shallow copy (copy.copy) creates a new outer object but still references the same nested/inner objects as the original — mutating a nested list inside a shallow copy affects the original too. A deep copy (copy.deepcopy) recursively copies every nested object, producing a fully independent structure with no shared references at any level. For flat structures with only immutable elements, shallow and deep copies behave identically, so the distinction only matters once you have nested mutable containers. Deep copying is more expensive, so it's worth avoiding when a shallow copy is sufficient.

6. How does exception handling work with try/except/else/finally?

try holds code that might raise an exception; except catches and handles specific exception types (more specific types should be listed before general ones); else runs only if no exception was raised in the try block, useful for code that should run only on success; finally always runs regardless of whether an exception occurred, typically used for cleanup like closing files or connections. Multiple except clauses can be chained to handle different exception types differently, and a bare except should generally be avoided since it also catches things like KeyboardInterrupt. You can also re-raise the original exception with a bare raise inside an except block if you want to log it but still propagate it.

7. What are Python generators and why use them?

A generator is a function that uses yield instead of return, producing a sequence of values lazily — one at a time, on demand — rather than building the entire result in memory upfront. This makes them memory-efficient for large or infinite sequences, and they maintain their local state between yields automatically, which is useful for streaming data processing pipelines. Generators are also iterators, so they work directly with for loops, next(), and any function expecting an iterable. Generator expressions (using parentheses instead of brackets, like (x*x for x in range(10))) give you the same laziness as a comprehension without materializing the full list.

8. What's the difference between @staticmethod, @classmethod, and instance methods?

An instance method takes self and can access/modify instance state. A @classmethod takes cls instead and operates on the class itself (e.g., alternative constructors), receiving the class, not an instance, as its first argument. A @staticmethod takes neither self nor cls — it's logically grouped with the class but doesn't touch instance or class state, essentially a plain function namespaced under the class. A classic use case for @classmethod is a factory method like from_string(cls, s) that parses input and returns a new instance, which subclasses inherit correctly because cls refers to whichever class it was called on.

9. What's the difference between list, dict, and set comprehensions?

A comprehension is a compact syntax for building a collection by applying an expression to every item in an iterable, optionally filtering with an if clause. List comprehensions ([x for x in items]) build a list, set comprehensions ({x for x in items}) build a set with automatic deduplication, and dict comprehensions ({k: v for k, v in items}) build key-value mappings. They're generally faster and more readable than the equivalent for-loop-with-append pattern because the looping happens in optimized C code under the hood. Nested comprehensions and multiple if clauses are possible but can hurt readability quickly, so it's common practice to fall back to a regular loop once a comprehension gets too dense.

10. What are *args and **kwargs used for?

*args collects any extra positional arguments into a tuple, and **kwargs collects any extra keyword arguments into a dict, letting a function accept a variable number of arguments without predefining every parameter name. They're commonly used in wrapper functions and decorators that need to forward arbitrary arguments to the function they wrap, since the wrapper doesn't know in advance what signature the wrapped function has. The names args and kwargs are just convention — what matters is the * and ** prefixes, which control how the arguments are packed and unpacked. The same syntax also works in reverse at a call site, e.g. f(*my_list, **my_dict), to unpack a sequence or mapping into positional and keyword arguments.

11. What is a context manager and how does the with statement work?

A context manager is an object that defines setup and teardown behavior around a block of code, implemented via the __enter__ and __exit__ dunder methods. The with statement calls __enter__ before running the block and guarantees __exit__ runs afterward, even if an exception is raised inside the block, which makes it ideal for resource management like closing files, releasing locks, or committing/rolling back database transactions. The classic example is with open('file.txt') as f, which ensures the file is closed automatically regardless of how the block exits. The contextlib module's @contextmanager decorator lets you write a context manager as a generator function with a single yield instead of a full class with both dunder methods.

12. What's the difference between an iterable and an iterator?

An iterable is any object you can loop over — it implements __iter__, which returns an iterator, so lists, tuples, dicts, and strings are all iterables. An iterator is the object that actually does the iterating — it implements __next__ (and typically __iter__ returning itself), keeps track of its current position, and raises StopIteration when exhausted. Calling iter() on an iterable produces a fresh iterator, which is why you can loop over the same list multiple times but can only exhaust a given iterator once. This distinction is also why generators are both iterators and iterables, since a generator object implements both methods automatically.

13. How do threading, multiprocessing, and asyncio differ, and when would you use each?

Threading uses multiple OS threads within one process sharing memory, but the GIL limits it to true concurrency only for I/O-bound work, since CPU-bound threads still take turns executing bytecode. Multiprocessing runs separate Python processes with separate memory and separate GILs, giving real parallelism for CPU-bound tasks at the cost of higher memory usage and the need to serialize data (pickle) between processes. Asyncio runs a single-threaded event loop that cooperatively switches between coroutines whenever one hits an await on an I/O operation, giving very high concurrency for I/O-bound work without the overhead of threads or processes, but it requires async-aware libraries throughout the call stack. The rough rule of thumb: CPU-bound → multiprocessing, I/O-bound with many concurrent connections → asyncio, I/O-bound with simpler blocking libraries or when you need to mix with existing sync code → threading.

14. How does Python manage memory, and what role does reference counting play?

CPython's primary memory management mechanism is reference counting — every object keeps a count of how many references point to it, and when that count drops to zero, the object is deallocated immediately. This handles most memory cleanup automatically and deterministically, but it can't detect reference cycles, like two objects that reference each other and are otherwise unreachable. To handle that, CPython also has a generational cyclic garbage collector (the gc module) that periodically scans for and cleans up unreachable cycles. In practice, this means you rarely need to manage memory manually, but circular references (e.g. in a doubly linked list or observer pattern) rely on the cyclic collector rather than reference counting alone to be freed.

15. What's the difference between __init__ and __new__?

__new__ is the actual object constructor — it's a static method (implicitly) that creates and returns a new instance of the class, called before __init__. __init__ is the initializer — it receives the already-created instance as self and sets up its initial state, but it doesn't return anything (returning non-None from __init__ raises a TypeError). You rarely need to override __new__ unless you're subclassing an immutable type (like str or tuple), implementing a singleton, or controlling instance creation itself, since __init__ covers the vast majority of setup needs. When both are defined, Python calls __new__ first to get the instance, then calls __init__ on that instance with the same arguments.

16. What are magic (dunder) methods, and can you give some examples?

Magic methods, also called dunder (double-underscore) methods, are special methods like __init__, __str__, and __len__ that Python calls implicitly to implement operator overloading and built-in behavior for custom classes. For example, defining __str__ controls what str(obj) and print(obj) show, __eq__ controls how == compares two instances, __len__ lets len(obj) work, and __getitem__ lets an object support indexing like obj[0]. Implementing __enter__/__exit__ makes an object usable in a with statement, and __iter__/__next__ makes it usable in a for loop. Using dunder methods lets custom objects integrate naturally with Python's built-in syntax and functions instead of requiring custom method names for common operations.

17. What are type hints, and are they enforced at runtime?

Type hints let you annotate variables, function parameters, and return values with expected types, e.g. def add(a: int, b: int) -> int, primarily to improve readability and enable static analysis tools like mypy or pyright to catch type errors before runtime. Python itself does not enforce these hints at runtime — they're purely advisory, so passing a string where an int is annotated won't raise an error unless you add explicit validation or use a library like pydantic that does enforce them. Hints are especially valuable in larger codebases and team settings, where they act as documentation and let IDEs provide better autocomplete and inline error checking. The typing module provides constructs like Optional, Union, List, and more recently, built-in generics (list[int] instead of List[int]) were added in Python 3.9+.

18. What is the walrus operator and when would you use it?

The walrus operator (:=), introduced in Python 3.8, lets you assign a value to a variable as part of a larger expression, rather than requiring a separate assignment statement beforehand. A common use case is in a while loop condition, like while (chunk := file.read(1024)): process(chunk), which avoids the awkward pattern of assigning before the loop and again at the end of the loop body. It's also handy inside list comprehensions or if statements when you want to both compute and reuse a value without calling the same expensive expression twice. It doesn't add new capability so much as reduce repetition and improve readability in specific, fairly narrow situations — overusing it can hurt clarity.

Related topics
JavaJavaScriptTypeScript