Python interview questions: data types, list vs tuple, GIL, decorators, generators and PEP 8.
30 questions
Tuples are slightly more memory efficient and signal intent that the data is constant.
The GIL (Global Interpreter Lock) allows only one thread to execute Python bytecode at a time, protecting CPython internal state. This means CPU-bound Python code does not speed up with threads.
For CPU-bound work use multiple processes (multiprocessing, each gets its own GIL) or C-based libraries (numpy). For I/O-bound work threads or asyncio work well because the GIL is released during blocking I/O.
A decorator is a function that takes a function and returns a new one, adding behaviour without changing the original source. The @name syntax applies it:
def timer(fn):
def wrapper(*args, **kwargs):
import time
t0 = time.perf_counter()
r = fn(*args, **kwargs)
print(fn.__name__, time.perf_counter() - t0)
return r
return wrapper
@timer
def work():
...Decorators are used for logging, caching, authentication and input validation.
A generator is a function that uses yield instead of return, producing values lazily one at a time while preserving its state between calls. It does not build the whole sequence in memory.
def evens(limit):
n = 0
while n < limit:
yield n
n += 2Great for large or infinite streams: read a huge file line by line without loading it all.
with open("file.txt") as f: guarantees the file is closed even if an exception occurs. The with protocol calls __enter__ on entry and __exit__ on exit (also on errors).
Locks, database connections and files should all be used as context managers to release resources reliably - no manual close/finally needed.
PEP 8 is the official style guide: 4-space indentation, snake_case for variables/functions, CamelCase for classes, blank lines around top-level definitions, 79-column line limit and imports at the top. It is not enforced by the compiler but is expected in shared code, and tools like flake8/black help follow it.
List - ordered, mutable, indexed, duplicates allowed: [1, 2, 3]. Tuple - ordered, immutable, often used as a fixed record or dict key: (1, 2). Dictionary - unordered key-value mappings with fast lookups: {"a": 1}. Also: set - unordered unique elements for fast membership tests.
A shallow copy (list.copy(), copy.copy()) duplicates the outer container but shares nested objects; mutating a nested list affects the original. A deep copy (copy.deepcopy()) recursively duplicates everything, so the two structures share nothing. For JSON-like data you can also emulate a deep copy with json.loads(json.dumps(x)).
The GIL allows only one Python thread to execute bytecode at a time, even on multi-core machines. Pure-CPU work in threads does not speed up - use multiprocessing (separate processes, each with its own interpreter) for CPU-bound tasks. I/O-bound tasks still benefit from threads because the GIL is released while waiting on I/O.
A normal function computes and returns a result immediately. A generator (contains yield) returns an iterator that produces values lazily, one by one, pausing between yields - so it can process streams without holding everything in memory:
def squares(n):
for i in range(n):
yield i * ireturn ends the function and hands back a value, terminating the generator (a return value in a generator marks it done). yield produces the next value of the sequence and pauses execution, resuming on the next next(); the generator state is preserved between yields. A generator can have both: yield values along the way and return to stop.
A decorator is a function that takes a function/class and returns a wrapped version - used with the @ syntax to add cross-cutting behavior (logging, timing, auth). A context manager defines __enter__/__exit__ and is used with with to manage resources (open files, DB connections, locks) with guaranteed cleanup:
with open("f.txt") as f: data = f.read()== compares values (calls __eq__), while is compares object identity (same memory address). Small integers and short strings are cached, so 5 is 5 may be True, but large objects rarely are. Use is only for singletons: None, True, False.
Python supports multiple inheritance and resolves methods by the C3 linearization, computed into __mro__. It orders ancestors so each class appears before its bases and the order is consistent (no inconsistency throws). super() follows the MRO chain, letting each class cooperate:
class A:
class B(A):
class C(A):
class D(B, C): # MRO: D, B, C, A__repr__ returns an unambiguous, developer-oriented string - ideally code that recreates the object; shown in the REPL and in containers. __str__ returns a readable, user-oriented string; used by print() and str(). If only __repr__ is defined, it serves both. Example: repr of datetime is datetime(2026,1,1,...), str is "2026-01-01".
type(x) returns the exact type of x; it does not consider inheritance, so type(child) == Parent is False. isinstance(x, cls) returns True when x is an instance of cls or of any subclass - it respects inheritance and accepts tuples of classes. Prefer isinstance() for flexible, polymorphic checks.
Use for k in d for keys, for v in d.values() for values, for k, v in d.items() for both. To remove while iterating, iterate over a copy (for k in list(d)) or collect keys first. For ordered access, dicts preserve insertion order (Python 3.7+).
PEP 8 is Python's style guide. Key rules: 4 spaces per indentation level, lines up to 79 characters, snake_case for functions/variables, PascalCase for classes, UPPER_CASE for constants, blank lines around functions/classes, and meaningful names. Tools like black, flake8 and pylint automate compliance.
*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. They also unpack when calling: f(*lst) and f(**d). Use them for flexible APIs, wrappers/decorators and forwarding arguments to base functions.
Immutable types cannot be changed after creation: int, float, str, tuple, bytes, frozenset. Any "change" creates a new object. Mutable types can change in place: list, dict, set, bytearray, and custom objects. This matters for defaults (never use a mutable default argument), hashing (only immutable types are generally hashable) and function argument side effects.
An instance method receives self and can access instance state. A classmethod (@classmethod) receives cls, can access class state and is often a factory: cls.from_string(s). A staticmethod (@staticmethod) receives neither - it is just a function grouped in the class, used for utilities that do not need the class.
__new__ is a classmethod that creates and returns the instance; it runs first and rarely needs overriding (singletons, immutable subclasses). __init__ then initializes the already-created instance. In practice you override __init__; __new__ is needed when the object is created by machinery rather than your constructor logic.
A global is declared at module level and visible everywhere; reading is fine inside functions, but assigning creates a local unless you declare global x. A local exists only inside its function/scope. Prefer local variables and passing arguments - global mutation is a common source of bugs. nonlocal allows rebinding in an enclosing (non-global) scope, as in closures.
for iterates over an iterable (list, dict, range, file lines) - clearer and safer because it manages the iterator for you. while repeats while a condition stays True - use it when the number of iterations is unknown (menu loops, waiting for a condition). Both support break/continue/else.
f-strings (f"...") inline expressions directly: f"User {name}, score {score:.2f}" - readable, fast and support format specs. They replaced %-formatting ("%s" % name), .format() ("{}".format(x)) and concatenation. Python 3.8+ also added f"{expr=}" for debugging output. Keep f-strings free of complex embedded expressions for readability.
A module is a single Python file with code. A package is a directory of modules with an __init__.py (namespace packages may omit it). A library is a broader collection - usually one or more packages distributed together (Pillow, requests). Import paths use dots: from package.module import name.
try contains the risky code; except SomeError as e (or multiple excepts, tuple of errors, and bare except for anything) handles it; else runs when no exception occurred; finally always runs for cleanup. Avoid bare except, catch specific exceptions, and re-raise with raise when you only log. Modern style uses raise ... from e to preserve context.
Exceptions are the mechanism for handling errors - Python raises them and they can be caught. Errors (in Python) are a subclass of Exception (SyntaxError, TypeError) - the term is used loosely; many "errors" are just exception classes. Warnings are issued by the warnings module for non-fatal issues (deprecations) and do not interrupt execution - shown as "UserWarning" unless turned into errors.
requests is the third-party de-facto HTTP library - clean API, sessions, JSON helpers (requests.get(url).json()). urllib is stdlib (urllib.request etc.) - decent but more verbose and lower level. http.client is the lowest-level stdlib HTTP. For real projects use requests (or httpx); stdlib options exist when you cannot add dependencies.
A single _ is a throwaway variable (ignored loop value) or a weak "internal use" marker. __name (double leading underscore) triggers name mangling to _Class__name inside classes - used to avoid subclasses clobbering attributes. __name__ (dunder, leading and trailing) is reserved for Python-defined special methods (__init__, __len__) and should rarely be invented.