All Blog Posts

Every article published on Tanveer Qureshie Tech, newest first.

Loading all posts...

150+ Python Interview Questions — From Basics to Advanced | Tanveer Qureshie

Designed by Tanveer Qureshie

Python Interview Field Guide

182 interview questions, fully ordered from fundamentals to advanced, with deep, easy-to-follow explanations, comparison tables, original diagrams, and full coverage of the data-science and web-backend stack: NumPy, pandas, Matplotlib/Seaborn, decorators, concurrency & async, and Flask/Django/SQLAlchemy. Tick each question off as you master it.

182 questions 14 sections, basic to advanced Diagrams and comparison tables throughout
YOUR PROGRESS0 / 182 questions completed (0%)
SECTION 01 · BEGINNER

Python Fundamentals

What Python is, how it runs, and the basic building blocks every other section assumes you already know.

12 questions
001What is Python, and why is it so widely used?Beginner+

Python is a high-level, general-purpose, dynamically typed programming language whose main design goal is readability — code is meant to read almost like plain English, using indentation instead of braces and simple keywords instead of dense symbols. That readability isn't just a nicety; it's a big part of why teams can onboard new developers quickly and why Python code tends to stay maintainable years later.

Its popularity comes from a rare combination: one single language realistically covers web backends (Flask/Django), data science and machine learning (pandas, NumPy, PyTorch), automation/scripting, and even embedded/IoT work — so a developer's skills transfer across very different jobs. On top of that, PyPI (the Python Package Index) is one of the largest package ecosystems of any language, meaning there's almost always a well-maintained library instead of having to build something from scratch.

print("Hello, interview!")  # runs immediately, no compile step visible to you
002Is Python interpreted, compiled, or both?Beginner+

The honest answer is 'both, in a specific way' — this is a common trick question because people expect a single clean label. CPython (the standard, most widely used implementation of Python) first compiles your `.py` source file into a lower-level, platform-independent representation called bytecode — this happens automatically and is cached as `.pyc` files so it doesn't need to be redone every single run if the source hasn't changed.

That bytecode is then run by the Python Virtual Machine (PVM), which interprets it instruction by instruction — this is the actual 'interpreted' part. So the accurate description is: 'compile once to bytecode, then interpret that bytecode' — not pure line-by-line interpretation of the raw source text, and also not a fully compiled machine-code executable like C produces.

# behind the scenes: script.py -> compiled to bytecode -> cached as __pycache__/script.cpython-3xx.pyc -> interpreted by the PVM
003Python 2 vs Python 3 — what actually changed?Beginner+

Python 2 officially reached end-of-life in January 2020, meaning it no longer receives security patches — every new project, tutorial, and library today targets Python 3, so recognizing legacy Python 2 code (and knowing what would break it) is still a relevant interview signal even though you'll rarely write Python 2 yourself.

The headline breaking changes: `print` changed from a statement (`print "hi"`) to a real function (`print("hi")`), which lets it take keyword arguments like `sep=` and `end=`. Division with `/` now always returns a float (true division) instead of silently truncating for two integers — you need the explicit `//` floor-division operator to get the old integer-truncating behavior back. Strings also became Unicode by default in Python 3, fixing a huge source of text-encoding bugs, and `range()` now behaves lazily like Python 2's old `xrange()` did, so it no longer builds a full list in memory.

# Python 2:  print "hi"      | Python 3: print("hi")
# Python 2:  5 / 2 == 2      | Python 3: 5 / 2 == 2.5  (use 5 // 2 for floor)
004What is dynamic typing, and is Python strongly or weakly typed?Beginner+

Type-checking can happen either at compile time (a static language like Java checks types before the program ever runs) or at run time (a dynamic language checks as each line actually executes). Python is dynamically typed: a variable name isn't bound to one fixed type forever — the same name can point to an integer at one moment and a string a moment later, because in Python a 'variable' is really just a label pointing at an object, and the object carries its own type.

Separately from static/dynamic, there's strong/weak typing — this measures how willing the language is to silently convert between incompatible types. Python is strongly typed: it will not silently coerce a string and an integer together in an operation like `+`, and instead raises a clear `TypeError` — this is different from a weakly-typed language like JavaScript, where `"1" + 2` silently produces `"12"` instead of erroring. Being both dynamic AND strong is exactly what makes Python flexible to write quickly, while still catching real type mistakes loudly instead of hiding them.

x = 5        # x is int
x = "five"   # now x is str — no declaration needed, but no silent coercion either
"1" + 2      # TypeError: can only concatenate str (not "int") to str
005What are Python's built-in data type families?Beginner+

Python organizes its built-in types into a handful of conceptual families, and being able to name them cleanly (rather than just listing random types) is a good sign of organized thinking in an interview. Numeric types (`int`, `float`, `complex`) represent numbers. Sequence types (`str`, `list`, `tuple`, `range`) represent ordered collections you can index and slice. Mapping types (`dict`) represent key-to-value associations.

Set types (`set`, `frozenset`) represent unordered collections of unique values, useful for membership testing and set math. `bool` is technically a subtype of `int` (True behaves as 1, False as 0 in arithmetic) representing truth values, and `NoneType` (with its single value `None`) represents the deliberate absence of a value. Section 02 goes much deeper into the sequence, mapping, and set families specifically, since those are what interviewers probe hardest.

type(5), type(5.0), type(1+2j)          # numeric family
type("a"), type([1,2]), type((1,2))     # sequence family
type({"k":1}), type({1,2}), type(None)  # mapping / set / NoneType
Comparison · Mutable vs Immutable Built-ins
CategoryTypesCan change in place?Hashable (usable as dict key)?
Immutableint, float, str, tuple, frozenset, boolNo — any "change" makes a new objectYes (if contents are hashable)
Mutablelist, dict, set, bytearrayYes — same object, new contentsNo
006How does Python decide code blocks — braces or indentation?Beginner+

Unlike C, Java, or JavaScript, Python has no `{ }` characters to mark the start and end of a block — instead, a consistent level of indentation IS the block boundary. Every statement inside an `if`, `for`, `while`, `def`, or `class` must be indented one consistent step further than the line that introduced it, and PEP 8 (Python's style guide) recommends exactly 4 spaces per level as the convention almost the entire ecosystem follows.

This design choice was deliberate: it forces code that LOOKS correctly nested to actually BE correctly nested, eliminating an entire category of bugs where brace-based languages have visually misleading indentation that doesn't match the real block structure. One practical gotcha: Python refuses to guess when tabs and spaces are mixed inconsistently within the same block, and raises a `TabError` rather than silently picking one — almost every modern editor is configured to insert spaces automatically to avoid this entirely.

if True:
    print("inside the block")   # 4-space indent
print("outside the block")
007Comments vs docstrings — what's the difference?Beginner+

A `#` comment is purely for human readers of the source code — the interpreter strips it out before execution, and no tool at runtime can ever see or retrieve it. It's meant for quick, local notes like explaining a tricky line or leaving a TODO.

A docstring is a triple-quoted string literal placed as the very first statement inside a module, function, or class body — Python treats this specially and stores it as the object's `__doc__` attribute, which means it's introspectable at runtime. This is exactly what powers `help(my_function)` in the interactive shell, and what IDEs show you as a tooltip when you hover over a function call — a comment can never do either of those things, which is why public functions and classes should always get a docstring, not just a comment.

def add(a, b):
    """Return the sum of a and b."""   # docstring — introspectable
    return a + b  # comment — not introspectable
008How do f-strings work, and why prefer them?Beginner+

An f-string is a string literal prefixed with `f` (or `F`) that lets you embed live Python expressions directly inside `{}` placeholders — the expression is evaluated at the moment the f-string itself is evaluated, so it always reflects the current value of a variable, not a value captured earlier. You can also add a format specifier after a colon inside the braces, like `{score:.1f}` to show exactly one decimal place.

They're preferred over the two older styles — `%`-formatting (`"%s scored %.1f" % (name, score)`) and `.format()` (`"{} scored {:.1f}".format(name, score)`) — for two concrete reasons: f-strings are measurably faster at runtime because the formatting is resolved essentially at parse time rather than through a separate method call, and they're more readable since the variable name sits directly where it's used instead of in a separate argument list you have to visually match up positionally.

name, score = "Sara", 95.456
print(f"{name} scored {score:.1f}%")   # Sara scored 95.5%
009What counts as truthy and falsy in Python?Beginner+

Every object in Python can be evaluated in a boolean context (an `if` condition, a `while` loop, `bool(x)`), and Python has clear, memorizable rules for which values count as `False` versus `True` in that context, without needing an explicit `== True` comparison. The false-y set is short and specific: the number zero in any numeric form (`0`, `0.0`, `0j`), the singleton `None`, the boolean `False` itself, and any EMPTY built-in collection — `""`, `[]`, `{}`, `()`, and `set()`.

Everything else is truthy, including values that beginners sometimes assume should be false — a negative number like `-1` is truthy, a non-empty string like `"False"` (the text) is truthy, and a list containing only falsy items like `[0]` is still truthy, because the LIST itself is non-empty even though its single element is falsy. This distinction between 'the container is empty' and 'the container holds a falsy value' is a common source of subtle bugs when filtering data.

for v in [0, 1, "", "x", [], [1], None]:
    print(repr(v), bool(v))
010for vs while — when do you reach for each?Beginner+

Use a `for` loop whenever you're iterating over a known, finite iterable — a list, a string, a range, an open file — where the loop's job is naturally 'do this once for each item'. Use a `while` loop when you're looping based on a CONDITION whose natural end can't be expressed as 'for each item in a collection' — like 'keep asking the user for input until they type something valid', where there's no pre-existing sequence to iterate over.

A lesser-known feature both loops share: an optional `else` clause, which runs only if the loop finished its ENTIRE iteration naturally, without hitting a `break`. This is genuinely useful for search-style loops — 'loop through looking for something, and if you never broke out because you found it, the else branch handles the not-found case' — cleaner than a separate flag variable tracked manually.

for n in [2, 4, 6]:
    if n % 2: break
else:
    print("all even")  # runs — no break happened
011break vs continue vs pass?Beginner+

These three keywords control flow inside loops and blocks in very different ways, and mixing them up is an easy beginner mistake. `break` immediately exits the ENTIRE loop — no more iterations happen at all, execution jumps to the first line after the loop. `continue` only skips the REST of the current iteration's body and jumps straight to the next iteration's condition check — the loop keeps going, just this one pass was cut short.

`pass` is fundamentally different from the other two — it doesn't affect loop flow at all; it's a no-op statement that does absolutely nothing, used purely as a placeholder wherever Python's grammar requires at least one statement but you don't have real logic to put there yet — an empty function body while you're sketching out a design, or an intentionally empty exception handler.

for i in range(6):
    if i == 4: break
    if i % 2 == 0: continue
    print(i)   # 1, 3
012What is PEP 8, and is it enforced?Beginner+

PEP 8 is Python's official style guide — a document maintained alongside the language itself that specifies naming conventions (`snake_case` for functions and variables, `PascalCase` for classes, `UPPER_SNAKE_CASE` for constants), preferred indentation (4 spaces), maximum line length guidance, and where to put whitespace around operators and after commas. Following it consistently is what makes unrelated Python codebases feel instantly familiar to any Python developer.

Critically, the Python interpreter itself does not check or enforce ANY of PEP 8 — you could write code with 2-space indents and camelCase variable names and it would run identically. Enforcement comes entirely from external tooling: linters like `ruff`, `flake8`, or `pylint`, and formatters like `black`, usually wired into a CI pipeline so a pull request fails automatically if the style guide is violated, rather than relying on manual review to catch it.

# ruff or flake8 in CI would flag this:
def MyFunction( x,y ):return x+y   # bad spacing, bad naming

# PEP 8 compliant:
def my_function(x, y):
    return x + y
SECTION 02 · BEGINNER TO INTERMEDIATE

Data Structures

Lists, tuples, dicts, and sets — creation, mutation, comprehensions, and the traps interviewers love to probe.

14 questions
Comparison · List vs Tuple vs Set vs Dict
TypeOrdered?Mutable?Duplicates?Typical use
listYesYesYesAn ordered, changing collection
tupleYesNoYesA fixed record / dict key / return value
setNoYesNoMembership tests, de-duplication, set math
dictYes (insertion order, 3.7+)YesUnique keysFast lookup by key
013List vs tuple — the interview classicBeginner+

Both a list and a tuple store an ordered collection that can mix different types and can contain duplicate values, and both support indexing and slicing identically — so on the surface they look almost interchangeable. The single, fundamental difference is mutability: once a list is created you can change its contents freely (append, remove, reassign an element), while a tuple is frozen permanently the moment it's created — there is no way to change, add, or remove an element from an existing tuple object.

That immutability has a very practical downstream consequence: because a tuple can never change, Python can safely compute a stable hash for it (as long as everything inside it is also hashable), which means a tuple can be used as a dictionary key or stored inside a set — a list can never be used this way, since its hash would become invalid the moment it was mutated. In practice, reach for a tuple when you're representing a fixed, small record (like coordinates `(x, y)` or a return value with a fixed shape) and a list when you genuinely expect the collection to grow, shrink, or be reordered.

t = ('sara', 6, 0.97)
l = ['sara', 6, 0.97]
l[0] = 'ansh'   # works
t[0] = 'ansh'   # TypeError
014What is slicing, exactly?Beginner+

Slicing extracts a sub-sequence from any sequence type — strings, lists, and tuples all support it identically — using the syntax `sequence[start:stop:step]`. Any of the three parts can be omitted: `start` defaults to the very beginning (index 0), `stop` defaults to the full length, and `step` defaults to 1. A negative `step` walks backward through the sequence instead of forward, which is exactly how the well-known `[::-1]` reversal trick works.

A subtlety worth knowing precisely for interviews: slicing is extremely forgiving about out-of-range indices — unlike direct indexing (`my_list[100]`), which raises `IndexError` immediately if the index doesn't exist, a slice with an out-of-range `start` or `stop` is simply CLAMPED to the nearest valid boundary rather than erroring. This makes slicing a safe way to grab 'up to N items' without needing to check the sequence's length first.

nums = [1,2,3,4,5,6,7,8,9,10]
nums[1::2]     # [2, 4, 6, 8, 10]
nums[::-1]     # reversed list
nums[100:200]  # [] — no IndexError, just an empty result
015What is list comprehension, and when should you avoid it?Beginner+

A list comprehension is compact syntax for building a new list by transforming (and optionally filtering) an existing iterable, all in a single expression — `[expression for item in iterable if condition]`. It's genuinely one of Python's most idiomatic features: it's usually both more concise AND measurably faster than the equivalent manual `for` loop with repeated `.append()` calls, because the loop machinery is optimized internally.

The trade-off is readability once complexity grows: nesting more than two `for` clauses inside one comprehension, stacking multiple `if` conditions, or — worst of all — sneaking a side effect (like a print statement or a mutation of an outside variable) inside the expression, all make the comprehension harder to read than an equivalent explicit loop would have been. The practical rule interviewers want to hear: comprehensions are for building a new collection from a transformation, not for executing side effects, and a plain loop is the right tool once a comprehension starts needing comments to explain itself.

squares = [x * x for x in range(6)]
evens = [x for x in range(10) if x % 2 == 0]
016What is a dictionary, and what can be a key?Beginner+

A dictionary stores an unordered-by-value (but insertion-ordered since Python 3.7) collection of key-to-value pairs, and its defining performance characteristic is O(1) average-case lookup by key — internally implemented as a hash table, the same underlying data structure that makes sets fast too. This is why dicts are the default tool whenever you need 'look this up by name/ID quickly' rather than searching through a list linearly.

Not every object can be a dictionary key — a key must be HASHABLE, meaning it has a stable hash value that never changes for the object's lifetime, plus a working equality comparison. Immutable built-ins like strings, numbers, and tuples-of-hashable-things all qualify. Mutable built-ins — lists, dicts, and sets themselves — cannot be keys, because if their contents changed after being used as a key, their hash would become inconsistent with where they were originally stored in the hash table, silently breaking lookups.

user = {"name": "Tanveer", "role": "Developer"}
print(user.get("city", "Unknown"))   # safe read, no KeyError
017What is a set, and what are the core set operations?Beginner+

A set is an unordered collection of unique, hashable values — attempting to add a duplicate value silently does nothing (no error, the set just stays the same). Like dicts, sets are backed by a hash table internally, which is why membership testing (`x in my_set`) runs in O(1) average time, dramatically faster than checking `x in my_list`, which has to scan every element one by one in the worst case.

Sets also support the actual mathematical set operations directly as operators, mirroring set theory notation: `|` for union (everything in either set), `&` for intersection (only what's in both), `-` for difference (in the left set but not the right), and `^` for symmetric difference (in exactly one of the two sets, not both). These come up constantly in practical tasks like 'find users in group A who are also NOT in group B' — expressible in one line instead of a manual loop with conditionals.

a, b = {1, 2, 3}, {3, 4, 5}
print(a & b, a | b, a - b, a ^ b)
018Why is [[0]*3]*3 a classic trap?Intermediate+

The `*` repeat operator on a list creates a NEW list containing `n` copies — but crucially, for a nested structure, those 'copies' are actually just `n` REFERENCES to the exact same single inner object, not `n` independently created objects. `[x] * n` never calls anything that would create fresh copies of `x` — it just repeats whatever `x` already is, reference and all.

So `[[0] * 3] * 3` creates one single inner list `[0, 0, 0]`, and then the OUTER list is populated with three references pointing at that SAME inner list object — there's really only one row, viewed from three different positions. Mutating what looks like 'row 0' therefore silently mutates every row, since they're all the same object under the hood. The fix is to force genuinely independent creation of each row using a list comprehension, which calls `[0] * 3` fresh, as a brand new object, on every single iteration.

bad = [[0] * 3] * 3
bad[0][0] = 9
print(bad)          # every row shows 9 in position 0

good = [[0] * 3 for _ in range(3)]  # each row is a fresh list
good[0][0] = 9
print(good)          # only row 0 changes
019Why are mutable default arguments dangerous?Intermediate+

This is arguably the single most infamous Python gotcha, and interviewers ask it specifically because it reveals whether a candidate understands WHEN Python code actually executes versus when it merely gets defined. A default argument value is evaluated exactly ONCE — at the moment the `def` statement itself runs and the function object is created — not fresh on every single call, which is what most people coming from other languages instinctively assume.

If that one-time default value is a mutable object like `[]` or `{}`, every call that doesn't explicitly pass its own argument ends up sharing and mutating that exact same object across completely unrelated calls, silently accumulating state between them in a way that looks like a memory leak or a bizarre bug rather than the intended, well-documented behavior it actually is. The standard, universally-used fix: default the parameter to the immutable sentinel `None`, and construct a fresh mutable object INSIDE the function body on each call if the argument wasn't provided.

def bad_add(item, bucket=[]):
    bucket.append(item)
    return bucket
print(bad_add(1)); print(bad_add(2))  # [1, 2] — surprise!

def good_add(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket
020append() vs extend() vs insert()?Beginner+

These three list methods all add data, but they differ in HOW MANY items get added and WHERE. `.append(x)` always adds exactly one new element to the end — even if `x` itself happens to be a list, it gets added as a single nested item, not merged in. `.extend(iterable)` iterates over whatever you pass and adds each of its individual elements onto the end one at a time — this is the one to reach for when you want to merge two lists together, not nest one inside the other.

`.insert(index, x)` is the odd one out positionally: instead of always working at the end, it inserts `x` at a SPECIFIC index, shifting every existing element from that position onward one slot to the right. It's less commonly needed than append/extend since inserting anywhere but the end of a list is an O(n) operation (everything after has to shift), so it's worth knowing but shouldn't be your default choice for simply growing a list.

a = [1, 2]; a.append([3, 4]); print(a)   # [1, 2, [3, 4]]
b = [1, 2]; b.extend([3, 4]); print(b)   # [1, 2, 3, 4]
021remove() vs pop() vs del — what's the difference?Beginner+

All three remove something from a list, but they differ in whether you specify a VALUE or a POSITION, and in what (if anything) they hand back to you. `.remove(value)` searches for the first occurrence of that exact value and deletes it — if the value simply isn't present anywhere in the list, it raises `ValueError` rather than silently doing nothing, which is a common source of unhandled exceptions if you don't check membership first.

`.pop(index)` removes by POSITION instead of by value, and importantly RETURNS the removed item — extremely useful when you need to both take an item out and immediately use its value, like implementing a stack. Called with no argument, it defaults to popping the very last item. `del my_list[i]` (or `del my_list[i:j]` for a slice) also removes by position, but it's a statement, not a method — it returns nothing at all, and it's the only one of the three that can delete an entire slice range in one call.

items = ["a", "b", "c", "d"]
items.remove("b")
last = items.pop()
del items[0]
022How do you merge two dictionaries in modern Python?Beginner+

Modern Python (3.9+) added the `|` merge operator specifically for dictionaries, mirroring the set union operator conceptually: `a | b` returns a brand-new dictionary containing every key from both, and whenever the SAME key exists in both, the RIGHT-hand dictionary's value wins — a clean, readable, one-line way to combine configuration dicts or apply overrides.

Before 3.9, the two common idioms were dictionary unpacking inside a literal — `{**a, **b}` (same right-wins-on-conflict behavior, just less readable) — or the in-place `a.update(b)` method, which mutates `a` directly instead of returning a new dict, meaning it's the right choice specifically when you WANT to modify the original dictionary rather than create a separate merged copy.

a = {"x": 1, "y": 2}
b = {"y": 20, "z": 3}
print(a | b)   # {'x': 1, 'y': 20, 'z': 3}
023What is unpacking, and what does the starred target do?Intermediate+

Unpacking lets you assign multiple names to the individual elements of an iterable in a single statement, instead of indexing into it repeatedly — `a, b, c = my_tuple` requires the counts to match exactly, or Python raises a `ValueError` about too many or too few values to unpack.

A single starred name in the middle of an unpacking assignment relaxes that exact-count requirement: it greedily collects 'everything else' into a list, letting the names before and after it grab a fixed number of items from the front and back respectively. Only one starred name is allowed per assignment (Python wouldn't know how to split 'everything else' between two of them), but it can be positioned anywhere — at the start, middle, or end of the target list.

first, *middle, last = [10, 20, 30, 40, 50]
print(first, middle, last)   # 10 [20, 30, 40] 50
024What is a defaultdict, and when does it beat .get()?Intermediate+

`collections.defaultdict(factory)` behaves exactly like a regular dict, except that accessing a MISSING key doesn't raise `KeyError` — instead, it automatically calls `factory()` (a zero-argument callable like `list`, `int`, or `set`) to create a fresh default value, stores it under that key, and returns it, all in one step.

This is a genuine ergonomic win specifically for the very common 'group items by some key' pattern: without a defaultdict, you'd need an explicit `if key not in d: d[key] = []` check before every single append, cluttering the loop body — with a defaultdict, you just call `.append()` directly and the empty list springs into existence automatically the first time that key is touched. `.get(key, default)` on a plain dict solves a similar READING problem (returning a fallback instead of raising `KeyError`), but it doesn't help when you specifically need to MUTATE a per-key collection that might not exist yet.

from collections import defaultdict
groups = defaultdict(list)
groups["python"].append("Sara")   # no KeyError, no manual init
print(groups["missing"])          # []
025What is a namedtuple, and why not just use a dict?Intermediate+

`collections.namedtuple` generates a new tuple SUBCLASS whose positions also have readable, dot-accessible field names — you get the memory efficiency and immutability of a regular tuple (no per-instance hash table overhead the way a dict has), while still being able to write `point.x` instead of the much less self-documenting `point[0]`.

Compared to a plain dict, a namedtuple is cheaper in memory (it doesn't carry a full hash table per instance) and communicates a fixed, known set of fields more clearly through its class definition — but it inherits tuple's immutability, so you can't reassign a field after creation. For anything that needs mutability, default values, or its own methods, a `dataclass` (covered in Section 05) is the more modern, more flexible choice — namedtuple still shows up constantly in existing codebases and in functions that return multiple related values, like the results of `os.stat()`.

from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(10, 20)
print(p.x, p.y)
026Why is list.pop(0) inefficient, and what's the fix?Intermediate+

A Python list is implemented internally as a contiguous, array-like block of memory — removing the FIRST element means every single remaining element has to be physically shifted one position to the left to close the gap, which makes `list.pop(0)` an O(n) operation, not the O(1) you might expect from 'just remove the front item'. Repeatedly popping from the front of a large list in a loop turns what looks like an O(n) algorithm into an accidental O(n²) one.

`collections.deque` (double-ended queue) solves this by using a different underlying structure (conceptually a doubly-linked block structure) that supports O(1) append and pop operations on BOTH ends — the front and the back — making it the correct tool for queues, sliding-window algorithms, and any 'add/remove from either end repeatedly' pattern, while a plain list remains the better choice when most of your access is by index or you're mainly adding/removing from the end only.

from collections import deque
queue = deque([1, 2, 3])
queue.append(4)          # O(1)
first = queue.popleft()  # O(1)
SECTION 03 · BEGINNER TO INTERMEDIATE

Strings & Regular Expressions

Text manipulation, formatting, and pattern matching — a near-guaranteed interview topic.

10 questions
027Why are strings immutable, and what does that mean in practice?Beginner+

Like tuples, strings are immutable in Python — once created, a string object's actual character content can never be changed in place. This immutability enables several real benefits: strings can be safely shared between different parts of a program without fear that one piece of code will unexpectedly change a string another piece is still relying on, they can be hashed (making them usable as dict keys and set members), and CPython can apply internal memory optimizations knowing the content will never shift.

The practical consequence is that every operation that LOOKS like it modifies a string — `.replace()`, `.upper()`, slicing, even `+=` — never actually touches the original object at all; each one computes and returns a brand-new string object, leaving the original completely untouched. This is why `text += "!"` in a tight loop is more expensive than it looks: each iteration silently builds an entirely new string rather than appending to an existing one.

text = "cat"
new_text = "b" + text[1:]   # "bat" — text itself is untouched
print(text, new_text)
028How do you join and split strings?Beginner+

`str.split(sep)` breaks a single string into a list of pieces wherever the separator occurs — called with no argument at all, it splits on any run of whitespace and automatically discards empty strings from leading/trailing/repeated spaces, which is usually exactly what you want for cleaning up user-typed text.

`sep.join(iterable)` does the reverse operation, but with a syntax that trips up almost every beginner the first time they see it: the separator string is the one you call `.join()` ON, and the iterable of pieces to combine is the argument you pass IN — so `" ".join(["a","b"])` reads as 'join these pieces using a space', not 'join these pieces, then space them'. Getting this backwards (`["a","b"].join(" ")`) is a genuinely common mistake that raises an `AttributeError` since lists have no `.join()` method at all.

parts = "This is a string.".split(" ")
print(" ".join(parts))   # back to the original
029How do you strip, replace, and case-convert text?Beginner+

`.strip()` removes leading and trailing whitespace (or, if you pass a specific set of characters, exactly those characters) from both ends of a string — `.lstrip()` and `.rstrip()` do the same but only from the left or right side respectively, useful when you only want to trim one direction. `.replace(old, new)` scans the whole string and substitutes every occurrence of `old` with `new` — pass an optional third argument to limit how many replacements happen.

`.upper()`, `.lower()`, and `.title()` all handle case conversion — `.title()` specifically capitalizes the first letter of every word, which is convenient for display formatting but has known edge cases with contractions and possessives (`"it's"` becomes `"It'S"`, not `"It's"`) that make it unsuitable for anything beyond simple, casual formatting. All of these, consistent with string immutability, return new strings rather than modifying anything in place.

text = "   I like Java   "
print(text.strip().replace("Java", "Python").upper())
030find() vs index() vs in — how do they differ on failure?Beginner+

All three answer some version of 'does this substring exist here', but their behavior when the substring is ABSENT is genuinely different, and picking the wrong one for your situation is a common source of unhandled exceptions in production code. `.find(substring)` searches and returns the starting index of the first match — but if nothing matches, it quietly returns `-1` instead of raising anything, meaning a careless caller who forgets to check for `-1` can end up silently using a nonsensical index.

`.index(substring)` does the identical search, but raises a `ValueError` immediately if the substring isn't found — some developers actually prefer this because a loud, explicit failure is safer than a silent `-1` that gets used accidentally. The plain `in` operator (`"sub" in text`) sidesteps both problems entirely by just returning a clean boolean — when all you need is a yes/no answer (not the position), `in` is almost always the clearest, least error-prone choice.

text = "learn python"
print(text.find("java"))   # -1, no exception
print("python" in text)    # True
031What is string interning, and can you rely on `is` for string equality?Intermediate+

CPython may, as an internal memory/performance optimization, choose to reuse ('intern') the same underlying string object for certain string literals — most reliably, short strings that look like valid Python identifiers (things made only of letters, digits, and underscores). This means two separately-written literals that happen to be interned can end up being the exact same object in memory, so `a is b` could accidentally return `True` even though you never explicitly linked them.

The critical thing to understand for an interview: interning is a CPython IMPLEMENTATION DETAIL, not a language guarantee documented in the spec — it isn't guaranteed to happen for every string, it can vary between Python versions or even between different builds, and other implementations of Python aren't required to do it at all. This is exactly why you should always compare string VALUE with `==`, and never rely on identity (`is`) for string equality — code that happens to work today because of an interning coincidence can silently break on a different Python version.

a = "python"
b = "py" + "thon"
print(a == b)   # rely on this — True
# a is b is unreliable, don't depend on it
032What is a raw string, and why do regexes need it?Intermediate+

A raw string literal, written with an `r` prefix (`r"..."`), tells Python to disable its normal backslash-escape-sequence processing for that specific literal — in a regular string, `\n` means a newline character and `\d` isn't even a recognized escape sequence (Python will actually warn about it), but in a raw string, backslashes are passed through completely untouched, exactly as typed.

Regular expressions use the backslash character heavily as their OWN escaping mechanism at the regex-engine level — `\d` means 'a digit', `\s` means 'whitespace', `\b` means 'a word boundary' to the `re` module. If you write a regex pattern as a normal (non-raw) Python string, Python's OWN string-literal parser tries to interpret those same backslash sequences FIRST, before the regex engine ever sees them — this can silently corrupt the pattern or produce confusing deprecation warnings. Prefixing the pattern with `r` sidesteps this entirely by making sure the backslashes reach the `re` module exactly as you wrote them.

import re
pattern = r"\d{4}-\d{2}-\d{2}"
print(bool(re.fullmatch(pattern, "2026-07-25")))
033re.match vs re.search vs re.fullmatch?Intermediate+

These three `re` functions all try to apply a pattern to a string, but they differ in WHERE in the string the pattern is required to match, and mixing them up is one of the most common regex interview mistakes. `re.match()` only checks whether the pattern matches starting AT THE VERY BEGINNING of the string — if the string doesn't start with a match, it returns `None` even if a match exists somewhere later in the string.

`re.search()` is more permissive: it scans through the ENTIRE string looking for the first position where the pattern matches anywhere, not just at the start — this is usually the one people actually mean when they think of 'does this pattern appear in this text'. `re.fullmatch()` is the strictest of the three: it requires the pattern to account for the ENTIRE string from the very first character to the very last, with nothing left over on either side — the right tool specifically for validation tasks like 'is this whole string a valid email address', where a partial match anywhere in the middle shouldn't count as success.

import re
text = "abc123"
print(re.match(r"\d+", text))       # None — doesn't start with a digit
print(re.search(r"\d+", text))      # matches "123"
print(re.fullmatch(r"[a-z]+\d+", text))  # matches the whole string
034How do you extract all matches, or replace with a regex?Intermediate+

`re.findall(pattern, text)` scans the whole string and returns EVERY non-overlapping match as a list — if your pattern contains capturing groups (parentheses), it returns the captured groups instead of the full matched text, which is a common source of confusion if you forget your pattern has groups in it. `re.finditer(pattern, text)` does essentially the same search, but returns a lazy iterator of full match OBJECTS (giving you access to `.start()`, `.end()`, `.group()`, etc.) instead of materializing a whole list upfront — the better choice for very large text where you don't want every match stored in memory at once.

`re.sub(pattern, replacement, text)` replaces every match of the pattern with the replacement text (which can itself reference captured groups using `\1`, `\2`, etc.), and an optional `count` argument limits how many replacements happen if you only want to replace the first N occurrences instead of all of them.

import re
text = "Phone: 123-456-7890"
print(re.findall(r"\d+", text))
print(re.sub(r"\d", "*", text))
035How do you check whether a string is fully digits or fully alphanumeric?Beginner+

Python's string type has several built-in classification methods that check the Unicode CATEGORY of every character directly, without needing to reach for a regular expression at all for these simple, common checks. `.isdigit()` returns `True` only if every character in the string is a digit (and the string is non-empty) — an empty string returns `False`, which trips up beginners expecting it to be vacuously true.

`.isalpha()` checks that every character is a letter (correctly handling Unicode letters from many languages, not just ASCII a–z), and `.isalnum()` checks that every character is either a letter or a digit — none of the three tolerate spaces, punctuation, or symbols anywhere in the string, so `"123 456".isdigit()` is `False` because of the space in the middle.

print("12345".isdigit())     # True
print("abc123".isalnum())    # True
print("xyz@1$".isalnum())    # False
036How do you encode and decode text safely?Intermediate+

In Python 3, `str` is always a sequence of Unicode CODE POINTS — an abstract representation of characters, not raw bytes. `bytes` is a completely separate type representing actual raw binary data. `str.encode(codec)` converts a text string into a concrete sequence of bytes according to a specific encoding scheme (like UTF-8), and `bytes.decode(codec)` reverses that process, turning raw bytes back into a text string — you MUST know (or correctly guess) which codec was used to encode data before you can decode it correctly.

UTF-8 is the standard, near-universal default for interoperable systems (web APIs, most modern file formats, most databases) because it can represent every Unicode character while staying backward-compatible with plain ASCII for English text. A mismatched codec — encoding as UTF-8 but later trying to decode as Latin-1, for instance — is a classic, very real source of `UnicodeDecodeError` crashes in production systems that handle text from multiple sources with inconsistent encodings.

text = "Python ✓"
data = text.encode("utf-8")
restored = data.decode("utf-8")
print(data, restored)
SECTION 04 · BEGINNER TO INTERMEDIATE

Functions & Functional Programming

Arguments, scope, closures, and the map/filter/reduce toolkit.

11 questions
037What do *args and **kwargs actually mean?Beginner+

`*args` in a function signature collects any EXTRA positional arguments (beyond the explicitly named parameters) into a tuple, letting a function accept a variable, not-known-in-advance number of positional inputs — inside the function body, `args` is just a normal tuple you can loop over or index. `**kwargs` does the equivalent job for extra KEYWORD arguments, collecting them into a regular dictionary mapping each argument name to its value.

Together, these two make it possible to write flexible wrapper functions and decorators (Section 07 relies on this heavily) that can accept and forward ANY arguments to another function, without needing to know or hard-code that function's exact signature ahead of time — this is exactly the mechanism that lets a generic logging decorator wrap literally any function regardless of how many arguments it takes.

def multiply(a, b, *args):
    result = a * b
    for n in args: result *= n
    return result
multiply(1, 2, 3, 4, 5)   # 120

def show(**kwargs):
    for k, v in kwargs.items(): print(k, ":", v)
038Keyword-only and positional-only parameters — what do `*` and `/` do in a signature?Beginner+

A bare `*` in a function's parameter list marks everything AFTER it as keyword-only — callers are FORCED to pass those specific arguments by name (`timeout=10`), never positionally, which makes call sites more self-documenting and prevents callers from accidentally swapping the order of two similarly-typed arguments.

A bare `/` marks everything BEFORE it as positional-only — callers must pass those arguments by position, never by keyword name, which is useful for a library author who wants the freedom to rename an internal parameter later without it counting as a breaking API change for anyone calling `divide(a=1, b=2)` explicitly by name. A number of Python's own built-in functions (like `len()`) actually use positional-only parameters internally, which is part of why this feature was eventually exposed to regular Python code too.

def connect(host, *, timeout=10):   # timeout is keyword-only
    print(host, timeout)

def divide(a, b, /):                # a, b are positional-only
    return a / b
039What are lambda functions, and when should you NOT use one?Beginner+

A `lambda` creates a small, anonymous, single-EXPRESSION function inline, without the ceremony of a full `def` block — its entire body must be one expression whose value is automatically returned, with no room for multiple statements, assignments, loops, or a docstring. They're most commonly used as a short, throwaway `key=` function for `sorted()`/`max()`/`min()`, or as a quick inline callback.

The clear line for when NOT to use one: if the logic needs a name for reuse elsewhere, needs more than one statement, needs a docstring for documentation, or is complex enough that squeezing it into one expression would hurt readability, a regular `def` function is unambiguously the better choice. Overusing deeply nested or overly clever lambdas is a common code-review complaint precisely because they trade a small amount of typing for a real loss in readability and debuggability (a lambda's traceback entry just says `<lambda>`, giving you no name to search for).

mul = lambda x, y: x * y
sorted(words, key=lambda w: len(w))
040map(), filter(), and functools.reduce() — what does each actually return?Intermediate+

In Python 3 (unlike Python 2), `map()` and `filter()` both return LAZY ITERATOR objects, not fully-computed lists — nothing is actually processed until you iterate over the result (with a `for` loop, `list()`, or similar), which saves memory when working with large or even infinite sequences, but also means printing the raw result directly just shows something like `<map object at 0x...>` rather than the values themselves, tripping up beginners who forget to wrap it in `list()`.

`functools.reduce(function, iterable, [initial])` repeatedly applies a two-argument function to running-accumulator-plus-next-item pairs, collapsing the whole iterable down into one single final value — it's the general-purpose tool that a sum, product, or 'combine everything into one thing' operation is really a specific instance of. Many Python style guides actually prefer an explicit loop or a comprehension over `reduce()` for anything beyond the simplest cases, since deeply nested reduce logic can become genuinely hard to read compared to an equivalent plain loop.

from functools import reduce
nums = [1, 2, 3, 4]
doubled = list(map(lambda x: x * 2, nums))
evens = list(filter(lambda x: x % 2 == 0, nums))
product = reduce(lambda a, b: a * b, nums)   # 24
041What is a closure?Intermediate+

A closure happens when an INNER function references a variable from its ENCLOSING function's scope, and that inner function is then returned or otherwise escapes to be used elsewhere — Python keeps that referenced variable alive and accessible to the inner function even after the outer function has already finished executing and would normally have had its local variables cleaned up.

This is the mechanism behind 'factory functions' that produce customized, specialized functions on demand (like a `multiplier(2)` that returns a dedicated 'double' function), and it's also the classic simplest way to build a decorator or maintain small amounts of private state without needing a full class — the closed-over variable behaves like a private instance variable that only the returned inner function can see or touch.

def multiplier(factor):
    def multiply(value):
        return value * factor   # factor is closed over
    return multiply

double = multiplier(2)
print(double(10))   # 20
Diagram · LEGB Scope Resolution
B - BUILT-IN (open, len, print...) G - GLOBAL (module-level names) E - ENCLOSING (outer function's locals) L - LOCAL (current function's variables) Python looks up a name L then E then G then B, stopping at the first match use nonlocal to rebind E, global to rebind G
Name resolution always searches Local first, then Enclosing, then Global, then Built-in.
042What does `nonlocal` do, and how is it different from `global`?Intermediate+

By default, simply ASSIGNING to a name inside a function (`count = count + 1`) creates a brand new LOCAL variable in that function's own scope, even if a variable with the same name already exists in an outer scope — this is a very common source of `UnboundLocalError` confusion for beginners who expected the assignment to modify the outer variable directly.

`nonlocal` explicitly tells Python 'when I assign to this name, don't create a new local — reach out and rebind the nearest ENCLOSING FUNCTION's variable instead', which is exactly what's needed to build a stateful closure like a counter that increments across calls. `global` solves the analogous problem one level higher — it tells Python to rebind a name at the MODULE (global) level instead of creating a local, used far less often since relying on global mutable state is generally discouraged in well-structured code.

def counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

tick = counter()
print(tick(), tick())   # 1 2
043Can a function return multiple values? What happens with no return statement?Intermediate+

Python doesn't literally support 'multiple return values' as a distinct language feature — what actually happens is that comma-separated values after `return` are automatically packed into a single TUPLE object, which the caller can then unpack directly into multiple variable names on the receiving end. This is why `return a, b` and `a, b = some_function()` feel like multiple return values even though under the hood it's really just one tuple being created and then destructured.

If a function reaches the end of its body without ever hitting an explicit `return` statement — or hits a bare `return` with no value after it — Python implicitly returns `None`. This is a subtle but real trap: forgetting a `return` in one branch of an `if`/`else` (having it in one branch but not the other) means some calls will silently get back `None` instead of raising any kind of error, which can cause a confusing failure much later in the code where that `None` gets used unexpectedly.

def stats(a, b):
    return a + b, a * b
total, product = stats(3, 4)
044Type hints — do they change runtime behavior?Intermediate+

Type hints (`def add(a: int, b: int) -> int:`) let you annotate a function's expected parameter and return types directly in its signature. They are purely for the benefit of humans reading the code, IDEs providing autocomplete, and separate static type-checking TOOLS like `mypy` or `pyright` that you run as an additional step alongside your code — they do not create any runtime enforcement whatsoever.

Concretely: calling `add("a", "b")` on a function hinted as taking `int` parameters will NOT raise any error at runtime purely because of the type hint — Python will happily try to run the function body with whatever was actually passed, and only fail if the ACTUAL operations inside the body (like `a + b` on two strings) themselves happen to raise an error. If you genuinely need runtime type enforcement, you must add it explicitly yourself (an `isinstance()` check, a validation library like Pydantic, or similar) — type hints alone are documentation and tooling support, not a runtime contract.

def find_user(user_id: int) -> str | None:
    return "Tanveer" if user_id == 1 else None
045Are Python arguments passed by value or by reference?Intermediate+

Neither of the classic textbook terms ('pass by value' or 'pass by reference') maps cleanly onto how Python actually works, which is exactly why this question is a favorite for separating candidates with a surface-level understanding from those with a real mental model. The most accurate description is 'pass by object reference' (sometimes called 'pass by assignment'): the parameter name inside the function becomes a NEW local name that refers to the SAME underlying object the caller's argument was already referring to.

The practical consequence splits into two very different cases depending on what you DO with that parameter inside the function. If you MUTATE the object through that reference (calling `.append()` on a list, for instance), the caller sees that change too, because there's genuinely only one object and both names point at it. But if you REASSIGN the parameter name to point at a completely different object (`arr = [9,9,9]`), that only rebinds the LOCAL name inside the function — the caller's original variable still points at the original, untouched object, since reassignment never reaches back out to change what the caller's own variable refers to.

def add_item(arr):
    arr.append(4)      # mutates the caller's list

def replace(arr):
    arr = [9, 9, 9]     # only rebinds the local name — caller unaffected

nums = [1, 2, 3]
add_item(nums)
print(nums)   # [1, 2, 3, 4]
046What is recursion, and why is it sometimes avoided in Python?Intermediate+

A recursive function is one that calls itself, working toward a BASE CASE — a condition simple enough to answer directly without any further recursive calls — which is what eventually stops the recursion instead of looping forever. Recursion is a natural fit for problems that are naturally defined in terms of smaller versions of themselves, like tree traversal, certain divide-and-conquer algorithms, and mathematical definitions like factorial or Fibonacci.

Python specifically has two practical limitations that make deep recursion riskier here than in some other languages: it does NOT perform tail-call optimization (a technique some languages use to avoid growing the call stack for certain recursive patterns), and it enforces a default maximum recursion depth (around 1000 calls, adjustable via `sys.setrecursionlimit()` but not infinitely so) to guard against a runaway recursive bug crashing the interpreter with a stack overflow. For this reason, algorithms that could recurse very deeply on realistic input sizes are frequently rewritten iteratively in production Python code, even when the recursive version is conceptually cleaner.

def factorial(n, acc=1):
    if n <= 1: return acc
    return factorial(n - 1, acc * n)   # not tail-call optimized in CPython
047What is memoization, and how does functools help?Intermediate+

Memoization is a specific caching technique for functions that are DETERMINISTIC (the same inputs always produce the same output) and have no meaningful side effects — the idea is to remember the result of a call keyed by its exact arguments, so that any FUTURE call with those same arguments can return the cached answer instantly instead of recomputing it from scratch. This is exactly what turns a naive, exponentially slow recursive Fibonacci implementation into a fast one, since it eliminates re-solving the same overlapping sub-problems over and over.

`functools.cache` (an unlimited-size cache, added in Python 3.9) or the older, more configurable `functools.lru_cache(maxsize=...)` add this entire behavior with a single decorator line, requiring zero manual cache-dictionary bookkeeping — the decorator automatically hashes the function's arguments to use as a cache key, which is exactly why memoized functions must only be called with HASHABLE arguments (a plain list argument, for instance, would fail, since lists aren't hashable).

from functools import cache

@cache
def fib(n):
    if n < 2: return n
    return fib(n - 1) + fib(n - 2)

print(fib(40))   # instant, thanks to caching
SECTION 05 · BEGINNER TO ADVANCED

Object-Oriented Python

Classes, inheritance, dunder methods, and the OOP vocabulary interviewers expect you to use precisely.

15 questions
048What does __init__ actually do, and what is self?Beginner+

`__init__` is Python's constructor HOOK — a special method the interpreter automatically calls right after a new instance has already been created in memory, and its job is to set up that instance's starting attributes. Technically, object creation itself is handled by a different special method, `__new__` (covered later in this section), and `__init__` only INITIALIZES the object that `__new__` already built — a distinction that matters more once you get into advanced metaclass/immutable-subclass territory.

`self` is the conventional name for the first parameter of every instance method, and it refers to the SPECIFIC instance the method was called on — it isn't a reserved keyword at all (you could technically name it anything), but every Python developer and every piece of tooling expects `self` by overwhelming convention, so deviating from it would confuse anyone reading your code. Every attribute you want a specific instance to remember gets assigned onto `self` inside `__init__`, exactly the way `self.name = name` stores the constructor argument as a per-instance attribute.

class Student:
    def __init__(self, name, section):
        self.name = name
        self.section = section

s1 = Student("Sara", "A2")
049What is inheritance, and what is method overriding?Beginner+

Inheritance lets a new (child/subclass) class automatically reuse all the attributes and methods of an existing (parent/base) class, without copying any code — you declare the relationship with `class Dog(Animal):`, and every instance of `Dog` immediately has access to everything `Animal` defines, unless the child specifically customizes it. This is the mechanism behind 'don't repeat yourself' for related classes that share common structure and behavior.

Overriding happens when the child class defines its OWN version of a method that already exists on the parent, using the exact same method name — when you call that method on a child instance, Python's attribute lookup finds the CHILD's version first (since it looks up the class hierarchy starting from the most specific class), so the child's version 'wins' and effectively replaces the parent's behavior for that specific class, while everything else the child DIDN'T override still falls through to the parent's implementation.

class Animal:
    def speak(self): return "sound"

class Dog(Animal):
    def speak(self): return "woof"   # overrides Animal.speak

print(Dog().speak())   # woof
Diagram · The Four Shapes of Inheritance
SINGLE Parent Child MULTILEVEL Grandparent Parent Child MULTIPLE Parent1 Parent2 Child HIERARCHICAL Parent Child A Child B class D(B, C): pass -> MRO resolves left-to-right, depth-first, no repeats (C3 linearization) Multiple inheritance is where method resolution order (MRO) starts to matter - see Q052
The four inheritance shapes Python supports, and why multiple inheritance needs MRO.
050What is polymorphism, and how does it differ from duck typing?Intermediate+

Polymorphism, broadly, means that different kinds of objects can respond to the SAME call or interface without the calling code needing to know or check exactly which concrete class it's dealing with — you can loop over a mixed collection of different objects and call `.speak()` on each one, and each responds in its own way, without an if/else chain checking types first.

Duck typing is Python's specific, informal flavor of polymorphism, and it's more radical than the classic OOP version: Python doesn't even require the objects to share a common base class or explicitly implement a shared interface — all that matters is whether the object actually HAS the method or attribute you're trying to use at the moment you use it. The name comes from the saying 'if it walks like a duck and quacks like a duck, it's a duck' — Python doesn't check an object's declared type before calling `.speak()` on it, it just tries the call and lets it fail naturally (usually with `AttributeError`) if the object genuinely doesn't support it.

class Dog:  # no shared base class needed
    def speak(self): return "woof"
class Cat:
    def speak(self): return "meow"
for animal in [Dog(), Cat()]:
    print(animal.speak())
051What is encapsulation in Python, given there's no real `private` keyword?Intermediate+

Unlike languages such as Java or C++ that have actual enforced `private`/`protected` access modifiers checked by the compiler, Python has no real access control at the language level at all — it relies entirely on NAMING CONVENTION and mutual trust between developers. No leading underscore means the attribute is intended as fully public API. A single leading underscore (`_balance`) is a widely-understood social signal meaning 'this is an internal implementation detail, please don't touch it from outside even though nothing technically stops you'.

A DOUBLE leading underscore (`__balance`) triggers actual name MANGLING — Python automatically rewrites the attribute name internally to `_ClassName__balance`, which mainly exists to prevent ACCIDENTAL name collisions in subclasses, not to provide real security (it's still fully discoverable and settable from outside if someone knows or looks up the mangled name). For attributes that genuinely need validation logic when read or written, `@property` (covered next) is the idiomatic Python way to add that control while still letting callers use plain, clean attribute-style syntax.

class Account:
    def __init__(self, balance):
        self._balance = balance
    @property
    def balance(self):
        return self._balance
Diagram · Encapsulation Naming Conventions
name public freely used from anywhere, no signal _name convention only "internal, please don't touch" - not enforced __name name-mangled becomes _Class__name avoids subclass clashes
None of these are truly private — Python trusts developers to respect the convention rather than enforcing access at the language level.
052What is method resolution order (MRO), and what does super() actually return?Advanced+

Method resolution order is the specific, well-defined SEQUENCE Python searches through a class's inheritance graph when looking up a method or attribute name, and it becomes genuinely important (rather than just trivia) once a class inherits from MULTIPLE parents that might define the same method name differently. CPython computes this order using an algorithm called C3 linearization, which guarantees a consistent, predictable order that respects each parent's own MRO and never lists a class before one of its own subclasses.

A common misconception is that `super()` means 'literally call my direct parent class's version of this method' — that's not actually accurate. `super()` returns a special proxy object that delegates to whichever class comes NEXT in the MRO relative to the current class, which in a single-inheritance chain happens to look like 'the parent', but in multiple inheritance can mean a completely different sibling class in the hierarchy. This is precisely the mechanism that makes 'cooperative multiple inheritance' work correctly — every class in a diamond-shaped hierarchy calling `super().method()` ends up visiting each ancestor exactly once, in a consistent order, rather than some ancestors being skipped or visited twice.

class A:
    def hello(self): print("A")
class B(A):
    def hello(self): super().hello(); print("B")
class C(A):
    def hello(self): super().hello(); print("C")
class D(B, C):
    def hello(self): super().hello(); print("D")

D().hello()          # A C B D
print(D.mro())        # [D, B, C, A, object]
053@staticmethod vs @classmethod vs a normal instance method?Intermediate+

These three decorators (or lack thereof) control what gets automatically passed as the first argument when a method is called, and picking the right one communicates intent clearly to anyone reading the class. A normal instance method automatically receives `self` — the specific instance it was called on — and is the right choice whenever the method's logic genuinely needs to read or modify that instance's own state.

A `@classmethod` automatically receives `cls` — the CLASS itself, not any particular instance — which makes it the standard pattern for 'alternate constructors' that build and return a new instance in some different way than the normal `__init__` (like `User.from_json(data)` or, as shown below, a 'default guest user' factory). A `@staticmethod` receives neither `self` nor `cls` at all — it behaves like a completely ordinary function that just happens to live inside the class's namespace for logical organization purposes, typically used for a small helper utility that's conceptually related to the class but doesn't need any instance or class data to do its job.

class User:
    def __init__(self, name): self.name = name
    @classmethod
    def guest(cls): return cls("Guest")     # alt constructor
    @staticmethod
    def is_valid_name(name): return bool(name.strip())
054What is @property, and why prefer it over plain getter/setter methods?Intermediate+

`@property` lets you define a method that behaves, from the OUTSIDE caller's perspective, exactly like a plain attribute — accessed with `obj.value`, no parentheses, no explicit method call syntax — while internally it's actually running real code every time it's accessed, whether that's a validation check, a computed value, or a lookup somewhere else.

This solves a real, practical API-design problem: many other languages (Java being the classic example) encourage writing explicit `get_value()`/`set_value()` methods from the very start, purely so that validation logic can be added later without breaking existing callers. Python's `@property` sidesteps needing that ceremony upfront — you can start with a completely plain public attribute, and later, if you need to add validation or make it a computed value, convert it to a property WITHOUT changing the calling code anywhere else in your codebase, since `obj.value` still works identically either way.

class Circle:
    def __init__(self, radius): self.radius = radius
    @property
    def diameter(self): return self.radius * 2

print(Circle(5).diameter)   # 10, computed, no stored duplicate
055What is an abstract base class, and why use one?Intermediate+

An abstract base class (ABC), built using Python's `abc` module, lets you define a REQUIRED interface that subclasses must implement, without providing a usable implementation itself — you mark specific methods with `@abstractmethod` to say 'every real subclass MUST override this method with actual logic'.

The real value is catching a design mistake immediately, at the moment of instantiation, rather than much later at some random call site: if a subclass forgets to implement even one required abstract method, Python refuses to let you create an instance of it at all, raising `TypeError` right away — this is far better than discovering the gap only when some unrelated piece of code eventually calls the missing method and gets a confusing `AttributeError` deep inside unrelated logic. ABCs are commonly used to define a shared contract for interchangeable implementations — like different payment providers or different storage backends — that calling code can treat uniformly.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self): ...

class Square(Shape):
    def __init__(self, side): self.side = side
    def area(self): return self.side ** 2
056Composition vs inheritance — how do you choose?Intermediate+

Inheritance models an 'is-a' relationship — a `Dog` genuinely IS AN `Animal`, sharing its fundamental nature — and is appropriate when the subclass really is a more specific version of the same underlying concept as its parent. Composition models a 'has-a' relationship instead — a `Car` HAS AN `Engine`, but a car isn't fundamentally a type of engine — implemented simply by storing another object as a regular attribute and delegating to its methods when needed.

The widely-cited guideline 'favor composition over inheritance' exists because deep inheritance hierarchies create tight, fragile coupling — a change to a base class can unexpectedly ripple through every subclass several levels down, and it's easy to end up forcing an awkward 'is-a' relationship onto two things that don't actually share a true underlying nature just to reuse some code. Composition tends to produce more flexible, independently testable pieces, since the `Engine` class knows nothing about `Car` and could be swapped out or reused in a completely different context without any inheritance relationship at all.

class Engine:
    def start(self): return "engine started"
class Car:
    def __init__(self): self.engine = Engine()
    def start(self): return self.engine.start()
057What is a dataclass, and when does it replace a namedtuple?Intermediate+

The `@dataclass` decorator inspects a class's type-annotated attributes and automatically generates a proper `__init__` (taking each attribute as a constructor argument, with sensible support for default values), a readable `__repr__` for debugging, and value-based equality (`__eq__`) — all boilerplate you would otherwise have to hand-write yourself for any class whose primary job is simply to hold structured data.

Compared to `namedtuple`, a dataclass gives up a small amount of memory efficiency in exchange for real flexibility: dataclass INSTANCES are mutable by default (you can reassign `user.age = 23` after creation, which a namedtuple would refuse), they support default values naturally through normal Python syntax, and — being a regular class — they can freely have additional custom methods, inherit from other classes, and use all of Python's usual OOP tools. The practical rule: reach for a dataclass whenever the data-holding object needs to change after creation or needs behavior beyond just holding values; namedtuple remains a reasonable lightweight choice for genuinely fixed, immutable records.

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int = 0

print(User("Sara", 22))
Comparison · Common Dunder Methods
MethodTriggered byTypical use
__init__ClassName(...)Set up starting attributes
__repr__ / __str__repr(obj) / print(obj)Developer-facing vs human-facing display
__eq__a == bCustom value equality
__len__len(obj)Make an object work with len()
__contains__x in objCustom membership testing
__call__obj(...)Make an instance callable like a function
__enter__ / __exit__with obj:Context manager setup/teardown
__add__, __lt__, etc.a + b, a < bOperator overloading
058What's the difference between __repr__ and __str__?Intermediate+

Both special methods control how an object is converted to a string, but they're aimed at different AUDIENCES and different purposes. `__repr__` is meant for developers and debugging — the convention (not strictly enforced) is that it should be unambiguous about exactly what the object is, and ideally, where feasible, look like valid Python code that could recreate an equal object. `__str__` is meant for END USERS — a friendlier, more human-readable display shown by `print()` or `str()`.

An important detail that trips people up: if a class defines ONLY `__repr__` and not `__str__`, Python automatically falls back to using `__repr__` for BOTH purposes — but the reverse is not true, defining only `__str__` does not make it get used for `repr()`. This is exactly why containers like lists always show their elements using each item's `__repr__` (`print([user])` shows the repr form) even if that same object's `__str__` would have looked different when printed directly.

class User:
    def __init__(self, name): self.name = name
    def __repr__(self): return f"User(name={self.name!r})"
    def __str__(self): return self.name

u = User("Sara")
print(u)        # Sara      (__str__)
print([u])      # [User(name='Sara')]  (__repr__, inside a container)
059How do you overload an operator, like +, for a custom class?Intermediate+

Python operators are implemented as calls to special 'dunder' methods behind the scenes — `a + b` is really `a.__add__(b)`, `a < b` is `a.__lt__(b)`, `a == b` is `a.__eq__(b)`, and so on — so you overload any operator for your own class simply by implementing the matching special method, letting your custom objects participate naturally in normal-looking arithmetic or comparison expressions.

An important, often-missed detail for correctness: when the OTHER operand's type isn't something your method knows how to handle, you should return the special singleton `NotImplemented` (a distinct value, not an exception) rather than raising an error yourself — this signals to Python that it should try the REFLECTED method on the other object instead (`b.__radd__(a)`), giving that other type a fair chance to handle the operation before Python ultimately gives up and raises `TypeError` on its own.

class Vector:
    def __init__(self, x, y): self.x, self.y = x, y
    def __add__(self, other):
        if not isinstance(other, Vector): return NotImplemented
        return Vector(self.x + other.x, self.y + other.y)
060isinstance() vs type(x) == SomeClass — which should you use, and why?Intermediate+

`isinstance(obj, SomeClass)` checks whether an object is an instance of `SomeClass` OR any of its subclasses — it respects the entire inheritance hierarchy, which matches how polymorphism is supposed to work: code written to accept an `Animal` should correctly recognize a `Dog` (which IS an `Animal`) as valid too.

`type(obj) == SomeClass` performs an EXACT class match instead — it silently returns `False` for any subclass instance, even though that subclass instance genuinely does satisfy an 'is-a' relationship with the parent class. This makes `type() ==` almost always the wrong tool for a general type check, since it silently breaks the moment someone introduces a legitimate subclass — `isinstance()` should be your default, with the exact-match version reserved only for the rare, deliberate case where you specifically want to EXCLUDE subclasses from matching.

class Animal: pass
class Dog(Animal): pass
dog = Dog()
print(type(dog) == Animal)      # False
print(isinstance(dog, Animal))  # True — prefer this
061getattr(), setattr(), hasattr() — what do they let you do dynamically?Intermediate+

These three built-in functions let you read, write, and check for the existence of an object's attribute using a NAME STORED AS A STRING at runtime, rather than fixed, hard-coded dot-notation written directly into your source code — `getattr(obj, "name")` is functionally equivalent to `obj.name`, but the actual attribute name can come from a variable, a config file, or user input instead of being baked into the code.

This dynamic capability is the foundation of a huge amount of 'meta' Python code: generic serializers that convert any object to a dictionary by iterating over its attribute names, simple ORMs that map database column names to object attributes without knowing them in advance, and plugin systems that need to call a method whose name is only known at runtime. `getattr()` also accepts an optional default value (like `dict.get()`) so you can safely check for an attribute that might not exist without needing a separate `hasattr()` check first.

class User: pass
user = User()
setattr(user, "name", "Tanveer")
print(hasattr(user, "name"), getattr(user, "name"))
062What is a class variable vs an instance variable, and why can a mutable class variable be risky?Intermediate+

A class variable is defined directly in the class body (not inside `__init__`), and it lives on the CLASS object itself rather than on any individual instance — every instance that doesn't have its OWN attribute of the same name will fall through to seeing the shared class-level value via Python's normal attribute lookup. An instance variable, by contrast, is set on `self` inside a method (typically `__init__`) and belongs uniquely to that one specific instance.

The danger appears specifically when a class variable's value is MUTABLE, like a list or dict — because there's genuinely only ONE such object shared across every instance (unlike immutable class variables, where reading it just gets you the same safe value), mutating it through any one instance's reference silently changes what every OTHER instance sees too, since they're all looking at the exact same underlying object. This is conceptually the exact same underlying trap as the mutable-default-argument problem from Section 02 — 'one shared mutable object accidentally used everywhere it shouldn't be' — just manifesting at the class level instead of the function-parameter level.

class Team:
    members = []   # shared! every Team instance sees the same list

a, b = Team(), Team()
a.members.append("Sara")
print(b.members)   # ['Sara'] — surprise
SECTION 06 · BEGINNER TO INTERMEDIATE

Exceptions & File I/O

Error handling, custom exceptions, and reading/writing files correctly.

8 questions
063try / except / else / finally — what runs when?Intermediate+

These four clauses together form Python's complete error-handling structure, and each one has a distinct, precise role that's worth knowing exactly for interviews. `try` wraps the code that MIGHT raise an exception. `except SomeError:` catches that SPECIFIC exception type if it's raised anywhere inside the `try` block, and its body runs only in that failure case — you can stack multiple `except` clauses to handle different exception types differently.

`else` is the least-known of the four: its body runs ONLY if the `try` block completed with absolutely no exception raised at all — it exists specifically to separate 'the risky operation' from 'what to do once it succeeded', which is cleaner than just adding that success-path code directly inside the `try` block (where it might accidentally catch exceptions it wasn't meant to handle). `finally` is unconditional — its body runs no matter what happened above it, whether an exception was raised, caught, uncaught, or nothing went wrong at all — making it the correct place for cleanup work (closing a file, releasing a lock) that absolutely must happen either way.

try:
    value = int("42")
except ValueError:
    print("Invalid number")
else:
    print("Parsed:", value)
finally:
    print("Finished")
064How do you define and raise a custom exception?Intermediate+

You create a custom exception by defining a new class that inherits from `Exception` (or, more precisely, from whichever built-in exception is the closest semantic match — inheriting from `ValueError` if your custom error is fundamentally a kind of value problem, for instance). Often the class body needs nothing more than `pass`, since it automatically inherits all of `Exception`'s existing message-handling and traceback behavior.

The real payoff of defining a dedicated exception TYPE (rather than just raising a generic `Exception("some message")` everywhere) is that it lets calling code catch YOUR specific error precisely, without accidentally also catching every other unrelated kind of exception a bare `except:` or `except Exception:` would sweep up — a caller can write `except InsufficientBalanceError:` and know with certainty exactly what failure mode they're handling, which also makes the code's intent far clearer to a future reader.

class InsufficientBalanceError(Exception):
    pass

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientBalanceError("Not enough balance")
065What is EAFP, and how does it differ from LBYL?Intermediate+

EAFP stands for 'easier to ask forgiveness than permission' — the idiomatic Python approach of just ATTEMPTING the operation directly inside a `try` block, and handling the specific exception if it turns out the operation wasn't actually valid. It's considered the more Pythonic default style, and it's baked into the language's culture and standard library conventions.

LBYL stands for 'look before you leap' — checking the condition FIRST with an explicit `if`, and only then performing the operation, which is the more common default style in some other languages. EAFP has two concrete practical advantages over LBYL in Python specifically: it avoids doing the same check TWICE (once to look, once implicitly when the operation itself re-validates), and it avoids a subtle class of race condition where the state could change in between your check and your subsequent action (imagine checking `if key in dict` and then having another thread remove that key before you actually read it) — the try/except approach handles both the check and the action as one atomic attempt.

# EAFP (preferred)
try:
    print(data["age"])
except KeyError:
    print("Age missing")

# LBYL
if "age" in data:
    print(data["age"])
066Why should assert never validate untrusted input?Beginner+

`assert condition, "message"` is designed as a debugging and development-time SANITY CHECK — a way to state 'I, the developer, believe this internal assumption must always be true; if it's ever not, something is fundamentally broken in my own logic, and I want to know immediately' — it raises `AssertionError` with the given message if the condition turns out to be false.

The critical thing that makes `assert` unsuitable for validating genuinely untrusted external input (like data coming from a user, an API request, or a file): when Python is run with the `-O` (optimize) command-line flag, EVERY assert statement in the entire program is stripped out and never executes at all — code that relied on an assert to reject bad input would silently let that bad input through completely unchecked in an optimized deployment, which is a serious, real security and correctness risk. Genuine input validation belongs in explicit `if`/`raise` logic that runs unconditionally, never inside an assert.

def divide(a, b):
    assert b != 0, "b must not be zero"   # for developer sanity checks only
    return a / b
067What's the correct way to open and close a file?Beginner+

The correct, idiomatic way is always a `with` block: `with open(path) as file:`. Opening a file this way guarantees that the file's `__exit__` method (which closes the underlying file handle and flushes any pending writes) runs automatically the moment the block ends — CRITICALLY, this happens even if an exception is raised somewhere inside the block, which a manual `file.close()` call at the end of the function would never reach if an error occurred first.

Manually calling `.open()` followed later by `.close()` is both more error-prone (it's easy to forget the close call, or for an early `return`/exception to skip past it entirely) and leaves the file handle open longer than necessary, which can lead to resource leaks — running out of available file descriptors is a genuine failure mode in long-running programs that open many files without properly closing them. The `with` statement's guarantee makes this entire class of bug simply not possible to write accidentally.

with open("notes.txt", "r", encoding="utf-8") as file:
    content = file.read()
# file is guaranteed closed here, even on error
068What is a context manager, and how do you write your own?Intermediate+

A context manager is any object that implements the pair of special methods `__enter__` (run when the `with` block starts, its return value becomes what `as x` binds to) and `__exit__` (run when the block ends, whether normally or via an exception) — this is the general protocol behind the file-closing guarantee from the previous question, and it applies to far more than just files: database transactions, thread locks, and temporary state changes all commonly use this exact pattern.

Writing your own full class with `__enter__`/`__exit__` works but is verbose for simple cases — `contextlib.contextmanager` lets you write a context manager as a regular generator function instead: everything BEFORE the `yield` runs as setup (equivalent to `__enter__`), and everything AFTER the `yield` runs as teardown (equivalent to `__exit__`), and wrapping that teardown code in a `try/finally` (as shown below) guarantees it runs even if the code inside the `with` block raises an exception.

from contextlib import contextmanager

@contextmanager
def managed():
    print("Acquire")
    try:
        yield
    finally:
        print("Release")   # always runs

with managed():
    print("Work")
069How do you work with JSON files in Python?Beginner+

The built-in `json` module provides two clearly-named pairs of functions depending on whether you're working with a STRING or a FILE. `json.dumps(obj)` ('dump string') converts a Python object into a JSON-formatted string in memory, and `json.loads(text)` ('load string') does the reverse — parsing a JSON string back into Python objects (dicts, lists, numbers, strings, etc.).

`json.dump(obj, file)` and `json.load(file)` (no trailing 's') work directly with an already-open FILE object instead of a string — they read from or write to the file handle themselves, which avoids the extra step of manually converting to a string first just to immediately write that string out to disk. A common gotcha worth knowing: Python's `dict` keys can be any hashable type, but JSON object keys must always be strings, so non-string dict keys get silently converted to strings during serialization, which can be surprising if you're not expecting it.

import json
data = {"name": "Tanveer", "skills": ["Python", "AI"]}
with open("data.json", "w") as f:
    json.dump(data, f)
with open("data.json") as f:
    restored = json.load(f)
070Why is unpickling untrusted data dangerous?Advanced+

`pickle` is Python's built-in mechanism for serializing arbitrary Python objects (including custom class instances, not just simple data types) into bytes and back — which sounds convenient, but its actual implementation works by including instructions that get EXECUTED during the deserialization ('unpickling') process, essentially by design, in order to reconstruct arbitrary objects correctly.

This means that unpickling data from an untrusted or unauthenticated source is genuinely equivalent to running arbitrary code that someone else supplied — a maliciously crafted pickle payload can execute essentially anything on the machine that unpickles it, making `pickle.loads()` on untrusted input a serious, well-documented remote-code-execution risk, not a theoretical edge case. For any data that might come from outside your own trusted system boundary — a network request, a file a user uploaded, a message queue shared with other services — JSON (which is pure data with no executable behavior) is the safe, standard choice instead.

import pickle
trusted_bytes = pickle.dumps({"value": 42})   # only safe with trusted data
obj = pickle.loads(trusted_bytes)
SECTION 07 · INTERMEDIATE TO ADVANCED

Iterators, Generators & Decorators

Lazy evaluation and the wrapping pattern that powers Flask routes, Django views, and pytest fixtures alike.

12 questions
071Iterable vs iterator — what's the actual protocol?Intermediate+

An ITERABLE is any object that implements `__iter__`, meaning you can meaningfully write `for item in obj:` over it — lists, strings, dicts, tuples, and sets are all iterables. Calling the built-in `iter()` function on an iterable is what actually PRODUCES an ITERATOR — a separate object that implements `__next__`, which returns the next value each time it's called and internally keeps track of exactly where it currently is in the sequence.

This distinction matters because an iterable and an iterator aren't the same thing, even though they're easy to conflate: a `for` loop actually calls `iter()` on your iterable once at the start (to get an iterator), then repeatedly calls `next()` on that iterator until it raises `StopIteration`, which the loop catches silently to know when to stop. Crucially, an iterator remembers its position and can only move FORWARD — once exhausted, you can't rewind it; you'd need to call `iter()` on the original iterable again to get a fresh iterator starting from the beginning.

numbers = [10, 20, 30]   # iterable
it = iter(numbers)        # iterator
print(next(it), next(it)) # 10 20
072What is a generator function, and how is `yield` different from `return`?Intermediate+

Any function containing at least one `yield` statement anywhere in its body automatically becomes a GENERATOR FUNCTION — and critically, CALLING it does not run any of its code immediately the way calling a normal function would. Instead, calling it just returns a generator OBJECT (which happens to satisfy the iterator protocol from the previous question) with the function's code paused at the very start, waiting to actually begin executing.

Each time `yield value` is reached (either via a direct `next()` call, or implicitly through a `for` loop pulling from the generator), execution PAUSES right at that point, hands `value` back to whoever asked for it, and — this is the key difference from `return` — REMEMBERS exactly where it left off, including every local variable's current value. The very next `next()` call resumes execution from precisely that paused point rather than starting the function over from scratch, which is what makes generators memory-efficient for producing a long or even infinite sequence of values one at a time, without ever needing to hold the entire sequence in memory at once.

def fib(n):
    a, b = 0, 1
    while a < n:
        yield a
        a, b = b, a + b

print(list(fib(10)))   # [0, 1, 1, 2, 3, 5, 8]
073List comprehension vs generator expression — when does the memory difference actually matter?Intermediate+

A list comprehension (`[x*x for x in range(n)]`) computes and stores EVERY single result in memory immediately, all at once, the moment the comprehension runs — you get back a fully realized list you can index into, iterate multiple times, or check the length of right away. A generator expression uses the exact same syntax but with parentheses instead of brackets (`(x*x for x in range(n))`), and it produces values LAZILY, one at a time, on demand, computing each one only when actually asked for it via iteration.

The memory difference genuinely matters once `n` gets large — a list comprehension over a million items holds all million results in memory simultaneously, while the equivalent generator expression holds essentially nothing extra beyond its current position and the logic to compute the next value. Use a generator expression whenever you're going to iterate through the results just ONCE (piping them into `sum()`, a `for` loop, or another function that consumes them sequentially) and don't need random access or to iterate multiple times — a list comprehension is still the right choice when you need to reuse, index into, or re-iterate the results.

list_values = [x * x for x in range(1_000_000)]   # all in memory now
gen_values  = (x * x for x in range(1_000_000))    # nothing computed yet
print(next(gen_values))
074What does `yield from` do?Intermediate+

`yield from other_iterable` delegates iteration to another iterable (or another generator) directly, automatically forwarding each of its values one by one, exactly as if you'd written an explicit `for item in other_iterable: yield item` loop yourself — but more concisely and, for the sub-generator case specifically, more completely correctly.

For plain iterables like lists, `yield from` is mostly a readability convenience over the manual loop. But when delegating to another GENERATOR (or a full coroutine), `yield from` does something the manual loop version can't easily replicate: it also transparently forwards values sent INTO the outer generator (via `.send()`) down to the inner one, and forwards exceptions and the inner generator's eventual return value back up correctly — making it the correct, complete way to compose generators together, not just a shorthand for a simple loop.

def combined():
    yield from [1, 2, 3]
    yield from [4, 5]

print(list(combined()))   # [1, 2, 3, 4, 5]
Diagram · How a Decorator Wraps a Function
def add(a, b): ... original function passed to @logger (decorator) def wrapper(*a,**k): print("calling") return add(*a,**k) returns add = wrapper name rebound @logger def add(...) is exactly: add = logger(add) every call to add() now actually calls wrapper() first
A decorator takes a function, returns a new callable, and the original name gets rebound to it.
075What is a decorator, and how do you write one from scratch?Intermediate+

A decorator wraps an existing function or class to add extra behavior AROUND it — logging, timing, access control, retry logic — WITHOUT editing the original function's own source code at all. The `@decorator` syntax placed directly above a function definition is literally just shorthand: `@logger` above `def add(...):` is exactly equivalent to writing `def add(...): ...` followed by `add = logger(add)` on the next line — the name `add` in your module ends up pointing not at your original function, but at whatever the decorator RETURNED instead.

To write one from scratch: the decorator itself is just a regular function that accepts the original function as its single argument, and it must RETURN some callable to replace it with — typically a new inner function (conventionally called `wrapper`) that does its extra work and then calls the original function somewhere inside itself, forwarding along whatever arguments it received using `*args, **kwargs` so the decorator works regardless of the wrapped function's specific signature.

def logger(func):
    def wrapper(*args, **kwargs):
        print("Calling:", func.__name__)
        return func(*args, **kwargs)
    return wrapper

@logger
def add(a, b): return a + b

print(add(2, 3))
076Why is functools.wraps needed inside a decorator?Intermediate+

When a decorator replaces the original function's NAME with its own inner `wrapper` function, it also — unless you take a specific extra step — silently replaces that function's METADATA too: `wrapped_add.__name__` would show `'wrapper'` instead of `'add'`, and `wrapped_add.__doc__` would show `wrapper`'s (usually empty) docstring instead of `add`'s real one, since the wrapper is a genuinely different function object with its own separate metadata.

This isn't just a cosmetic annoyance — it actively breaks real tooling that inspects functions by name or signature: debuggers and error tracebacks become confusing when every decorated function reports itself as `wrapper`, and — critically for web frameworks specifically — Flask and Django's URL routing systems inspect view function names internally, so multiple decorated routes can collide or misbehave if their wrapped functions all report the same generic `wrapper` name. `functools.wraps(func)`, applied as a decorator ON your `wrapper` function itself, copies over `__name__`, `__doc__`, and other metadata from the original function automatically, fixing this in one line.

from functools import wraps

def decorator(func):
    @wraps(func)          # preserves func's metadata on wrapper
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper
077How do you write a decorator that itself takes arguments?Intermediate+

A plain decorator like `@logger` only receives one argument implicitly (the function being decorated) — but a decorator like `@retry(attempts=3)` needs to accept ITS OWN configuration arguments FIRST, before it even knows which function it will eventually wrap. This requires one additional layer of nesting beyond a normal decorator: an outer function that takes the decorator's configuration arguments and returns the ACTUAL decorator function, which in turn takes the function to be wrapped and returns the final wrapper.

So the full call chain when you write `@retry(attempts=3)` above a function is: Python first calls `retry(attempts=3)`, which returns the real `decorator` function; THEN Python applies that returned decorator to your function exactly like a normal decorator, calling `decorator(your_function)`, which returns the final `wrapper`. This three-layer nested-function shape is exactly what powers `@app.route("/path")` in Flask — the route path is the decorator's own configuration argument, supplied before Flask even knows which view function will be wrapped.

def retry(attempts=3):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(attempts):
                try: return func(*args, **kwargs)
                except Exception: pass
            raise RuntimeError("all attempts failed")
        return wrapper
    return decorator

@retry(attempts=3)
def unstable(): ...
078What is generator.send(), and how does it differ from next()?Advanced+

`next(gen)` resumes a paused generator and runs it until the next `yield`, but it always treats a `yield` expression as evaluating to `None` when resuming — it has no way to feed a VALUE back into the paused generator at the point where it's waiting. `gen.send(value)` does the same resuming action, but additionally makes that paused `yield` EXPRESSION evaluate to `value` inside the generator's own code, effectively injecting data back into where it paused, not just pulling data out.

This turns a generator into a rudimentary two-way communication channel — the generator can both produce values (via `yield`) and receive values (via what gets assigned from the `yield` expression) — which is the exact underlying mechanism that historically powered Python's older generator-based coroutine style, before the dedicated `async`/`await` syntax existed (covered in the next section). One subtlety: the very first call to a generator must be `next(gen)` (or `gen.send(None)`, which is equivalent) to advance it to its first `yield`, since you can't send a real value into a generator that hasn't started running yet.

def receiver():
    value = yield "ready"
    yield f"received: {value}"

gen = receiver()
print(next(gen))          # "ready"
print(gen.send("hello"))  # "received: hello"
079What does itertools.chain() and itertools.islice() give you that plain slicing can't?Intermediate+

`itertools.chain(iter1, iter2, ...)` walks through multiple iterables one after another as though they were a single continuous sequence, WITHOUT actually concatenating them into a new combined list in memory first — it just switches which underlying iterable it's pulling from once the current one is exhausted, which is far more memory-efficient than `list1 + list2` when the inputs are large or when they're generators that can't even be concatenated with `+` in the first place.

`itertools.islice(iterable, start, stop)` gives you slice-LIKE behavior (grabbing a specific range of items), but critically it works on ANY iterable, including generators and other one-time-use iterators that don't support real indexing or slicing at all — and, unlike materializing the whole thing into a list first just to slice it, `islice` never has to realize items outside the requested range, which is exactly why it can safely operate on something like `range(10**9)`, an effectively-huge sequence, without ever building the whole thing in memory.

from itertools import chain, islice
print(list(chain([1, 2], [3, 4])))
print(list(islice(range(10**9), 5, 10)))   # works even though range is huge
080How would you implement a class-based decorator?Intermediate+

Any object that implements `__call__` can be used exactly like a function — Python doesn't care whether the callable you're using is a `def`-defined function or a class instance, as long as it can be invoked with `()`. This means a class can act as a decorator too: you implement `__init__` to receive the function being decorated (storing it as an instance attribute), and implement `__call__` to actually invoke that stored function when the decorated 'function' is later called.

The genuine advantage of a class-based decorator over a plain function-based one appears when the decorator needs to hold and maintain STATE across multiple calls — a class instance naturally has its own persistent attributes (like a running call counter) that survive between invocations, which is more explicit and sometimes clearer than relying on a closure variable captured inside a nested function-based decorator.

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.calls = 0
    def __call__(self, *args, **kwargs):
        self.calls += 1
        return self.func(*args, **kwargs)

@CountCalls
def greet(): return "hi"
081What is a class decorator, as opposed to a function decorator?Intermediate+

The decorator syntax `@something` isn't limited to decorating functions — it can be applied to a `class` definition too, and in that case the decorator RECEIVES the entire class object itself (not an instance of it) as its argument, and can inspect, modify, or entirely replace that class before the name gets bound to it, exactly parallel to how a function decorator works on functions.

A very common real use case is registering classes into some central lookup automatically, purely as a side effect of defining them — a plugin system might use a class decorator to add every decorated class into a shared `registry` dictionary keyed by class name, so that other code can later look up and instantiate the right plugin class by a string name without needing an explicit, manually-maintained if/elif chain. You've almost certainly already used a class decorator without necessarily thinking of it that way: `@dataclass` (from Section 05) is exactly this pattern — it receives your class and returns a modified version of it with `__init__`, `__repr__`, and `__eq__` automatically added.

def register(cls):
    registry[cls.__name__] = cls
    return cls

registry = {}
@register
class Plugin: pass
082What is a coroutine, and how does it relate to generators?Advanced+

Historically, before Python had dedicated `async`/`await` syntax, 'coroutines' in Python meant exactly the generator-plus-`.send()` pattern from a few questions above — a generator function driven not by simply pulling values OUT of it with `next()`, but by cooperatively pausing and resuming it while also pushing values IN via `.send()`, letting two pieces of code hand control back and forth to each other.

Modern Python has a completely separate, dedicated coroutine TYPE created with `async def`, with its own cleaner `await` keyword for suspension instead of overloading generator syntax — but conceptually, under the hood, it's built on the exact same fundamental suspension-and-resumption mechanism the interpreter uses for generators. Understanding old-style generator-based coroutines gives real insight into WHY `async`/`await` (Section 09) behaves the way it does, even though you'd write genuinely new async code using `async def` and `await`, not the older generator-based style.

SECTION 08 · ADVANCED

Advanced Python Internals

What's actually happening under the hood — memory, metaclasses, descriptors, and the questions that separate senior candidates.

12 questions
083How does CPython manage memory — reference counting and cyclic GC?Advanced+

CPython's primary memory management strategy is REFERENCE COUNTING: every object silently keeps a running count of how many places in the program currently refer to it, and the instant that count drops to zero — no code anywhere still holds a reference to it — the object is immediately and deterministically freed. This is why CPython memory is usually reclaimed promptly and predictably, rather than in occasional unpredictable pauses like some garbage-collected languages.

Reference counting alone has one fundamental blind spot: REFERENCE CYCLES, where two or more objects reference each other (directly or through a longer chain), keeping each other's count above zero forever even though nothing OUTSIDE that cycle can reach them anymore — a classic memory leak that pure reference counting can never detect on its own. CPython solves this with a separate, periodically-running GENERATIONAL cyclic garbage collector that specifically scans for and reclaims these unreachable cycles — it's mostly invisible and automatic, and manually calling `gc.collect()` is rarely needed in normal application code.

import gc
print(gc.isenabled(), gc.get_count())
collected = gc.collect()   # rarely needed manually
print("Collected:", collected)
084What is __slots__, and what's the trade-off?Advanced+

By default, every instance of a regular Python class carries its own per-instance `__dict__` — a full hash table used to store that instance's attributes — which is flexible (you can add brand new attributes to an instance at any time) but comes with real memory overhead, since a hash table itself takes noticeably more memory than the bare minimum needed just to store a fixed, known set of values.

Declaring `__slots__ = ("x", "y")` on a class tells Python 'this class will only ever have these specific named attributes', and in exchange, Python skips creating that per-instance `__dict__` entirely, storing the declared attributes in a more compact, fixed-size internal structure instead — this can meaningfully cut memory usage when you're creating a very large number of small objects (imagine millions of `Point` objects in a data-processing pipeline). The real trade-off: instances of a slotted class can no longer have arbitrary NEW attributes added at runtime (attempting `p.z = 5` when `z` wasn't declared in `__slots__` raises `AttributeError`), and combining `__slots__` with multiple inheritance across classes that both define slots gets genuinely tricky and is generally best avoided.

class Point:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x, self.y = x, y

p = Point(1, 2)
# p.z = 5   # AttributeError — not in __slots__
085What is a metaclass, in plain terms?Advanced+

Every regular object in Python is an instance of some class — a metaclass extends that same idea one level higher: it's literally the class that a CLASS ITSELF is an instance of. Just as your own custom class controls how INSTANCES of it get created and behave, a metaclass controls how CLASSES THEMSELVES get created and behave — by default, every class you define is secretly an instance of the built-in metaclass `type`, whether or not you ever think about it explicitly.

You reach for a custom metaclass specifically when you need to intercept or modify the CLASS-CREATION process itself — automatically injecting attributes into every class that uses it, validating that a class definition follows certain rules, or registering every subclass automatically the moment it's defined. Both Django's ORM (which uses a custom metaclass to turn declared model fields into actual database-aware descriptors behind the scenes) and Python's own `abc.ABC` machinery rely on custom metaclasses — but this is genuinely advanced, rarely-needed territory: most application code, even fairly sophisticated code, never needs to write a custom metaclass of its own.

class Meta(type):
    def __new__(mcls, name, bases, ns):
        ns["category"] = "generated"
        return super().__new__(mcls, name, bases, ns)

class Example(metaclass=Meta): pass
print(Example.category)   # "generated"
086What are descriptors, and where have you already used them without knowing it?Advanced+

A descriptor is any object that controls what happens when an attribute is READ, WRITTEN, or DELETED on the class that holds it, by implementing one or more of `__get__`, `__set__`, and `__delete__` — instead of a plain attribute just sitting there as a static value, a descriptor lets you run arbitrary code (validation, computed values, logging) every single time that attribute is accessed or assigned, at the CLASS level rather than needing to hand-write it per attribute inside `__init__`.

This is genuinely more foundational to Python than it first appears: `@property` is itself implemented internally as a descriptor, ordinary METHODS on a class are technically descriptors too (which is part of the actual mechanism that makes `self` get automatically passed in when you call `instance.method()`), and `@staticmethod`/`@classmethod` are descriptors as well. Beyond the language internals, most serious ORMs — Django's model fields, SQLAlchemy's column types — use custom descriptors under the hood to intercept attribute assignment and automatically run validation or track 'dirty' (changed but not yet saved) state, which is exactly why writing `product.price = -5` can trigger validation logic immediately rather than only failing later when you try to save it.

class Positive:
    def __set_name__(self, owner, name): self.name = "_" + name
    def __get__(self, obj, owner): return getattr(obj, self.name)
    def __set__(self, obj, value):
        if value <= 0: raise ValueError("must be positive")
        setattr(obj, self.name, value)

class Product:
    price = Positive()
    def __init__(self, price): self.price = price
087__new__ vs __init__ — what does each one own?Advanced+

`__init__` is the method most Python developers think of as 'the constructor', but strictly speaking it only INITIALIZES an object that has already been created — the actual CREATION step is a separate, earlier special method called `__new__`, which is a static method responsible for actually allocating and returning the new (usually still-empty) instance. Under normal circumstances, `object.__new__` handles this automatically and invisibly, and `__init__` runs immediately afterward on the object it produced, so most code never needs to think about `__new__` at all.

You need to override `__new__` specifically in situations where `__init__` genuinely can't do the job — most commonly, subclassing an IMMUTABLE built-in type (like creating a custom `str` subclass that also validates or transforms its value, which must happen at creation time since a `str`'s value can never be changed afterward by `__init__` alone), or implementing a strict singleton pattern where you need to control whether a NEW instance gets created at all versus returning an already-existing one — a decision that has to happen before `__init__` would even run.

class Demo:
    def __new__(cls, *a, **kw):
        print("Creating"); return super().__new__(cls)
    def __init__(self, value):
        print("Initializing"); self.value = value

Demo(10)
088What is a weak reference, and when do you need one?Advanced+

A normal reference to an object increments that object's reference count, which is exactly what keeps it alive under CPython's reference-counting memory model (from the earlier question) — as long as at least one normal reference exists somewhere, the object cannot be garbage collected. A `weakref` deliberately breaks this: it lets you hold a reference to an object WITHOUT counting toward its reference count at all, meaning the object can still be freed even while a weak reference to it still technically exists (in which case the weak reference simply starts returning `None` when called).

The classic use case is a CACHE or an observer/listener registry: if a cache held normal (strong) references to every object it had ever cached, those objects could never actually be garbage collected as long as the cache itself was alive — even objects nobody else in the program cares about anymore would be kept alive artificially, purely because the cache is still referencing them, creating a memory leak. Using weak references in the cache instead means the cache doesn't itself keep otherwise-unused objects alive; they get collected normally once nothing else needs them, and the cache entry simply becomes stale/empty rather than preventing cleanup.

import weakref
class User: pass
user = User()
ref = weakref.ref(user)
print(ref() is user)   # True
del user
print(ref())            # None — object is gone
089What is monkey patching, and why treat it carefully?Advanced+

Monkey patching means replacing or modifying an attribute, method, or function on an already-loaded module or class AT RUNTIME, from OUTSIDE that module's own original source code — Python allows this freely because classes and modules are themselves just regular, mutable objects that can be reassigned like anything else.

It has a legitimate, well-established use in TESTING: replacing a real network call or database dependency with a fake/mock version for the duration of a test, so the test doesn't need real infrastructure to run — testing libraries like `unittest.mock` are built around exactly this pattern. In application/production code, though, it's a genuine maintenance hazard: behavior that depends on a monkey patch having been applied somewhere else, at some point, in some specific import order becomes extremely hard to trace and reason about — a bug caused by a monkey patch can be almost invisible in the code you're actually looking at, since the patch itself lives somewhere completely different.

class Service:
    def status(self): return "original"

def fake_status(self): return "patched"
Service.status = fake_status   # monkey patch
print(Service().status())
Diagram · Shallow Copy vs Deep Copy
copy.copy() - shallow original clone same nested [1,2] mutate through either - both see it copy.deepcopy() - deep original clone nested [1,2] nested [1,2] copy fully independent - mutate one, the other is untouched import copy; b = copy.copy(a) vs c = copy.deepcopy(a) assignment (b = a) is neither - it's just two names for one object
Shallow copy duplicates the outer container only; deep copy recursively duplicates everything.
090Shallow copy vs deep copy vs plain assignment — walk through the differenceIntermediate+

Plain assignment (`b = a`) doesn't create any new object at all — it just creates a second NAME that points at the exact same underlying object as `a` already does, so `a` and `b` are truly indistinguishable and any change through either name affects 'both', because there's really only ever one object. `copy.copy(a)` (a shallow copy) creates a genuinely new OUTER container object, but its contents are still just references to the SAME inner objects the original had — if those inner objects are mutable, mutating one through the copy still visibly affects the original, because that specific nested object was never actually duplicated.

`copy.deepcopy(a)` recursively walks the entire structure and creates independent copies of every nested object all the way down, so the resulting structure shares NOTHING with the original — mutating any part of the deep copy leaves the original completely untouched. The practical rule: use plain assignment when you genuinely want two names for the same thing, shallow copy when you want a new top-level container but don't mind sharing nested mutable data, and deep copy specifically when you need total, guaranteed independence — understanding that deep copy is also the most expensive of the three, since it has to visit and duplicate every nested level.

import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)

shallow[0].append(99)   # affects original too
deep[0].append(77)      # original stays untouched
091What do id() and hash() actually return, and how do dicts use them?Intermediate+

`id(obj)` returns an integer that uniquely identifies a specific object for as long as that object stays alive — in CPython's actual implementation, this happens to correspond to the object's memory address, though that's an implementation detail you shouldn't rely on for anything beyond identity comparison. Two separate `id()` calls on the SAME object always return the same number; two DIFFERENT objects (even if they're equal in value) will have different `id()` values.

`hash(obj)` returns a different kind of integer entirely — one specifically designed for BUCKETING an object efficiently inside a hash-table-based container like a dict or set, so that lookup can jump almost directly to roughly the right location instead of scanning every entry. Only HASHABLE objects (immutable built-ins, or custom objects with a properly implemented `__hash__`) can be dict keys or set members precisely because the object's hash value must never change for as long as it's stored in that container — a mutable object's hash could become 'wrong' relative to where it was originally placed in the table the moment its content changed, silently breaking future lookups.

a, b, c = [], [], a  # b is a distinct empty list; c aliases a
print(id(a) == id(c))   # True
print(id(a) == id(b))   # False
092What is name mangling, and does it actually make an attribute private?Advanced+

Any attribute name starting with two leading underscores and at most one trailing underscore, WHEN WRITTEN INSIDE A CLASS BODY, gets automatically rewritten by the Python compiler to `_ClassName__attributename` — so `self.__token` inside a class named `User` actually gets stored, transparently, as `self._User__token`. This transformation happens purely at the SOURCE-CODE level, based on the class the code is textually written inside, not based on any runtime access-control check.

The real, documented purpose of name mangling is narrower than most people assume: it exists specifically to prevent ACCIDENTAL name collisions when a subclass happens to define an attribute with the exact same double-underscore name as something in a parent class it doesn't know about — each class's mangled version stays distinct from the others automatically. It is explicitly NOT a security or access-control mechanism: the mangled name is fully visible and directly accessible from outside the class if you simply know (or look up) what it got mangled TO, which anyone can trivially discover by inspecting `instance.__dict__`.

class User:
    def __init__(self): self.__token = "secret"

u = User()
print(u.__dict__)   # {'_User__token': 'secret'} — visible, just renamed
093What does the walrus operator := actually buy you?Intermediate+

Ordinary assignment in Python (`x = 5`) is a STATEMENT, not an expression — it doesn't produce a usable value of its own, which is exactly why you can't write something like `if (x = 5):` in Python the way some other languages allow. The walrus operator `:=`, introduced in Python 3.8, creates an ASSIGNMENT EXPRESSION instead — it both assigns a value to a name AND evaluates to that same value, in one single expression, which means it CAN legally appear inside a larger expression like an `if` condition.

The practical benefit is avoiding computing or fetching the same value TWICE — without the walrus operator, checking a condition based on some computed value and then using that value again inside the block typically means either computing it twice (wasteful, and risky if the computation isn't perfectly deterministic or is expensive) or introducing an extra line just to store it first. `if (length := len(text)) > 5:` computes `len(text)` exactly once, both checks it against 5 AND makes it available as `length` for use inside the block, in a single compact line.

text = "Python"
if (length := len(text)) > 5:
    print("Length:", length)   # no separate len(text) call needed
094How do you measure how much memory an object actually uses?Advanced+

`sys.getsizeof(obj)` returns the number of bytes the object ITSELF occupies — but this is a genuinely common trap in interviews and in real profiling work, because it only measures a SHALLOW size: for a container like a list, it counts the memory used by the list structure and its internal array of POINTERS to the contained items, but it does NOT recursively add up the memory used by whatever those contained items themselves actually are.

This means `sys.getsizeof([1, 2, 3])` dramatically understates the true memory footprint of a list containing large or complex nested objects, since none of those nested objects' own sizes get included at all — to get a genuinely accurate total for a nested structure, you'd need to recursively walk it yourself (or use a specialized third-party memory-profiling tool designed for exactly this) rather than trusting a single `sys.getsizeof()` call on the outer container to represent everything inside it.

import sys
items = [1, 2, 3]
print(sys.getsizeof(items))   # size of the list container itself only
SECTION 09 · ADVANCED

Concurrency, Parallelism & Async

The GIL, threading, multiprocessing, and asyncio — and how to pick the right one under interview pressure.

14 questions
Diagram · Threading vs Multiprocessing vs Asyncio
THREADING (1 CPU core, GIL) Core 1 T1 T2 T3 GIL lets one thread run bytecode at a time great for I/O-bound waiting, not CPU-bound math MULTIPROCESSING (N cores) Core 1 Core 2 Proc A Proc B separate memory, separate GIL each true parallelism - best for CPU-bound work ASYNCIO (1 thread, event loop) single thread, event loop task1 task2 task3 cooperative switching at each await huge numbers of I/O tasks, near-zero overhead rule of thumb: I/O-bound + many tasks -> asyncio . I/O-bound + blocking libs -> threading CPU-bound (crunching numbers) -> multiprocessing
All three solve "do more than one thing at once" — they differ in memory model and what kind of waiting they're good at.
Comparison · Threading vs Multiprocessing vs Asyncio
ThreadingMultiprocessingAsyncio
MemorySharedSeparate per processShared (single thread)
Limited by GIL?YesNo (each process has its own)N/A — one thread, cooperative
Best forI/O-bound, blocking librariesCPU-bound, heavy computationI/O-bound, thousands of tasks
Overhead per taskModerate (OS thread)High (new process + IPC)Very low (just a coroutine object)
Failure modeRace conditionsSerialization/IPC costOne blocking call stalls everything
095What is the GIL, and why does it exist?Advanced+

The Global Interpreter Lock is a single mutex inside CPython (the standard Python implementation) that only allows ONE thread to be executing Python BYTECODE at any given instant — even on a machine with many CPU cores, and even if you've spun up multiple Python threads, only one of them is actually running Python code at a time; the others are waiting their turn. It exists primarily because CPython's memory management (the reference counting from Section 08) is not thread-safe by default, and the GIL was the simplest, most performant way to protect that shared internal state without requiring fine-grained locking around every single object.

The practical consequence interviewers really want you to state clearly: Python threads give you genuine concurrency for I/O-BOUND work — while one thread is blocked waiting on a network response or a disk read, the GIL is released and another thread can run — but they do NOT give you real PARALLELISM for CPU-bound work, since only one thread can ever be crunching Python bytecode at once regardless of how many CPU cores are physically available. This is exactly why spinning up 4 threads to do pure number-crunching barely speeds anything up compared to 1 thread, while spinning up 4 threads to make 4 concurrent network requests genuinely does help.

# CPU-bound work barely speeds up with threads because of the GIL:
from threading import Thread
def crunch(): sum(i * i for i in range(10_000_000))
# 4 threads here roughly equals the same wall time as 1, for pure CPU work
096Is the GIL going away? What should you know about free-threaded Python?Advanced+

CPython has been actively working on and shipping an experimental 'free-threaded' build (defined by PEP 703, first available starting with Python 3.13) that CAN run without the GIL at all, allowing genuine multi-core parallelism for regular Python threads for the first time in the language's history — a change that took a substantial amount of internal rework to make CPython's memory management thread-safe without relying on the single global lock.

The precise, interview-safe way to describe where things currently stand: this free-threaded mode is OPT-IN and still maturing (as of the current knowledge available), not the default mainstream behavior of ordinary CPython installs — many C extensions and libraries in the ecosystem still need updates to be fully compatible with running safely without the GIL. The accurate claim is that the GIL is being made OPTIONAL over time, not that it has already vanished from standard, everyday Python — overstating this in an interview is a subtle but real accuracy mistake.

097What is a race condition, and how does a Lock fix it?Advanced+

A race condition occurs when the correctness of a program depends on the unpredictable TIMING or ORDERING of operations across multiple threads (or processes) accessing shared state — the classic example is two threads both reading a shared counter's current value, both independently computing 'value + 1', and both writing their result back, with the second write simply overwriting the first, silently losing one of the two increments even though the program logically intended both to count.

A `Lock` (also called a mutex) fixes this by making a specific block of code MUTUALLY EXCLUSIVE — only one thread at a time is permitted to be inside a `with lock:` block that's protecting the same lock object; any other thread that tries to enter has to wait until the first one releases it. Wrapping every read-modify-write sequence on shared state in the SAME lock turns what would otherwise be an unpredictable race into a strictly ordered, one-at-a-time sequence of complete operations, eliminating the possibility of two threads interleaving their reads and writes in a way that loses data.

from threading import Lock
lock = Lock()
counter = 0

def increment():
    global counter
    with lock:          # only one thread inside at a time
        counter += 1
098What is a deadlock, and how do you avoid one?Advanced+

A deadlock happens when two (or more) threads each end up permanently WAITING for a resource the other one is currently holding — thread A holds Lock 1 and is waiting to acquire Lock 2, while thread B holds Lock 2 and is waiting to acquire Lock 1; neither can ever proceed, because each is blocked waiting for the exact resource the other refuses to release, and neither will release what it's holding until it finishes (which it can never do).

The standard, universally-recommended defense is establishing and consistently following a single GLOBAL ORDER for acquiring multiple locks throughout your entire codebase — if every single piece of code that needs both Lock 1 and Lock 2 always acquires Lock 1 first and Lock 2 second, without exception, the circular-waiting scenario described above simply cannot arise, because there's no code path that could ever acquire them in the opposite order. Minimizing how often you need to hold multiple locks simultaneously at all, and keeping locked sections as short as possible, further reduces the surface area where a deadlock could occur in the first place.

099What does concurrent.futures give you over raw threading/multiprocessing?Intermediate+

`concurrent.futures` provides a HIGH-LEVEL, UNIFIED API for submitting units of work and collecting their results, sitting on top of both the low-level `threading` and `multiprocessing` modules — `ThreadPoolExecutor` and `ProcessPoolExecutor` share the exact same interface (`.map()`, `.submit()` returning a `Future` object you can check or wait on), which means you can switch your program between thread-based and process-based concurrency literally just by swapping which class you instantiate, without rewriting any of your actual submission/collection logic.

This also removes a fair amount of manual bookkeeping that raw `threading`/`multiprocessing` usage requires — you don't need to manually create and track individual `Thread`/`Process` objects, manually call `.join()` on each one, or manually collect results into a shared list yourself; the executor and its pool of workers handle all of that internally, and `.map()` in particular gives you back results in the same order as the inputs, even though the underlying work may have completed in a different order.

from concurrent.futures import ThreadPoolExecutor

def square(x): return x * x
with ThreadPoolExecutor(max_workers=4) as pool:
    print(list(pool.map(square, [1, 2, 3, 4])))
100What does `await` actually do to a coroutine?Advanced+

`await some_coroutine_or_awaitable` suspends execution of the CURRENT coroutine at exactly that point, and — critically — hands control back to the EVENT LOOP rather than blocking the whole program while it waits. The event loop is then free to go run any OTHER ready task during that waiting time, and it will resume this specific coroutine again once the awaited operation has actually made progress or fully completed.

This cooperative hand-off is the entire mechanism underpinning asyncio's concurrency model: unlike threads, where the operating system can pause ANY thread at essentially any instruction (preemptive scheduling), asyncio tasks only ever yield control voluntarily, at an explicit `await` point — which means asyncio code never needs locks to protect shared state the way multi-threaded code does (since only one coroutine is ever actually running at once, and it only gets interrupted at points it explicitly agreed to), but it also means a single coroutine that never awaits anything (like a long CPU-bound loop) will block the ENTIRE event loop and starve every other task.

import asyncio
async def main():
    print("Start")
    await asyncio.sleep(1)   # yields control here, not blocking the loop
    print("End")
asyncio.run(main())
101asyncio.create_task() vs plain await — what's the difference?Advanced+

Simply writing `await some_coroutine()` runs that coroutine and waits for it to fully finish before your own code moves on to the next line — this is SEQUENTIAL from your coroutine's perspective, even though the underlying await itself doesn't block the whole event loop; nothing else you write runs concurrently with it unless you've separately scheduled something else beforehand.

`asyncio.create_task(some_coroutine())` is different: it immediately schedules the coroutine to start running on the event loop RIGHT AWAY, in the background, and returns a `Task` object you can hang onto — your own code continues executing the very next line immediately, without waiting, and the two pieces of work now genuinely run concurrently. You only need to actually `await` the task later, at the point where you specifically need its RESULT — which is exactly the pattern that lets you kick off several independent operations at once and only wait for all of them once you actually need their combined results, rather than waiting for each one to finish before starting the next.

import asyncio
async def work():
    await asyncio.sleep(1); return 42

async def main():
    task = asyncio.create_task(work())   # starts running now
    print("doing other things")
    print(await task)                     # collect result later

asyncio.run(main())
102What does asyncio.gather() do, and how does error handling change if one task fails?Advanced+

`asyncio.gather(coro1, coro2, coro3)` runs multiple awaitables CONCURRENTLY (scheduling all of them onto the event loop at once, similar to wrapping each in `create_task` internally) and returns their results together as a list, in the SAME ORDER the awaitables were originally passed in — regardless of which one actually happens to finish first in real time.

Error handling has a genuinely important default behavior worth knowing precisely: by default, the moment ANY one of the gathered coroutines raises an exception, that exception propagates immediately out of the `gather()` call itself, and gather also attempts to CANCEL the other still-running sibling tasks rather than letting them continue to completion. Passing `return_exceptions=True` changes this behavior entirely — instead of raising immediately, gather waits for every task to finish (successfully or not) and returns a list where any failed task's SLOT simply contains its exception object instead of a normal result, letting you inspect and handle failures individually after everything has settled rather than losing all the other results the instant one thing goes wrong.

import asyncio
async def double(x): await asyncio.sleep(0.1); return x * 2

async def main():
    results = await asyncio.gather(double(1), double(2), double(3))
    print(results)
asyncio.run(main())
103How do you build a producer/consumer pipeline with asyncio.Queue?Advanced+

`asyncio.Queue` is the async-native equivalent of the thread-safe `queue.Queue` covered a couple of questions below — it provides `await queue.put(item)` and `await queue.get()` methods that cooperate correctly with the event loop (an `await q.get()` on an empty queue suspends that specific coroutine and lets other tasks run, rather than blocking the entire program while waiting for something to arrive).

The classic producer/consumer pipeline pattern: one or more PRODUCER coroutines put items onto the queue as they become available, while one or more CONSUMER coroutines pull items off and process them — often running concurrently via `asyncio.gather()`. `queue.task_done()`, called by a consumer after finishing work on an item, combined with `await queue.join()` elsewhere, gives you a clean way to know precisely when ALL queued work has actually been fully processed, not just when the queue happens to look empty (which could just mean a producer hasn't added the next item yet).

import asyncio
async def producer(q):
    for i in range(5): await q.put(i)
    await q.put(None)

async def consumer(q):
    while (item := await q.get()) is not None:
        print("Processing:", item)
        q.task_done()
    q.task_done()

async def main():
    q = asyncio.Queue()
    await asyncio.gather(producer(q), consumer(q))
asyncio.run(main())
104How do threads safely hand off data to each other?Intermediate+

`queue.Queue` (the classic, thread-based version, distinct from `asyncio.Queue`) is specifically designed to be THREAD-SAFE out of the box — internally, its `.put()` and `.get()` methods handle all the necessary locking on your behalf, meaning multiple threads can safely add to and remove from the same queue concurrently WITHOUT you needing to write any manual `Lock` code yourself around it, unlike a plain list, which is not safe to mutate concurrently from multiple threads without your own external locking.

This makes `queue.Queue` the standard, idiomatic tool for the classic producer/consumer pattern in threaded code: one or more producer threads push work items onto the queue, and one or more worker/consumer threads pull items off and process them, with the queue itself handling all the coordination and blocking behavior (a `.get()` call on an empty queue simply blocks that thread until something becomes available) needed to make this safe and correct.

from queue import Queue
from threading import Thread

q = Queue()
def worker():
    while (item := q.get()) is not None:
        print("got", item)
        q.task_done()
105Given a mixed workload, how do you decide threading vs multiprocessing vs asyncio?Intermediate+

The right first question is always: 'what is this work actually spending most of its TIME doing?' — waiting on something external (network requests, database queries, disk I/O), or actively computing on the CPU? If the workload is I/O-bound and involves MANY concurrent operations (hundreds or thousands of simultaneous network requests, for example), asyncio is typically the most efficient choice, since each asyncio task has extremely low overhead compared to a real OS thread.

If the I/O-bound work has to go through existing BLOCKING libraries that aren't async-native (a database driver or a third-party SDK with no async support), threading is often the more practical choice, since it doesn't require rewriting everything around `async`/`await` — the GIL is released during genuinely blocking I/O calls, so threads still provide real concurrency benefit here despite the GIL's limitation on CPU-bound work. If the workload is CPU-bound — actual number-crunching, image processing, heavy computation — multiprocessing is the right tool, since each process gets its own independent Python interpreter and its own GIL, achieving genuine multi-core PARALLELISM that neither threading nor asyncio can provide on their own (though pushing the heavy computation into NumPy or a C extension, which release the GIL internally during their own compiled code, is often an even better first option before reaching for multiprocessing's overhead).

106Coding: implement a thread-safe counterAdvanced+

This is a very common practical follow-up to the race-condition question above — it asks you to actually apply the Lock pattern correctly in real code, rather than just describing it abstractly. The core requirement: every single mutation of the shared `value` attribute must happen while holding the SAME lock instance, so that increments from different threads can never interleave and lose an update.

Wrapping the increment logic in a small class (rather than relying on a bare global variable and a module-level lock) also demonstrates good practice: the lock and the state it protects are bundled together, making it much harder for future code to accidentally mutate `value` from somewhere else without going through the lock — a mistake that's easy to make with loose global variables and a separately-declared lock that nothing enforces using consistently.

from threading import Lock, Thread

class Counter:
    def __init__(self):
        self.value = 0
        self.lock = Lock()
    def increment(self):
        with self.lock:
            self.value += 1

counter = Counter()
threads = [Thread(target=counter.increment) for _ in range(100)]
for t in threads: t.start()
for t in threads: t.join()
print(counter.value)   # 100, reliably
107Coding: write a retry decorator using time.sleep for backoffAdvanced+

This problem combines two earlier sections directly: the parameterized-decorator pattern from Section 07 (an outer function taking configuration like `attempts` and `delay`, returning the actual decorator) applied to a genuinely practical resilience concern — automatically retrying a flaky operation (a network call that occasionally times out, for instance) a bounded number of times before finally giving up, rather than failing immediately on the very first transient error.

A correct implementation needs to remember the LAST exception encountered across all failed attempts, so that if every single attempt ultimately fails, it can re-raise that most recent, most relevant error to the caller — rather than swallowing the failure information entirely or raising some generic, less informative error that hides what actually went wrong on the final attempt.

from functools import wraps
import time

def retry(attempts=3, delay=0.2):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for _ in range(attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_error = exc
                    time.sleep(delay)
            raise last_error
        return wrapper
    return decorator

@retry(attempts=3)
def unstable():
    raise RuntimeError("Temporary failure")
108How do you measure whether concurrency actually helped?Intermediate+

It's a genuine mistake to assume adding threads, processes, or async automatically makes code faster — sometimes the coordination overhead (spawning threads/processes, context switching, serialization for inter-process communication) can outweigh the benefit, especially for small workloads. The only reliable way to know is to actually MEASURE, comparing a concurrent version against a simple sequential BASELINE doing the exact same work.

`time.perf_counter()` is the standard choice for measuring wall-clock elapsed time around a larger benchmark — it's a high-resolution monotonic clock specifically meant for timing intervals (unlike `time.time()`, which reflects wall-clock date/time and can jump around due to system clock adjustments). For small, repeatable snippets where you want a statistically stable measurement rather than one noisy single run, the `timeit` module runs the code many times automatically and reports a more reliable average, correctly accounting for measurement overhead that a single manual `perf_counter()` call around a tiny operation might not.

from time import perf_counter
start = perf_counter()
# ... concurrent work ...
print("elapsed:", perf_counter() - start)
SECTION 10 · DATA SCIENCE

NumPy

Vectorized array computing — the foundation pandas, scikit-learn, and most of the scientific Python stack build on.

14 questions
109What is NumPy, and what problem does it solve?Beginner+

NumPy ('Numerical Python') is built around one core data structure — the `ndarray` — an N-dimensional array where every element must be the SAME fixed data type, stored contiguously in memory, unlike a plain Python list which stores generic pointers to arbitrary Python objects scattered around memory. On top of that array, NumPy provides a huge library of fast mathematical operations implemented in compiled C, operating on entire arrays at once instead of element by element in Python bytecode.

This combination is the entire reason NumPy exists and matters: Python lists are flexible but slow for heavy numeric work, since every arithmetic operation on a list element involves Python-level overhead (type checking, dynamic dispatch) repeated for every single item. NumPy trades away that per-element flexibility for raw speed and memory efficiency, and it's exactly this performance foundation that pandas, scikit-learn, PyTorch, and most of the rest of the scientific Python ecosystem are built directly on top of.

110How do you create 1D, 2D, and 3D arrays?Beginner+

`np.array()` is the most direct way to create an array from existing Python data — it automatically infers the number of dimensions from how deeply your input is nested: a flat list becomes 1D, a list of lists becomes 2D, and a list of lists of lists becomes 3D. Passing `ndmin=N` forces the result to have at least N dimensions even if the input data itself wouldn't naturally produce that many, which is occasionally useful for keeping array shapes consistent across code paths.

For arrays you want to BUILD rather than convert from existing data, `np.zeros(shape)` and `np.ones(shape)` create arrays pre-filled with 0s or 1s of any given shape without you needing to type out every value, and `np.arange(start, stop, step)` is NumPy's array-producing equivalent of Python's built-in `range()`, generating evenly-spaced values directly as an array.

import numpy as np
np.array([1, 2, 4])                 # 1D
np.array([[1,2,3],[4,5,6]])         # 2D
np.zeros((2, 3))                    # 2x3 of zeros
np.arange(0, 10, 2)                 # [0 2 4 6 8]
111shape vs reshape — how do you check and change an array's dimensions?Beginner+

`.shape` is a read-only attribute (a tuple, not a method you call) that tells you the size of the array along each of its dimensions — a 2D array's shape `(3, 4)` means 3 rows and 4 columns; a 1D array's shape `(12,)` means 12 elements along a single axis (note the trailing comma — it's still a tuple even with one element).

`.reshape(new_shape)` returns a new VIEW of the same underlying data, just reinterpreted with a different shape — critically, the TOTAL number of elements must stay exactly the same before and after (you can't reshape a 12-element array into a shape holding 10 or 15 elements), since reshape doesn't create or destroy any actual data, it just changes how that data is indexed. Passing `-1` for one of the dimensions tells NumPy 'figure this size out automatically based on the total element count and the other dimensions I specified', which avoids you having to manually compute it yourself.

arr = np.arange(12)
print(arr.shape)          # (12,)
grid = arr.reshape(3, 4)  # 3 rows, 4 cols
grid2 = arr.reshape(3, -1) # same thing, -1 inferred
112How does NumPy indexing/slicing differ from a Python list's?Intermediate+

NumPy extends indexing well beyond what a plain Python list supports: comma-separated multi-dimensional indexing (`grid[1, 2]` for row 1, column 2 in one step, instead of the nested `grid[1][2]` a list of lists would require), BOOLEAN MASK indexing (selecting elements based on a condition applied to the whole array at once, like `grid[grid > 3]`), and FANCY indexing (selecting using an entire array of indices at once, like `arr[[0, 2, 4]]`) — none of which a plain Python list can do directly.

An important, genuinely easy-to-miss distinction with real performance and correctness implications: slicing a NumPy array (`arr[1:3]`) usually returns a VIEW that shares the same underlying memory as the original array — modifying the slice modifies the original too — while slicing a plain Python list ALWAYS creates an independent COPY. This means code that assumes 'slicing is always safe and independent' (true for lists) can introduce subtle bugs when applied to NumPy arrays, where an accidental mutation through a slice silently corrupts the original array.

grid = np.array([[1,2,3],[4,5,6]])
print(grid[1, 2])          # 6 — row 1, col 2
print(grid[:, 1])          # [2 5] — whole column
print(grid[grid > 3])      # [4 5 6] — boolean mask
Diagram · NumPy Broadcasting
3x3 array 1 2 3 4 5 6 7 8 9 + 1x3 array (stretched) 10 20 30 10 20 30 10 20 30 = 3x3 result 11 22 33 14 25 36 17 28 39 a (3,3) + b (3,) -> b's shape is stretched to (3,3), no data actually copied rule: trailing dimensions must match, or one of them must be 1
Broadcasting lets NumPy apply an operation between arrays of different (but compatible) shapes without an explicit loop.
113What is broadcasting, and what's the compatibility rule?Intermediate+

Broadcasting is the set of rules NumPy uses to let you perform elementwise operations between arrays of DIFFERENT shapes, without you having to manually write a loop or manually resize one of them to match — conceptually, the smaller array is 'stretched' (with no actual memory copying happening) across the larger one so their shapes line up for the operation.

The compatibility rule, applied by comparing shapes starting from the TRAILING (rightmost) dimension and working backward: two dimensions are compatible for broadcasting if they are exactly equal, OR if one of them is exactly 1 (in which case that size-1 dimension gets conceptually repeated to match the other). If neither condition holds for some pair of dimensions, NumPy raises a `ValueError` about incompatible shapes rather than guessing — this rule is exactly why a `(3,3)` array can be added to a `(3,)` array (the trailing dimension 3 matches), but a `(3,3)` array cannot be directly added to a `(4,)` array.

a = np.ones((3, 3))
b = np.array([10, 20, 30])   # shape (3,)
print(a + b)                  # b is broadcast across every row
Comparison · NumPy Array vs Python List
Python listNumPy ndarray
Element typesMixed, arbitrary objectsSingle fixed dtype
Memory layoutArray of pointers to objectsContiguous raw values
Elementwise math (a + b)Not supported (or wrong result)Vectorized, no explicit loop
Typical speed at scaleBaselineOften 10 to 100x faster
ResizingCheap, flexibleExpensive — fixed size per array
114Why are NumPy arrays so much faster than Python lists for numeric work?Intermediate+

A Python list stores references (pointers) to arbitrary, independently-allocated Python objects scattered around memory — every single arithmetic operation on a list element has to go through Python's normal dynamic-dispatch machinery: check the object's actual type at runtime, look up the right method for that type, then perform the operation — real, measurable overhead repeated separately for every single element, every single time.

A NumPy array stores raw values of one KNOWN, FIXED type packed contiguously in memory, with no per-element type checking needed at all — a vectorized operation like `arr * 2` runs as a single, tight, compiled C loop operating directly on that contiguous memory block, completely bypassing Python's per-element interpretation overhead. This combination — no repeated type dispatch, contiguous memory that's friendly to CPU caching, and compiled-C execution instead of interpreted bytecode — is what typically produces the well-known 10x to 100x speedups NumPy achieves over equivalent pure-Python loops for numeric work.

import numpy as np, time
a = np.arange(1_000_000)
b = list(range(1_000_000))
# %timeit a * 2                       # vectorized — fast
# %timeit [x * 2 for x in b]          # Python-level loop — much slower
115What is dtype, and why does it matter for memory?Beginner+

Every `ndarray` has exactly one fixed `dtype` (data type) shared by ALL its elements — common examples include `int64` (64-bit integers), `float32`/`float64` (32-bit or 64-bit floating point), and `bool`. This single-fixed-type constraint is precisely what allows NumPy's compact, contiguous memory layout and fast vectorized operations in the first place — a mix of types, like a plain Python list allows, would prevent that.

The dtype directly determines how many bytes EACH element occupies, which multiplies out significantly across a large array — choosing `float32` instead of the default `float64` roughly HALVES the memory footprint of a large numeric array (4 bytes per element instead of 8), which matters a lot for very large datasets or memory-constrained environments, though it comes at the cost of reduced numeric precision, which is a real trade-off to be conscious of rather than something to do by default everywhere.

arr = np.array([1, 2, 3], dtype=np.float32)
print(arr.dtype, arr.nbytes)
116What does the axis parameter control in reductions like sum() or mean()?Intermediate+

For a multi-dimensional array, a reduction operation like `.sum()` or `.mean()` needs to know WHICH dimension to collapse — this is exactly what the `axis` parameter specifies, and getting the direction right (which axis means 'down the rows' vs 'across the columns') is a genuinely common source of confusion. `axis=0` collapses along the FIRST dimension (typically rows in a 2D array), producing one result PER COLUMN — think of it as 'squashing the rows together, column by column'.

`axis=1` collapses along the SECOND dimension (typically columns), producing one result PER ROW — 'squashing the columns together, row by row'. Calling the reduction with no `axis` argument at all collapses EVERY dimension down to a single scalar value, summarizing the entire array as one number rather than a per-row or per-column result — the specific axis you need depends entirely on which direction of the data you're trying to summarize.

grid = np.array([[1,2,3],[4,5,6]])
print(grid.sum())        # 21 — whole array
print(grid.sum(axis=0))  # [5 7 9] — per column
print(grid.sum(axis=1))  # [6 15] — per row
117What is a ufunc?Intermediate+

A universal function ('ufunc') is a NumPy function that operates ELEMENTWISE on arrays — applying the same operation independently to every corresponding element — while also supporting broadcasting for arrays of compatible-but-different shapes, all implemented internally as fast, compiled C loops rather than Python-level iteration. `np.add`, `np.sqrt`, `np.exp`, `np.sin`, and dozens of others fall into this category.

A detail that often surprises people the first time they learn it: the ordinary Python operators you use every day on NumPy arrays — `+`, `-`, `*`, `/` — are THEMSELVES implemented as ufuncs under the hood (`a + b` literally dispatches to `np.add(a, b)`) — so 'writing normal-looking arithmetic on arrays' and 'calling a ufunc explicitly' aren't actually two different things, they're the exact same underlying mechanism, just with different, more or less convenient syntax for invoking it.

print(np.sqrt(np.array([4, 9, 16])))   # [2. 3. 4.]
118How do you stack or split arrays?Intermediate+

`np.concatenate([a, b], axis=...)` joins arrays along an EXISTING axis (the arrays must already agree in every OTHER dimension) — `np.vstack` and `np.hstack` are more convenient, more readable shortcuts for the extremely common cases of stacking arrays as new ROWS (vertically) or as new COLUMNS (horizontally) respectively, without needing to remember exactly which axis number corresponds to which direction.

`np.split(array, n)` (or `np.array_split`, which tolerates unequal divisions when the array doesn't split evenly) reverses this process, dividing one array back into multiple smaller pieces along a specified axis — useful for tasks like partitioning a dataset into batches or folds for cross-validation.

a, b = np.array([1,2]), np.array([3,4])
print(np.vstack([a, b]))     # stack as rows
print(np.hstack([a, b]))     # [1 2 3 4]
119How do you use np.where() for conditional selection?Intermediate+

`np.where(condition, value_if_true, value_if_false)` is the fully VECTORIZED equivalent of Python's ternary conditional expression (`x if condition else y`), applied elementwise across an ENTIRE array all at once — rather than writing an explicit Python-level loop with an `if`/`else` inside it checking each element individually, `np.where` evaluates the condition array and both value arrays together in one fast compiled operation.

A very common real-world use is clamping or cleaning numeric data — for instance, replacing every negative value in an array with 0 while leaving positive values untouched — expressed as one concise line instead of a manual loop, and running dramatically faster than the equivalent pure-Python loop version would for large arrays.

arr = np.array([1, -2, 3, -4])
print(np.where(arr > 0, arr, 0))   # [1 0 3 0] — clip negatives to 0
120How do you generate reproducible random numbers with NumPy?Intermediate+

The modern, recommended NumPy random API is centered around explicit `Generator` OBJECTS, created with `np.random.default_rng(seed)` — passing the same seed always produces the exact same sequence of 'random' numbers, which is essential for REPRODUCIBLE experiments, debugging, and tests, since you often need the exact same 'random' data across multiple runs to compare results fairly.

This modern API is preferred over the older, legacy global-state approach (`np.random.seed()` followed by bare functions like `np.random.rand()`) specifically because each `Generator` instance is independent and self-contained — using the older global-seed approach across multiple parts of a program (or across parallel processes) can lead to subtle, hard-to-debug interactions where one part of the code accidentally affects another part's 'random' sequence by consuming from the same shared global state.

rng = np.random.default_rng(42)
print(rng.integers(0, 10, size=5))
print(rng.random(3))
121How do you sort a 2D array by one specific column?Advanced+

NumPy doesn't have a direct 'sort this 2D array by column N' method the way you might expect — instead, the idiomatic approach is a two-step process: first, `np.argsort()` applied to just the target COLUMN returns the array of ROW INDICES that WOULD sort that column into ascending order (not the sorted values themselves, but the order they'd need to appear in).

Then, indexing the WHOLE original 2D array with that array of row indices reorders every full row consistently according to that same order — this pattern (compute the sort order from one piece of data, then apply that same order to reorder something else, possibly the full related structure) shows up constantly in data manipulation and is worth having memorized precisely, since it generalizes to sorting one array 'by' the values in a related but separate array too.

arr = np.array([[8,3,2],[3,6,5],[6,1,4]])
order = np.argsort(arr[:, 1])   # sort by column index 1
print(arr[order])
122How do you do basic linear algebra — dot products and matrix multiplication?Intermediate+

The `@` operator (added in Python 3.5 specifically to support this use case cleanly) performs proper MATRIX multiplication between two NumPy arrays, following standard linear-algebra rules — this is meaningfully DIFFERENT from the plain `*` operator, which performs ELEMENTWISE multiplication instead (multiplying corresponding positions together), a distinction that trips up almost everyone the first time they work with NumPy matrices. `np.matmul` is the explicit function-call equivalent of `@`.

`np.dot()` is more general-purpose and handles both plain vector dot products AND matrix multiplication depending on the shapes of its inputs, which is convenient but can occasionally be less explicit about intent than `@` for pure matrix-multiplication code. Beyond basic multiplication, the `np.linalg` submodule covers the rest of standard linear algebra — matrix inverses, determinants, eigenvalues/eigenvectors, and solving linear systems — which underpins a large amount of the mathematics behind machine learning algorithms built on top of NumPy.

A = np.array([[1,2],[3,4]])
B = np.array([[5,6],[7,8]])
print(A @ B)                # matrix multiply
print(np.linalg.inv(A))     # matrix inverse
SECTION 11 · DATA SCIENCE

Pandas

DataFrames, indexing, joining, and the day-to-day data-wrangling toolkit.

16 questions
123Series vs DataFrame — what's the relationship?Beginner+

A `Series` is pandas' 1-dimensional labeled array — conceptually a single column of data, where each value additionally has an associated INDEX label (not just a position, like a plain list) that travels along with it through operations. A `DataFrame` is the 2-dimensional counterpart — a full table, and it's genuinely useful to think of it as a dictionary of Series objects that all share the exact same row index, one Series per column.

This relationship isn't just a mental model — it's directly observable in code: selecting a single column out of a DataFrame with `df["col"]` literally returns a `Series` object, confirming that a DataFrame really is composed of Series underneath. Understanding this relationship explains why so many Series methods (`.mean()`, `.unique()`, string accessor methods) feel consistent with what you'd do on a single DataFrame column — because a single DataFrame column IS a Series.

import pandas as pd
s = pd.Series([2.0, 3.1, 2.2], index=["a","b","c"])
df = pd.DataFrame({"score": s, "grade": ["B","A","B"]})
print(type(df["score"]))   # <class 'pandas.core.series.Series'>
124How do you load a CSV and get a quick overview of it?Beginner+

`pd.read_csv(path)` is the standard entry point for loading tabular data from a CSV file into a DataFrame — it accepts a wide range of optional parameters for handling real-world messiness (custom delimiters, specific columns to parse as dates, which row to treat as the header, how to handle missing-value markers), but the bare defaults handle the common, clean case with no extra configuration needed.

Once loaded, three methods together form the standard 'first look' at any new dataset: `.head()` shows the first 5 rows so you can visually sanity-check the data looks like what you expected. `.info()` shows the column names, their inferred data types, and how many NON-NULL values each column has — immediately revealing which columns have missing data. `.describe()` computes summary statistics (count, mean, standard deviation, min, quartiles, max) for every NUMERIC column, giving you an immediate sense of each column's range and distribution before doing any deeper analysis.

df = pd.read_csv("sales.csv")
df.head()      # first 5 rows
df.info()      # dtypes + non-null counts
df.describe()  # count/mean/std/min/max for numeric columns
125.loc vs .iloc — when do you use each?Intermediate+

`.loc` selects rows and columns by their LABEL — the actual index/column names, whatever they happen to be (which could be strings, dates, or even non-sequential custom integers) — and a genuinely important detail: when used for slicing, `.loc`'s END label is INCLUSIVE, meaning `df.loc["a":"b"]` includes BOTH row 'a' and row 'b' in the result, which is different from how Python slicing normally behaves everywhere else in the language.

`.iloc` selects purely by INTEGER POSITION, exactly like indexing into a plain Python list — position 0 is always the first row/column regardless of what the actual index labels are, and its slicing follows Python's normal convention where the END position is EXCLUDED. Mixing these two up — especially on a DataFrame whose index ISN'T simply 0, 1, 2, ... (like a string index, or a date index, or a re-ordered/filtered DataFrame whose original integer positions no longer match its current row order) — is a classic, genuinely common source of off-by-one row-selection bugs.

df = pd.DataFrame({"x":[10,20,30]}, index=["a","b","c"])
print(df.loc["a":"b"])   # rows a AND b — label slicing is inclusive
print(df.iloc[0:2])      # rows 0 and 1 only — position slicing excludes the end
126How does boolean-mask filtering work in pandas?Intermediate+

Writing a comparison directly on a DataFrame column (`df["age"] > 18`) doesn't immediately filter anything — it returns a `Series` of `True`/`False` values, one for each row, indicating whether THAT row satisfies the condition. Indexing the ORIGINAL DataFrame with that boolean Series (`df[df["age"] > 18]`) then keeps only the rows where the mask is `True` — this two-step 'compute a boolean mask, then use it to index' pattern is the fundamental building block of essentially all row filtering in pandas.

Combining multiple conditions requires the bitwise operators `&` (and) and `|` (or) — NOT Python's regular `and`/`or` keywords, which don't work correctly on Series because they're designed for single boolean values, not element-by-element array comparison. Each individual condition also needs to be wrapped in its own PARENTHESES when combined this way, because Python's operator precedence would otherwise evaluate the `&`/`|` before the comparison operators, producing an error or a wrong result.

df[(df["age"] > 18) & (df["country"] == "IN")]
127What is groupby, and what's the "split-apply-combine" mental model?Intermediate+

`groupby()` implements the exact same conceptual pattern SQL's `GROUP BY` does, commonly described in pandas documentation as 'split-apply-combine': SPLIT the DataFrame into separate groups based on the values of one or more key columns, APPLY some aggregation function INDEPENDENTLY to each group (a mean, a count, a custom function), then COMBINE all those per-group results back together into one final result.

`df.groupby("department")["salary"].mean()` demonstrates the simple case directly — split rows by department, apply `.mean()` to the salary column within each group, combine into a Series indexed by department. The more general `.agg()` form lets you apply MULTIPLE different named aggregations across potentially different columns in one call, producing a richer result table (like both an average AND a count per group at once) rather than being limited to one single aggregation per call.

df.groupby("department")["salary"].mean()
df.groupby("department").agg(avg_salary=("salary","mean"), count=("salary","size"))
Comparison · merge() vs join() vs concat()
FunctionCombines onDirectionSQL equivalent
pd.merge(a, b, on=...)Shared column value(s)Side-by-sideJOIN
a.join(b)The row indexSide-by-sideJOIN ... ON index
pd.concat([a, b])Nothing — just stacksRows (default) or columns (axis=1)UNION ALL
128merge vs join vs concat — walk through when you'd reach for eachIntermediate+

`pd.merge(a, b, on="key_column")` is pandas' direct equivalent of a SQL JOIN — it combines two DataFrames SIDE BY SIDE based on matching VALUES in one or more shared columns, and supports the familiar join types (`how="inner"`, `"left"`, `"right"`, `"outer"`) controlling what happens to rows that don't find a match on the other side, exactly the same way SQL join types work.

`.join()` performs a similar side-by-side combination, but matches rows based on the DataFrame's INDEX rather than an explicit column — a natural fit when you've already set a meaningful column as the index on both DataFrames beforehand. `pd.concat([a, b])` is fundamentally different from the other two — it doesn't try to MATCH anything between the two DataFrames at all; by default it simply STACKS them, one on top of the other as additional rows (like SQL's `UNION ALL`), or side by side as additional columns if you pass `axis=1` — the right tool specifically when you're combining datasets with the SAME shape/columns, not correlating them by a shared key.

orders = pd.DataFrame({"user_id":[1,2], "total":[50,30]})
users  = pd.DataFrame({"user_id":[1,2], "name":["A","B"]})
pd.merge(orders, users, on="user_id", how="left")
129How do you find and handle missing values?Intermediate+

`.isnull()` (equivalently `.isna()`) returns a DataFrame or Series of booleans marking exactly where values are missing (`NaN`); chaining `.sum()` onto it gives you a per-column COUNT of missing values, which is usually the first thing you check when exploring a new, possibly messy dataset.

There are two fundamentally different strategies once you've found missing values, and choosing between them is a real analytical decision, not just a mechanical one: `.fillna(value)` FILLS the gaps — with a constant, with a computed statistic like the column mean, or using a forward-fill/backward-fill strategy that carries the nearest known value forward or backward. `.dropna()` REMOVES rows (or columns) that have missing data outright — appropriate when a missing value in a critical field genuinely makes that row unusable, but risky if applied broadly, since it can silently discard a large fraction of your dataset if missingness is common.

df.isnull().sum()
df["age"] = df["age"].fillna(df["age"].mean())
df.dropna(subset=["email"])   # drop rows missing a required field
130apply() vs map() vs applymap() — what's the scope of each?Intermediate+

These three methods all apply a function to data, but they differ in exactly WHAT they apply it to. `Series.map(func)` transforms every individual element of ONE Series, one value at a time — the natural fit for something like converting a numeric score column into a letter-grade column via a lookup function.

`DataFrame.apply(func, axis=...)` operates at a COARSER granularity — it runs your function along an entire AXIS at once, passing either a whole ROW (with `axis=1`) or a whole COLUMN (with `axis=0`, the default) as the argument each time, which is what you need when a computation genuinely depends on MULTIPLE columns together (like summing several specific columns per row). `DataFrame.applymap()` (being renamed to a unified `.map()` on newer pandas versions) is the odd one out in terms of scope — it applies a function to literally EVERY individual cell across the whole DataFrame independently, regardless of row or column, useful for something like formatting every cell's display without regard to which column it's in.

df["grade"] = df["score"].map(lambda s: "A" if s >= 90 else "B")
df["row_total"] = df[["q1","q2","q3"]].apply(sum, axis=1)
131How do you build a pivot table?Intermediate+

`pivot_table()` reshapes 'long' data (where each row is one observation, like one sale) into a summarized GRID — you choose which column's unique values become the new ROW labels (`index=`), which column's unique values become the new COLUMN labels (`columns=`), and which column's values get AGGREGATED (summed, averaged, etc., via `aggfunc=`) into each cell of that grid.

This is directly analogous to building a pivot table in Excel, and is often the fastest way to get an immediately readable summary of a dataset with two natural categorical dimensions — like revenue broken down by region (rows) and quarter (columns) simultaneously, computed and laid out in one single call rather than manually building it up through several separate groupby operations.

df.pivot_table(index="region", columns="quarter", values="revenue", aggfunc="sum")
132How do you work with dates and time series?Intermediate+

`pd.to_datetime(column)` parses a column of date/time STRINGS (or mixed formats) into pandas' proper `datetime64` dtype — once converted, the column unlocks the `.dt` accessor (giving you `.dt.year`, `.dt.month`, `.dt.day_name()`, etc.) and enables genuinely date-AWARE slicing and comparison, which plain string dates cannot support correctly (string-sorting dates doesn't always match chronological order, especially with inconsistent formats).

`.resample(rule)`, called on a Series or DataFrame with a DATETIME INDEX, aggregates data into COARSER time buckets — resampling daily sales data with rule `"M"` (month-end) and `.sum()` collapses each month's individual daily values down into one monthly total, directly analogous to `groupby()` but specifically built around time-based bucketing rather than arbitrary categorical grouping.

df["date"] = pd.to_datetime(df["date"])
df.set_index("date")["sales"].resample("M").sum()
133How do you add, rename, or drop a column?Beginner+

Creating a new column is as simple as assigning to a column label that doesn't exist yet, exactly like adding a new key to a dictionary (`df["total"] = df["price"] * df["qty"]`) — the assignment can be a computed expression involving other existing columns, computed elementwise across every row automatically. `.rename(columns={"old": "new"})` renames one or more columns using a mapping dictionary.

`.drop(columns=[...])` removes one or more columns entirely. An important, commonly-missed detail shared by most pandas methods, including rename and drop: by default they return a NEW DataFrame rather than modifying the original in place — you either need to reassign the result back to a variable (`df = df.rename(...)`) or explicitly pass `inplace=True` if you want the original DataFrame object itself to be modified directly.

df["total"] = df["price"] * df["qty"]
df = df.rename(columns={"qty": "quantity"})
df = df.drop(columns=["unused_col"])
134How do you sort a DataFrame, and how does value_counts() help with quick EDA?Beginner+

`.sort_values(by="column")` sorts the entire DataFrame's rows according to the values in one (or, passing a list, MULTIPLE) specified column — `ascending=False` reverses the direction, and sorting by multiple columns lets you specify a primary sort key plus tie-breaking secondary keys, exactly like a multi-column SQL `ORDER BY`.

`Series.value_counts()`, called on a single categorical column, is one of the fastest and most commonly reached-for tools during exploratory data analysis (EDA) — it counts how many times each unique value appears and returns them SORTED from most to least frequent by default, letting you instantly see a column's frequency distribution — which categories dominate the dataset, which are rare — in one line, without manually writing a groupby-and-count yourself.

df.sort_values(by="revenue", ascending=False)
df["country"].value_counts()
135How do you find and remove duplicate rows?Intermediate+

`.duplicated()` returns a boolean Series flagging rows that are duplicates of an EARLIER row already seen in the DataFrame (the first occurrence of any repeated value is marked `False`, not a duplicate; only later repeats are flagged `True`) — passing `subset=[...]` restricts the duplicate-check to only specific columns rather than requiring every single column to match exactly.

`.drop_duplicates()` performs the actual removal based on that same logic, and by default KEEPS the first occurrence of each duplicate group while dropping the rest — `keep="last"` flips that to keep the last occurrence instead, and `keep=False` drops EVERY row that has any duplicate at all (including the first occurrence), which is useful when you specifically want to isolate and inspect only the genuinely unique rows.

df[df.duplicated(subset=["email"])]
df = df.drop_duplicates(subset=["email"], keep="first")
136How do you clean text columns efficiently in pandas?Intermediate+

The `.str` accessor, available on any Series holding string data, gives you access to VECTORIZED versions of Python's normal string methods — `.str.strip()`, `.str.upper()`, `.str.split()`, and dozens more — applied across the ENTIRE column at once in optimized pandas code, rather than requiring a manual Python-level `.apply()` loop calling the plain string method on each individual value one at a time.

Beyond the pure speed advantage, `.str` methods handle missing values GRACEFULLY by design — applying a `.str` method to a row containing `NaN` simply propagates `NaN` through rather than raising an error the way calling a plain string method directly on a missing (non-string) value would. Chaining multiple `.str` operations together (as shown below) is also a very natural, readable way to express a multi-step text-cleaning pipeline in one line.

df["name"] = df["name"].str.strip().str.title()
df["domain"] = df["email"].str.split("@").str[1]
137How do you cut down a large DataFrame's memory usage?Advanced+

Two techniques handle most of the realistic memory savings available for a typical DataFrame. First, DOWNCASTING numeric columns to a smaller dtype where the actual data's range safely allows it — converting `int64` to `int32` (or smaller) or `float64` to `float32` roughly halves (or better) the memory used by that column, as long as the values genuinely fit within the smaller type's range without losing meaningful precision or overflowing.

Second, and often the bigger win for real-world datasets, converting LOW-CARDINALITY text columns (columns with relatively few distinct repeated values, like a status field or a country name) to the `category` dtype — instead of storing the full string repeated in every single row (which is expensive when the same handful of strings repeat thousands of times), a category column stores each UNIQUE value only once internally, plus a small integer 'code' per row referencing which category it belongs to, which can be a dramatic memory reduction for exactly this common shape of data. `df.memory_usage(deep=True).sum()` gives you an accurate before/after total to actually verify these optimizations helped.

df["status"] = df["status"].astype("category")
df["qty"] = pd.to_numeric(df["qty"], downcast="integer")
df.memory_usage(deep=True).sum()
138Beyond CSV — how do you read from Excel or a SQL database?Beginner+

`pd.read_excel(path, sheet_name=...)` reads spreadsheet data directly into a DataFrame — it requires an additional engine library installed (commonly `openpyxl` for modern `.xlsx` files) since Excel's binary/XML format is considerably more complex to parse than a plain CSV, and unlike a CSV file, an Excel workbook can contain MULTIPLE sheets, which is exactly what the `sheet_name` argument lets you target.

`pd.read_sql(query, con=connection)` runs an actual SQL query against a live database connection (from any DB-API-compatible driver, or a SQLAlchemy engine — covered in the next section) and loads the result set DIRECTLY into a DataFrame, skipping the intermediate step of exporting to a file first — this is the standard way pandas integrates with a real production database for analysis, letting you push filtering and joining logic down to the database itself via SQL before the (potentially much smaller) result ever reaches pandas.

df = pd.read_excel("report.xlsx", sheet_name="Q1")
df = pd.read_sql("SELECT * FROM orders", con=engine)
SECTION 12 · DATA SCIENCE

Data Visualization: Matplotlib & Seaborn

Turning DataFrames into readable charts — the two-library stack behind almost every Python plot.

12 questions
139Figure vs Axes — what's the difference in Matplotlib's object model?Beginner+

A `Figure` is the entire top-level canvas or window that everything gets drawn onto — think of it as the whole page or the whole exported image file. An `Axes` (despite the confusingly plural-sounding name, one `Axes` object is a SINGLE individual plot) is one specific plotting area living inside that figure — a figure can contain just one Axes, or a whole grid of several Axes arranged as subplots.

`fig, ax = plt.subplots()` is the standard, recommended way to get both objects explicitly at once — this OBJECT-ORIENTED style (calling methods directly on the specific `ax` object, like `ax.plot(...)`) is strongly preferred over the older, more implicit `plt.plot(...)` STATEFUL style, which secretly operates on 'whatever the current active axes happens to be' — that implicit style becomes genuinely confusing and error-prone the moment you have more than one subplot, since it's not always obvious which axes is 'current' at any given point in the code.

import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(6,4))
ax.plot([1,2,3], [4,1,5])
ax.set_title("Example")
plt.show()
140How do you make a basic line, bar, and scatter plot?Beginner+

Once you have an `Axes` object (from `fig, ax = plt.subplots()`), each fundamental chart type is just a differently-named METHOD called on that same object, all sharing a broadly similar `x, y` calling convention — this consistency means switching your visualization from a line chart to a bar chart or scatter plot is frequently as simple as changing the method NAME, without needing to restructure the rest of your plotting code.

`ax.plot(x, y)` draws a line connecting the data points in order — the natural choice for continuous or sequential data like a time series. `ax.bar(categories, values)` draws vertical bars — the natural choice for comparing discrete categories against each other. `ax.scatter(x, y)` draws individual unconnected points — the natural choice for examining the RELATIONSHIP between two numeric variables without implying any particular order or connection between the points.

fig, ax = plt.subplots()
ax.plot(x, y)          # line
ax.bar(categories, values)   # bar
ax.scatter(x, y)       # scatter
141How do you create a grid of subplots?Intermediate+

`plt.subplots(nrows, ncols)` returns not just one but TWO things: the overall `Figure` object, and — when more than one subplot is requested — a whole ARRAY of individual `Axes` objects arranged to match the grid you asked for, which you then index into individually (like `axes[0, 0]` for the top-left subplot in a 2D grid) to draw onto each specific subplot separately.

A very common finishing touch after building a multi-subplot figure is `fig.tight_layout()`, which automatically adjusts the spacing between subplots to prevent titles, axis labels, and tick labels from visually overlapping each other — without it, a densely packed grid of subplots frequently ends up with labels from adjacent subplots colliding illegibly, especially with longer text or rotated tick labels.

fig, axes = plt.subplots(2, 2, figsize=(10, 8))
axes[0, 0].plot(x, y)
axes[0, 1].bar(cats, vals)
fig.tight_layout()   # prevents overlapping labels
142How do you add titles, axis labels, and a legend?Beginner+

On the object-oriented `Axes` API, `.set_title("...")` sets the plot's title, and `.set_xlabel("...")`/`.set_ylabel("...")` label the horizontal and vertical axes respectively — these small annotations are what turn a chart that only makes sense to the person who made it into one that's actually readable and interpretable by someone else seeing it for the first time.

For a legend to have anything meaningful to display, you need to pass a `label="..."` argument to EACH individual plotted series when you draw it (like `ax.plot(x, y1, label="Revenue")`) — the legend itself is then generated with a separate `.legend()` call, which automatically collects and displays every label that was attached to something drawn on that Axes, matched up with a small color/style swatch so a reader can tell which line or series corresponds to which label.

ax.plot(x, y1, label="Revenue")
ax.plot(x, y2, label="Cost")
ax.set_xlabel("Month"); ax.set_ylabel("USD"); ax.set_title("2026 P&L")
ax.legend()
143How do you save a plot to a file instead of just displaying it?Beginner+

`fig.savefig(path, dpi=..., bbox_inches="tight")` writes the figure out to an actual image or document file on disk — Matplotlib automatically infers the output FORMAT from the file extension you provide (`.png` for a raster image, `.svg` for scalable vector graphics, `.pdf` for a document-ready vector format), so you don't need to specify the format separately from the filename in most cases.

`dpi` (dots per inch) controls the resolution of raster formats like PNG — a higher value produces a sharper, larger image file, important when a chart needs to be sharp at a large display size or in print. `bbox_inches="tight"` trims excess whitespace around the edges of the figure automatically. One easy-to-miss ordering detail: call `savefig()` BEFORE `plt.show()` in a plain script — some Matplotlib backends clear or close the figure's internal state once it's been displayed, which can result in a blank or incomplete saved file if you save AFTER showing instead of before.

fig.savefig("chart.png", dpi=200, bbox_inches="tight")
Comparison · Matplotlib vs Seaborn
MatplotlibSeaborn
LevelLow-level — you build up each elementHigh-level — built on top of Matplotlib
InputRaw x/y arraysWhole DataFrames + column names
Statistical plotsManual (compute stats yourself)Built in: regression lines, distributions, CIs
Default aestheticsPlain, needs stylingPolished out of the box
Fine-grained controlTotal — every pixel is reachableGood, but drop to Matplotlib's Axes for the rest
144Why use Seaborn on top of Matplotlib instead of just Matplotlib?Intermediate+

Seaborn is built directly on top of Matplotlib (it doesn't replace it) but its API is designed around working NATIVELY with pandas DataFrames — most Seaborn functions take a `data=df` argument plus simple COLUMN NAME strings for `x=`/`y=`/`hue=`, rather than requiring you to first manually extract raw arrays from your DataFrame the way plain Matplotlib usually expects, which removes a real amount of repetitive boilerplate for DataFrame-centric workflows.

Beyond convenience, Seaborn also bakes in genuinely STATISTICAL plot types that would take considerable manual work to reproduce in plain Matplotlib — automatic regression line fitting with confidence bands, kernel density estimates for distribution shape, and built-in confidence-interval error bars on bar/point plots, all computed and drawn correctly with a single function call. Critically, Seaborn functions still RETURN ordinary Matplotlib `Axes` objects underneath, so nothing about switching to Seaborn locks you out of also using plain Matplotlib calls to further customize the same plot afterward.

import seaborn as sns
sns.scatterplot(data=df, x="height", y="weight", hue="gender")
145What are the go-to Seaborn plots for exploring a new dataset?Intermediate+

For understanding the DISTRIBUTION of a single numeric column, `histplot` (a histogram, optionally with a smoothed `kde=True` overlay curve) is the standard first look — it immediately shows you the shape, spread, and any obvious skew or multiple peaks in that one variable. For COMPARING a numeric column's distribution ACROSS different categories, `boxplot` (showing median, quartiles, and outliers per category) or `violinplot` (showing the full estimated distribution shape per category, not just summary statistics) are the standard tools.

For understanding RELATIONSHIPS between variables, `heatmap` applied to a correlation matrix visually surfaces which pairs of numeric columns move together (and how strongly), color-coded for immediate visual scanning rather than reading a dense table of numbers. `pairplot` goes a step further by automatically plotting EVERY numeric column against every OTHER numeric column in one big grid, giving you a comprehensive first-pass overview of all pairwise relationships in a dataset in a single function call — genuinely useful as an early exploratory step, though it can get visually overwhelming and slow with a large number of columns.

sns.histplot(data=df, x="age", bins=20, kde=True)
sns.boxplot(data=df, x="department", y="salary")
sns.pairplot(df.select_dtypes("number"))
146How do you set a consistent theme across all your plots?Intermediate+

`sns.set_theme(style=..., palette=..., font_scale=...)`, called ONCE at the top of a notebook or script, sets a coordinated visual style — background style (grid lines, background color), color palette, and font sizing — that then applies automatically to EVERY subsequent plot drawn for the rest of that session, whether it's created through a Seaborn function or through plain Matplotlib calls.

This is far more maintainable than manually restyling each individual plot separately, and it ensures visual CONSISTENCY across an entire notebook or report — every chart shares the same color scheme, gridline style, and font scale, giving a polished, cohesive look to a full set of visualizations without repeating styling code for each one.

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
147How do you visualize a correlation matrix?Intermediate+

`.corr()`, called on the numeric columns of a DataFrame, computes the pairwise PEARSON correlation coefficient between every pair of columns, returning a square matrix (a DataFrame itself) where each cell shows how strongly two variables move together, ranging from -1 (perfectly inversely related) through 0 (no linear relationship) to +1 (perfectly directly related).

`sns.heatmap()` takes that correlation matrix and renders it as a color-coded grid, making strong positive and negative relationships visually jump out immediately rather than requiring you to scan a table of raw numbers. `annot=True` additionally prints the actual numeric coefficient directly inside each cell (combining the visual color-scan benefit with the numeric precision of the raw table), and `center=0` correctly anchors the color scale so that 0 correlation sits at a neutral midpoint color, with positive and negative correlations diverging symmetrically in opposite color directions from it.

corr = df.select_dtypes("number").corr()
sns.heatmap(corr, annot=True, cmap="coolwarm", center=0)
148Categorical comparisons — bar plot vs count plot, what's the difference?Intermediate+

`sns.countplot(data=df, x="department")` answers a purely CATEGORICAL question — 'how many rows fall into each category' — it counts occurrences of each unique value in the specified column FOR YOU automatically, essentially a built-in, automatically-plotted version of calling `.value_counts()` yourself and then charting the result.

`sns.barplot(data=df, x="department", y="salary")` answers a different question entirely — it plots an AGGREGATED numeric value (the mean, by default) of the `y` column, calculated SEPARATELY within each category of the `x` column — so it's showing 'the average salary per department', not 'how many rows are in each department'. Barplot also automatically adds an error bar representing the uncertainty around that aggregated estimate (based on the underlying data's variability), which countplot has no equivalent concept of, since it's just a raw count with nothing to estimate uncertainty around.

sns.countplot(data=df, x="department")                 # how many per dept
sns.barplot(data=df, x="department", y="salary")        # mean salary per dept
149How do you combine Seaborn's convenience with Matplotlib's fine control?Advanced+

Because every Seaborn plotting function accepts an optional `ax=` parameter, you can create a plain Matplotlib `Figure`/`Axes` pair YOURSELF first (giving you full control over figure size, subplot layout, and so on), then tell Seaborn to draw its chart directly ONTO that existing `ax` object instead of creating its own new figure — this cleanly combines Seaborn's convenient, statistically-aware plotting functions with Matplotlib's complete, low-level customization capabilities in the same workflow.

After Seaborn has drawn onto that `ax`, it's still just an ordinary Matplotlib `Axes` object afterward — you retain full access to every plain Matplotlib customization method (`.set_title()`, `.tick_params()` for rotating or restyling tick labels, adding annotations, adjusting limits) to polish the chart further, exactly as if you'd built the whole thing in plain Matplotlib from scratch, just without having to hand-compute the statistical elements Seaborn already handled for you.

fig, ax = plt.subplots(figsize=(8,5))
sns.boxplot(data=df, x="dept", y="salary", ax=ax)
ax.set_title("Salary spread by department")
ax.tick_params(axis="x", rotation=30)
150What's the difference between plt.show() blocking behavior in a script vs a notebook?Intermediate+

In a plain standalone Python script run from the command line, `plt.show()` actually BLOCKS further execution of your script — it opens an interactive plot window and pauses your program right there until you manually close that window, at which point execution continues to whatever code comes next.

In a Jupyter notebook using the standard inline/interactive backend, figures typically render automatically at the end of a cell simply because the cell finished executing, WITHOUT needing an explicit `plt.show()` call at all, and there's no actual blocking behavior — the notebook keeps running normally regardless. It's still considered good, portable practice to include `plt.show()` explicitly anyway, since it's harmless in the notebook context and makes the same code work correctly and predictably if it's later copied into or run as a plain script instead.

SECTION 13 · WEB BACKEND

Flask, Django & SQLAlchemy

The Python web-backend stack — routing, ORMs, migrations, and the WSGI/ASGI plumbing underneath.

14 questions
151What does a minimal Flask app look like, and how does routing work?Beginner+

Flask is deliberately a MICRO-framework: out of the box it gives you request routing and basic request/response handling, and leaves essentially everything else — which ORM to use, how to structure your project, which auth system to plug in — entirely up to you to choose and wire together yourself, rather than imposing an opinionated full-stack structure.

`@app.route("/hello/<name>")` is a direct, practical application of the parameterized-decorator pattern from Section 07: the route PATH itself is the decorator's own configuration argument (supplied before Flask even knows which view function will be attached), and the angle-bracket segment (`<name>`) captures that part of the URL as a variable, automatically passed into the decorated view function as a matching keyword argument — visiting `/hello/Sara` calls `hello(name="Sara")` without you needing to manually parse the URL path yourself.

from flask import Flask
app = Flask(__name__)

@app.route("/hello/<name>")
def hello(name):
    return f"Hello, {name}!"

if __name__ == "__main__":
    app.run(debug=True)
152How do you read query params, JSON bodies, and return JSON in Flask?Intermediate+

Inside any Flask view function, the special global `request` object gives you access to everything about the CURRENT incoming HTTP request — `request.args` is a dict-like object holding the URL's query string parameters (`?q=python` becomes `request.args.get("q")`), `request.form` holds traditional HTML form-submitted data, and `request.get_json()` parses the request BODY as JSON when a client sends a JSON payload (typically for an API-style POST request rather than a traditional form submission).

`jsonify(data)` is the standard way to send a JSON RESPONSE back — it doesn't just call `json.dumps()` on your data; it also sets the correct `Content-Type: application/json` response header automatically, which browsers, API clients, and testing tools rely on to correctly interpret the response body as JSON rather than plain text. Returning a tuple like `jsonify(data), 201` from a view function is the idiomatic Flask way to specify a non-default HTTP status code (201 Created, in this case) alongside the response body.

from flask import request, jsonify

@app.route("/search")
def search():
    q = request.args.get("q", "")
    return jsonify({"query": q, "results": []})

@app.route("/users", methods=["POST"])
def create_user():
    data = request.get_json()
    return jsonify(data), 201
Comparison · Flask vs Django
FlaskDjango
PhilosophyMicro-framework, minimal coreBatteries included — full framework
ORMNot built in (commonly pairs with SQLAlchemy)Built-in Django ORM
Admin panelNone built inAuto-generated admin site
Project structureYou decideEnforced app/project convention
Best forAPIs, small services, full controlLarger apps that want structure fast
153Flask vs Django — how do you decide which to reach for?Intermediate+

Flask's minimal core gives you complete freedom over your ORM, authentication system, and overall project structure, without imposing any opinions you'd have to work around or unwind later — this is a genuine advantage for a lean API, a small internal service, or any situation where you want total control and don't want a large framework's conventions dictating your architecture.

Django is deliberately 'batteries included': it ships with its own ORM, an automatically-generated admin panel for managing your data, a built-in authentication system, and versioned schema migrations, all working together out of the box in a consistent, well-integrated way — the trade-off is accepting Django's own conventions and structure in exchange for that speed and integration. The practical decision usually comes down to: choose Flask when you want a lean, fully custom-assembled stack (especially for APIs or microservices); choose Django when you want a fuller-featured application scaffolded quickly and you're comfortable working within its established conventions rather than fighting them.

154What is Django's MTV (Model-Template-View) architecture?Intermediate+

Django organizes an application around three cooperating pieces, using naming that's a deliberate twist on the more universally-known MVC (Model-View-Controller) pattern. The MODEL defines your data's structure and talks to the database through Django's own ORM — a Python class describing fields maps directly to a database table, with Django handling the SQL underneath. The TEMPLATE is responsible for rendering the actual HTML output, combining static markup with dynamic data passed into it.

The VIEW is a Python function (or class) that receives an incoming request, does whatever logic is needed (often querying models), and decides which template to render with which data — this is Django's own naming twist specifically: what MVC traditionally calls the 'Controller' (the piece that coordinates between data and presentation), Django calls the 'View' instead, which is a common point of confusion for developers coming from other frameworks that use 'View' to mean something closer to what Django calls a 'Template'.

# models.py
class Article(models.Model):
    title = models.CharField(max_length=200)
    published = models.DateTimeField(auto_now_add=True)

# views.py
def article_list(request):
    articles = Article.objects.all()
    return render(request, "articles/list.html", {"articles": articles})
155How do Django models and migrations work together?Intermediate+

A Django MODEL class, written in plain Python, is the single source of truth describing your intended database schema — field types, constraints, and relationships are all declared as class attributes, and Django's ORM translates that Python-level description into the appropriate underlying SQL automatically, without you needing to write `CREATE TABLE` statements yourself.

MIGRATIONS are the mechanism that keeps the ACTUAL database schema in sync with your evolving model definitions over time. `python manage.py makemigrations` compares your current models against the last known schema state and generates a new migration FILE describing exactly what changed (a new column, a new table, a changed field type) — this file gets checked into version control alongside your code, just like any other source file. `python manage.py migrate` then actually APPLIES that migration to a real database, executing the necessary SQL — this two-step 'generate the diff as a file, then apply it' workflow keeps schema changes reviewable, versioned, and repeatable across every environment (development, staging, production) rather than manually running ad-hoc SQL changes by hand.

# terminal
python manage.py makemigrations
python manage.py migrate
156SQLAlchemy Core vs SQLAlchemy ORM — what's the difference?Advanced+

SQLAlchemy CORE is a SQL expression toolkit — it lets you build queries using Python objects and method chaining that stay conceptually very close to the actual underlying SQL, and executing a Core query gives you back plain ROWS (tuples of values), similar in spirit to writing raw SQL but with Python-level composability and protection against SQL injection built in.

The SQLAlchemy ORM is built ON TOP of Core, adding a mapping layer between your own Python CLASSES and database tables — instead of getting back plain rows, ORM queries return actual instances of your model classes, complete with attribute access (`user.name`) and navigable RELATIONSHIPS to other mapped objects (`user.orders`), at the cost of a somewhat thicker abstraction layer between your code and the raw SQL actually being executed. Most application code uses the ORM layer for its convenience and object-oriented feel, while Core remains available (and is what the ORM itself is built on) for situations needing more direct, lower-level SQL control.

# ORM style
from sqlalchemy.orm import declarative_base, Session
from sqlalchemy import Column, Integer, String

Base = declarative_base()
class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    name = Column(String)

session = Session(engine)
session.add(User(name="Sara"))
session.commit()
157How does SQLAlchemy model a one-to-many relationship?Advanced+

The database-level side of the relationship is expressed with a `ForeignKey` column placed on the 'MANY' side of the relationship — in an author-writes-many-books relationship, the `books` table gets an `author_id` column referencing the `authors` table's primary key, exactly as it would in raw SQL.

The Python-level, application-facing convenience comes from adding `relationship()` on BOTH mapped classes, with `back_populates` linking the two directions together — this is what lets your application code NAVIGATE the relationship naturally as Python attributes (`author.books` gives you a list of that author's Book objects; `book.author` gives you the corresponding Author object) instead of manually writing and executing SQL JOINs yourself every time you need related data.

class Author(Base):
    __tablename__ = "authors"
    id = Column(Integer, primary_key=True)
    books = relationship("Book", back_populates="author")

class Book(Base):
    __tablename__ = "books"
    id = Column(Integer, primary_key=True)
    author_id = Column(Integer, ForeignKey("authors.id"))
    author = relationship("Author", back_populates="books")
158What are Flask Blueprints, and why use them?Intermediate+

A Blueprint bundles together a related, self-contained GROUP of routes, templates, and static files as one reusable unit — for example, all the routes related to authentication (`/login`, `/logout`, `/register`) can live together in an `auth` blueprint, entirely separate from an `admin` blueprint's routes, keeping related functionality organized together rather than all mixed into one giant flat file.

That blueprint is defined independently of any specific app, and only actually gets attached to a real Flask application via `app.register_blueprint(bp)`, optionally with a URL prefix applied to every route inside it automatically. This is Flask's own lighter-weight, more flexible answer to the problem Django solves with its enforced 'app' structure — it lets a Flask project scale from a single small file up to a larger, modular, multi-feature application, without Flask forcing any specific mandatory directory layout the way Django's app system does.

from flask import Blueprint
bp = Blueprint("auth", __name__, url_prefix="/auth")

@bp.route("/login")
def login(): ...

app.register_blueprint(bp)
159What is middleware, in Flask/Django terms?Intermediate+

Middleware is code that runs AROUND every single request and response passing through the application — not tied to any one specific view/route — commonly used for cross-cutting concerns that genuinely apply to the whole app uniformly: logging every incoming request, checking authentication before a view even runs, or adding CORS headers to every outgoing response.

Django ships with an explicit, ordered MIDDLEWARE chain configured directly in settings, applied by default to every request/response passing through the whole application. Flask expresses the same underlying idea more lightly through `@app.before_request` and `@app.after_request` decorated functions (which run before/after EVERY view function, not just one specific route), or, for lower-level needs, through WSGI middleware wrapping the whole app at the server-interface level — different syntax, but solving exactly the same 'run this around every request' problem.

@app.before_request
def log_request():
    print(request.method, request.path)
160How do you build a JSON REST API in Flask and Django?Intermediate+

In Flask, a REST API is commonly built either with plain view functions manually returning `jsonify(...)` responses (perfectly workable for smaller APIs), or with the **Flask-RESTful** extension, which adds some additional structure specifically for resource-oriented API design on top of Flask's core routing.

In Django, the dominant, closely-integrated equivalent is **Django REST Framework (DRF)** — it adds SERIALIZERS (which convert Django model instances to/from JSON, including validation), VIEWSETS (which bundle together the standard CRUD operations for a resource), and a genuinely useful browsable, interactive API documentation UI, all building directly on top of Django's existing ORM and models rather than requiring you to redefine your data structures separately just for the API layer.

# DRF serializer sketch
class ArticleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Article
        fields = ["id", "title", "published"]
161How do database migrations work outside Django — e.g. with SQLAlchemy?Intermediate+

**Alembic** is SQLAlchemy's dedicated migration tool, playing exactly the same architectural role that Django's built-in `makemigrations`/`migrate` commands play natively inside Django — since plain SQLAlchemy itself has no built-in migration system of its own, Alembic exists specifically to fill that gap for projects using SQLAlchemy outside of Django.

Alembic can auto-generate a migration script by comparing your current SQLAlchemy model definitions against the actual live database schema (similar conceptually to Django's `makemigrations`), or you can write migration scripts by hand for more complex schema changes that automatic detection can't safely infer on its own. `alembic upgrade head` then applies all pending migrations up to the latest ('head') version, keeping the real database schema in sync with your versioned migration history, the same fundamental workflow Django provides natively.

# terminal
alembic revision --autogenerate -m "add users table"
alembic upgrade head
162Session-based auth vs JWT — what's the trade-off?Advanced+

Session-based authentication stores the actual authentication STATE on the SERVER SIDE (in memory, a database, or a cache like Redis), and the client only holds a small, meaningless-on-its-own session ID in a cookie that references that server-side state. This makes REVOKING a session trivially easy and immediate — just delete that session's server-side record — but it requires all your application servers to share access to the SAME session store, which adds infrastructure complexity once you're running multiple server instances behind a load balancer.

JWT-based authentication instead makes the token itself SELF-CONTAINED — the client holds a signed token carrying its own claims (like user ID and expiry), and any server can independently verify that signature without needing to consult any shared, centralized session store at all, which scales very cleanly across many independent server instances. The real trade-off surfaces around revocation: since no server-side record 'remembers' having issued a given JWT, invalidating one specific token before its natural expiry time is genuinely awkward without adding extra infrastructure back in (like a maintained blocklist of revoked token IDs) — which somewhat undermines the pure statelessness benefit JWTs are otherwise prized for.

163WSGI vs ASGI — why does async Python need a different server interface?Advanced+

WSGI (Web Server Gateway Interface) is the long-established, standard interface connecting a Python web application to the web server running it — Flask and classic (pre-async) Django both use it. It's fundamentally SYNCHRONOUS: one incoming request maps to one blocking call into your application code at a time, which works well for traditional request/response web apps but has no native concept of asynchronous operations, WebSockets, or long-lived connections.

ASGI (Asynchronous Server Gateway Interface) was created as WSGI's async-capable successor specifically to support `async def` request handlers, WebSocket connections, and other long-lived, non-request/response communication patterns that WSGI's synchronous, one-request-in-one-response-out model has no way to express. FastAPI is built natively around ASGI from the ground up, and modern Django added ASGI support (alongside continued WSGI support) specifically to enable async views and Django Channels for WebSocket-style functionality — the underlying reason ASGI exists at all is that WSGI's synchronous design genuinely cannot represent these newer communication patterns, no matter how you tried to bolt them on.

164Where should secrets and per-environment config live?Intermediate+

Secrets (API keys, database passwords, signing keys) and any value that legitimately differs between environments (development, staging, production — like a database URL pointing at a different server in each) should NEVER be hard-coded directly into your source code — doing so means the secret ends up committed into version control history, potentially visible to anyone with repository access, and it makes the exact same code behave incorrectly (or dangerously) if accidentally run against the wrong environment's hard-coded values.

The standard approach is reading these values from ENVIRONMENT VARIABLES at runtime, often loaded from a local `.env` file during development (via a library like `python-dotenv`) while the real deployed environments set them through their platform's own secret/config management instead — the exact same application code then behaves correctly across every environment purely based on which environment variables happen to be present, without any code changes required between environments. Reading a genuinely REQUIRED variable with `os.environ["KEY"]` (which raises immediately if missing) rather than `os.getenv("KEY")` (which silently returns `None`) is a deliberate choice to FAIL LOUDLY and immediately at startup if a critical piece of configuration is missing, rather than failing confusingly much later when that missing value is actually used.

import os
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
DATABASE_URL = os.environ["DATABASE_URL"]   # fail loudly if missing
SECTION 14 · CODING ROUND

Coding & DSA Interview Problems

The problems that repeat across almost every Python coding round, with clean, idiomatic solutions.

18 questions
165Two Sum — find two numbers that add up to a targetIntermediate+

The naive approach checks every possible PAIR of numbers with a nested loop, comparing each one against every other — this works, but costs O(n²) time since for every element you're re-scanning the rest of the list to find its complement. The key insight that improves this: for each number, you already know EXACTLY what value you'd need to find alongside it (`target - num`) — the question becomes 'have I already seen that specific value earlier', which a hash set can answer in O(1) average time.

By walking through the list just ONCE and maintaining a running set of values already seen, you check membership in that set BEFORE adding the current number to it — the moment you find a number whose needed complement is already in the set, you've found your pair, having done only a single pass over the data instead of a nested one. This hash-set trick — 'store what I've seen, check what I need' — is one of the most broadly reusable patterns in coding interviews generally, not just for this specific problem.

def two_sum(nums, target):
    seen = set()
    for num in nums:
        needed = target - num
        if needed in seen:
            return needed, num
        seen.add(num)
    return None

print(two_sum([2, 7, 11, 15], 9))   # (2, 7)
166Reverse a string without using reversed() or [::-1]Beginner+

Interviewers frequently ask for the manual version of a task Python can otherwise solve in one built-in call specifically because the shortcut hides whether you actually understand what's happening index by index underneath — being able to build the reversal yourself demonstrates real comfort with basic string/loop mechanics rather than just knowing which built-in to reach for.

The approach here walks through the original string CHARACTER BY CHARACTER in its normal forward order, but PREPENDS each character to the front of an accumulating result string rather than appending to the end — since each new character gets placed before everything accumulated so far, the final result ends up in reverse order relative to the original, without ever needing to know the string's length upfront or index backward through it explicitly.

def reverse_string(text):
    result = ""
    for char in text:
        result = char + result
    return result

print(reverse_string("python"))   # nohtyp
167Check whether a string is a palindromeBeginner+

A palindrome reads identically forwards and backwards — but real-world palindrome checks (like phrases with spaces and mixed case, such as 'Never odd or even') need NORMALIZATION first: converting everything to a consistent case and stripping out characters (like spaces) that shouldn't affect whether the underlying sequence of meaningful characters is symmetric.

Once the text is cleaned (lowercased, spaces removed), the actual palindrome check itself becomes trivially simple: compare the cleaned string directly against its own REVERSED version (`cleaned[::-1]`) — if they're identical, the original satisfies the palindrome property once you ignore case and whitespace. Interviewers sometimes ask you to extend this to ALSO ignore punctuation, which just means expanding the cleaning step (e.g., keeping only alphanumeric characters) before doing the same reverse-and-compare check.

def is_palindrome(text):
    cleaned = text.lower().replace(" ", "")
    return cleaned == cleaned[::-1]

print(is_palindrome("Never odd or even"))   # True
168Compute factorial iterativelyBeginner+

A recursive factorial implementation is often the FIRST thing people reach for since the mathematical definition (`n! = n * (n-1)!`) maps almost directly onto recursive code — but as covered in Section 04, Python has both a real recursion depth limit and no tail-call optimization, so a purely recursive factorial can hit `RecursionError` for sufficiently large `n`, which an iterative version avoids entirely.

The iterative version simply walks a running product through every integer from 2 up to (and including) `n`, multiplying it into an accumulator variable as it goes — no function call stack growth at all, regardless of how large `n` is, just a simple loop. In real production code, you'd typically reach for the already-optimized `math.factorial()` from the standard library rather than reimplementing this yourself, but the manual version is exactly what interviewers want to see you construct to check your understanding of loops and accumulation.

def factorial(n):
    result = 1
    for value in range(2, n + 1):
        result *= value
    return result

print(factorial(5))   # 120
169Check whether a number is primeBeginner+

The naive approach checks every possible divisor from 2 up to `n - 1`, but this does a lot of unnecessary work — a key mathematical insight dramatically reduces that range: if `n` has any factor LARGER than its own square root, that factor must be paired with a CORRESPONDING factor SMALLER than the square root (since factors of a number always come in pairs multiplying to that number) — meaning if no factor exists up to and including `√n`, none can exist beyond it either.

So the loop only needs to check candidate divisors up to `√n` (implemented here as `d * d <= n`, avoiding an explicit square-root computation and its associated floating-point considerations) — this turns an O(n) check into an O(√n) one, a meaningful complexity improvement that's worth being able to explain clearly, since 'why does this loop stop where it does' is a near-guaranteed interview follow-up question for this exact problem.

def is_prime(n):
    if n < 2: return False
    d = 2
    while d * d <= n:
        if n % d == 0: return False
        d += 1
    return True

print(is_prime(29))   # True
170Generate the first N Fibonacci numbers, iterativelyIntermediate+

Unlike the recursive Fibonacci implementation that recomputes overlapping sub-problems repeatedly (the reason memoization was introduced back in Section 04), this iterative version needs to track only the PREVIOUS TWO values at any given moment — there's no need to store or recompute the entire sequence up to that point, since each next value depends only on its two immediate predecessors.

The loop simply advances that pair of tracked values forward one step at a time (`a, b = b, a + b`, using Python's simultaneous tuple assignment so both updates happen together based on the OLD values, rather than accidentally using an already-updated `a` when computing the new `b`), appending the current value to the results list before each advance — this runs in O(n) time and O(1) additional space beyond the output list itself, a meaningful efficiency improvement over the exponential-time naive recursive version.

def fibonacci(count):
    a, b, result = 0, 1, []
    for _ in range(count):
        result.append(a)
        a, b = b, a + b
    return result

print(fibonacci(10))
171Check if two strings are anagrams of each otherIntermediate+

Two strings are anagrams of each other precisely when they contain EXACTLY the same characters with EXACTLY the same frequencies, just possibly rearranged — this observation turns the problem into a comparison of character-frequency maps rather than needing to actually try rearranging anything. `collections.Counter` builds exactly that frequency map automatically from any iterable (a string, in this case), counting how many times each character appears.

Two `Counter` objects compare as EQUAL (via `==`) if and only if they contain the exact same keys mapped to the exact same counts — meaning the entire anagram check collapses to building two Counters (after normalizing away spaces and case differences, since 'anagram checks' in interviews usually intend a case-insensitive, space-ignoring comparison) and comparing them directly with `==`, a remarkably concise solution for what sounds like it should require more manual character-counting logic.

from collections import Counter

def are_anagrams(a, b):
    clean_a = a.replace(" ", "").lower()
    clean_b = b.replace(" ", "").lower()
    return Counter(clean_a) == Counter(clean_b)

print(are_anagrams("listen", "silent"))   # True
172Find the missing number from a 1..n sequenceIntermediate+

If you're told a list SHOULD contain every integer from 1 to `n` exactly once, but exactly ONE value is missing (with no duplicates present), you don't need to sort the list or check for each expected value individually — there's a well-known closed-form formula for the sum of the first `n` positive integers: `n * (n + 1) / 2`, computed with `//` for exact integer division since that formula always produces a whole number.

Computing what the sum SHOULD be (using that formula) and subtracting the ACTUAL sum of the given list directly reveals the missing value, since the difference between 'what should be there' and 'what's actually there' is exactly the one number that's absent — this runs in O(n) time (one pass to sum the actual list) and needs no sorting or extra data structure at all, a much more elegant solution than the more obvious approach of checking every number from 1 to n against a set of what's present.

def missing_number(nums, n):
    expected = n * (n + 1) // 2
    return expected - sum(nums)

print(missing_number([1, 2, 4, 5], 5))   # 3
173Group a list of words into sets of anagramsIntermediate+

This builds directly on the anagram-checking insight from a couple of questions above, but scaled up to group MANY words at once rather than just comparing a single pair — the key idea is finding a single, consistent CANONICAL representation that's identical for every word in the same anagram group, so words can be bucketed by that shared representation instead of comparing every pair of words against each other (which would be far slower).

Sorting a word's individual LETTERS alphabetically produces exactly such a canonical form — 'eat', 'tea', and 'ate' all sort to the identical letter sequence `('a','e','t')`, regardless of their original letter order, while a genuinely different word like 'bat' sorts to something different. Using a `defaultdict(list)` keyed by that sorted-letters tuple, every word gets appended into the bucket matching its canonical form automatically (with an empty list auto-created the first time a new canonical form is seen, exactly the defaultdict pattern from Section 02) — words that are anagrams of each other naturally end up grouped together in the same bucket by construction.

from collections import defaultdict

def group_anagrams(words):
    groups = defaultdict(list)
    for word in words:
        key = tuple(sorted(word))
        groups[key].append(word)
    return list(groups.values())

print(group_anagrams(["eat","tea","tan","ate","nat","bat"]))
174Flatten an arbitrarily nested list recursivelyIntermediate+

When a list can contain OTHER lists nested to an unknown, arbitrary depth (not just one level of nesting), a purely iterative approach becomes awkward to write cleanly — recursion is the natural fit here, since 'flatten this nested list' can be defined directly in terms of the smaller sub-problem 'flatten each nested list found inside it', which is exactly the recursive structure covered conceptually in Section 04.

The function checks each item: if it's ITSELF a list, recursively flatten IT and extend the accumulating result with whatever that recursive call returns (handling nesting to any depth this way, since the recursive call will itself recurse further if IT finds more nested lists inside); if it's not a list, it's a genuine leaf value and gets appended directly to the result as-is. `isinstance(item, list)` is the check that decides which of these two paths to take for each individual item encountered.

def flatten(items):
    result = []
    for item in items:
        if isinstance(item, list):
            result.extend(flatten(item))
        else:
            result.append(item)
    return result

print(flatten([1, [2, [3, 4]], 5]))   # [1, 2, 3, 4, 5]
175Validate balanced brackets in a stringIntermediate+

A STACK (last-in-first-out) is the natural data structure for this problem, because bracket matching inherently follows a last-in-first-out pattern: the most RECENTLY opened bracket must be the NEXT one closed — a plain list, used with `.append()` to push and `.pop()` to pop from the end, works perfectly as a stack in Python without needing any specialized data structure.

Walking through the string character by character: every OPENING bracket gets pushed onto the stack, to be matched later. Every CLOSING bracket must correctly match whatever is currently on TOP of the stack (the most recently opened, still-unmatched bracket) — if the stack is empty when a closing bracket appears (nothing left to match against), or the popped opening bracket doesn't correspond to this specific closing bracket type, the string is definitively unbalanced and you can return `False` immediately. If you make it through the entire string this way, the string is balanced if and only if the stack ends up completely EMPTY — a non-empty stack at the end means some opening brackets were never actually closed.

def balanced(text):
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = []
    for char in text:
        if char in "([{":
            stack.append(char)
        elif char in pairs:
            if not stack or stack.pop() != pairs[char]:
                return False
    return not stack

print(balanced("{[()]}"))   # True
176Merge two already-sorted lists into one sorted listIntermediate+

Because BOTH input lists are already individually sorted, you don't need to re-sort anything from scratch — you can build the merged result by repeatedly comparing just the CURRENT FRONT element of each list and taking whichever is smaller, advancing only the pointer for the list you took from. This 'two-pointer merge' technique is exactly the core building block used inside merge sort's own merge step, and it's genuinely worth understanding well beyond just this one interview question.

Once either list's pointer runs past its own end, the OTHER list's remaining elements (whatever's left, all of which are guaranteed to be larger than everything already merged, since both inputs were sorted) can simply be appended onto the result directly with a slice, without any further comparisons needed. This entire merge runs in O(n + m) time — linear in the COMBINED size of the two inputs — which is precisely why merge sort, built around repeatedly applying this merge step, achieves its well-known O(n log n) overall time complexity.

def merge_sorted(a, b):
    i = j = 0
    result = []
    while i < len(a) and j < len(b):
        if a[i] <= b[j]:
            result.append(a[i]); i += 1
        else:
            result.append(b[j]); j += 1
    result.extend(a[i:]); result.extend(b[j:])
    return result

print(merge_sorted([1, 3, 5], [2, 4, 6]))
177Implement binary search on a sorted listIntermediate+

Binary search only works correctly on data that's ALREADY SORTED, and it exploits that sortedness to eliminate HALF of the remaining possible positions with each single comparison, rather than checking elements one at a time from the start — this is exactly what gives it its well-known O(log n) time complexity, dramatically faster than a linear O(n) scan for large sorted collections.

The algorithm maintains a shrinking search window (`left` to `right`), repeatedly checking the MIDDLE element of the current window: if it matches the target, you're done immediately. If the middle element is SMALLER than the target, the target (if present at all) must be somewhere in the RIGHT half, so you discard the entire left half by moving `left` past the midpoint. If the middle element is LARGER, you discard the right half instead by moving `right` before the midpoint. This 'explain why your algorithm is O(log n)' follow-up is one of the single most common complexity-analysis questions asked in coding interviews across essentially every company.

def binary_search(items, target):
    left, right = 0, len(items) - 1
    while left <= right:
        mid = (left + right) // 2
        if items[mid] == target: return mid
        if items[mid] < target: left = mid + 1
        else: right = mid - 1
    return -1

print(binary_search([1, 3, 5, 7, 9], 7))   # 3
178Find the most frequent element in a listBeginner+

Rather than manually building a frequency-counting dictionary yourself with a loop (a perfectly valid approach, but more code than necessary), `collections.Counter` does exactly that counting work for you in one call, producing an object that behaves like a dictionary mapping each distinct value to how many times it appeared in the input.

`Counter` additionally provides `.most_common(n)`, which returns the `n` most frequently occurring items as a list of `(value, count)` tuples, already SORTED by frequency from highest to lowest — calling `.most_common(1)` and taking the single result at index `[0]` combines the counting AND the ranking-by-frequency into one compact expression, rather than needing to separately count everything and then manually find the maximum yourself.

from collections import Counter

def most_frequent(items):
    return Counter(items).most_common(1)[0]

print(most_frequent(["a","b","a","c","a","b"]))   # ('a', 3)
179Find the second-largest unique number in a listBeginner+

The word 'unique' in this problem is doing real work and is easy to overlook: if the list contains duplicates of the largest value (like `[10, 10, 8]`), a naive approach that just finds the two largest VALUES by POSITION (rather than by distinct value) could incorrectly return the duplicate 10 as the 'second largest' instead of the genuinely different next-highest value, 8.

Converting the list to a `set` first automatically removes duplicates, collapsing repeated values down to one occurrence each — sorting that deduplicated set and taking the second-to-last element (`unique[-2]`) then correctly gives the second-largest DISTINCT value. A robust, interview-quality implementation should also explicitly guard against the edge case where fewer than 2 unique values exist at all (raising a clear error rather than letting an `IndexError` happen confusingly on the `[-2]` access), which is exactly the kind of edge-case awareness interviewers are specifically watching for beyond just the 'happy path' logic.

def second_largest(nums):
    unique = sorted(set(nums))
    if len(unique) < 2:
        raise ValueError("Need at least 2 unique values")
    return unique[-2]

print(second_largest([10, 5, 8, 10, 7]))   # 8
180Rotate a list to the right by k positionsIntermediate+

Rotating a list to the right by `k` positions means the LAST `k` elements move to the FRONT, and everything else shifts right to make room — this can be expressed with just two SLICES concatenated together: the last `k` elements (`items[-k:]`), followed by everything except those last `k` elements (`items[:-k]`), joined with `+` — no manual element-by-element shifting loop is needed at all.

An important robustness detail: if `k` is LARGER than the list's own length (rotating by more positions than there are elements), the rotation effectively 'wraps around' one or more full cycles back to where it started — taking `k %= len(items)` FIRST reduces `k` down to its meaningful remainder within a single full rotation, correctly handling this case rather than producing an incorrect or out-of-range slice; it also correctly avoids a `ZeroDivisionError` concern if you additionally guard against an empty input list, as done here with the early `if not items` check.

def rotate_right(items, k):
    if not items: return items
    k %= len(items)
    return items[-k:] + items[:-k]

print(rotate_right([1, 2, 3, 4, 5], 2))   # [4, 5, 1, 2, 3]
181Remove duplicates from a list while preserving orderBeginner+

Simply converting a list to a `set` removes duplicates effectively, but sets in Python don't guarantee any particular ORDER — if the ORIGINAL sequence order of the surviving unique elements matters to your problem (which it very often does in real applications, and is explicitly required by this question), a plain `set()` conversion alone isn't sufficient on its own.

Since Python 3.7, regular dictionaries reliably preserve INSERTION order — and critically, dictionary KEYS, just like set elements, must be unique. `dict.fromkeys(items)` exploits both of these facts together: it builds a dictionary using every item in the list as a key (silently ignoring later duplicate keys, since a dict can't hold the same key twice, exactly discarding duplicates as a side effect), while preserving the ORDER each distinct key was first encountered — converting that resulting dict's keys back into a list gives you exactly the de-duplicated list in original first-seen order, all without writing an explicit loop yourself.

def dedupe(items):
    return list(dict.fromkeys(items))

print(dedupe([3, 1, 3, 2, 1, 4]))   # [3, 1, 2, 4]
182Implement a simple LRU cacheAdvanced+

An LRU (Least Recently Used) cache needs to do two things efficiently: quickly look up a cached value by key, AND keep track of which entries were used most vs. least recently, so that when the cache reaches its capacity limit, it can evict specifically the LEAST recently used entry to make room for a new one — rather than evicting arbitrarily or requiring an expensive separate scan to figure out which entry is oldest.

`collections.OrderedDict` is the ideal building block here because it directly tracks insertion/access order AND supports moving an existing key to the end cheaply via `.move_to_end(key)` — every time a key is either read (`get`) or written (`put`), moving it to the end marks it as 'just used', naturally pushing genuinely stale, untouched entries toward the FRONT of the ordering over time. `.popitem(last=False)` then evicts specifically from the front — the least recently used entry — the moment the cache exceeds its configured capacity, giving you a correct, efficient LRU cache in a fairly small amount of code rather than needing to hand-build a more complex linked-list-plus-hashmap structure yourself.

from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.data = OrderedDict()

    def get(self, key):
        if key not in self.data: return -1
        self.data.move_to_end(key)
        return self.data[key]

    def put(self, key, value):
        if key in self.data: self.data.move_to_end(key)
        self.data[key] = value
        if len(self.data) > self.capacity:
            self.data.popitem(last=False)

cache = LRUCache(2)
cache.put("a", 1); cache.put("b", 2)
print(cache.get("a"))     # 1
cache.put("c", 3)          # evicts "b" — least recently used
print(cache.get("b"))     # -1
Python Interview Field Guide · Compiled & Designed by Tanveer Qureshie