# Data Analyst Interview Questions — Python Source: Codeayan (https://codeayan.com) Canonical: https://codeayan.com/get-hired/data-analyst/python Questions: 54 Last updated: 2026-08-20 Licence: free to read. Please cite Codeayan when quoting. --- ## 1. Explain mutable versus immutable types. Why does the distinction matter the moment you pass something into a function? *Easy · Very Common* **Short answer.** Immutable objects such as int, str and tuple cannot be changed after creation; lists, dicts and sets can. Python passes references, so a function receiving a mutable object can modify the caller's data in place. Rebinding the parameter name affects nothing outside the function. Two functions that look almost identical behave completely differently. ``` def add_topping(order, topping): order.append(topping) # mutates caller's list def replace_order(order, new_items): order = new_items # rebinds local name only pizza = ["cheese"] add_topping(pizza, "olives") print(pizza) # ['cheese', 'olives'] replace_order(pizza, ["paneer"]) print(pizza) # ['cheese', 'olives'] ← unchanged ``` The parameter name is a local label pointing at the same object the caller passed. `append` reaches through that label and changes the object itself, which the caller sees. Assignment moves the label to a different object and leaves the original alone. An immutable object cannot be reached through in that way at all, which is why passing an int or a string is always safe. `s.upper()` returns a new string; it does not modify `s`. The failure this causes in real code is quiet. A helper that normalises a config dictionary and mutates it in place looks fine in isolation, and then a caller that expected its own dictionary untouched starts seeing modified values three functions away. When a function modifies its argument, either name it so that is obvious, or copy the input at the top and return the new object. One thing worth knowing for the follow-up: immutability is shallow. A tuple cannot be reassigned, but if it holds a list, that list is still mutable. ``` row = ("BLR", ["delayed"]) row[1].append("cancelled") # works ``` That also means such a tuple is not hashable and cannot be used as a dictionary key. **Likely follow-ups** - If Python passes references, why doesn't reassigning the parameter inside the function change the caller's variable? - Which built-in types are immutable, and is a tuple always safe to share? - How would you write a function that clearly signals it modifies its argument? --- ## 2. Pull the last month of data from a REST API with requests. What do you handle beyond the happy path? *Medium · Very Common* **Short answer.** Set a timeout on every call, check the status code with `raise_for_status()`, and pass parameters through `params=` rather than building the URL by hand. Without a timeout a request can hang indefinitely, which is how a scheduled job silently stops producing output. ``` import requests resp = requests.get( "https://api.weatherboard.in/v2/stations", params={"from": "2026-07-01", "to": "2026-07-31"}, headers={"Authorization": f"Bearer {token}"}, timeout=(5, 30), # connect, read ) resp.raise_for_status() data = resp.json() ``` Three things there are not optional. **Timeout.** `requests` has no default timeout. A server that accepts the connection and then stops responding leaves your call blocked forever, and a nightly job that hangs looks identical to a job that is still running. The tuple form separates the connection timeout from the read timeout, which matters for an endpoint that is quick to reach and slow to compute. **Status check.** A 404 or a 500 still returns a response object, and `resp.json()` on an HTML error page raises a confusing decode error rather than telling you the request failed. `raise_for_status()` turns any 4xx or 5xx into an `HTTPError` you can catch. **`params=`.** Building `?from=" + date` by hand breaks the moment a value contains a space or an ampersand. Passing a dict lets requests handle the encoding. The failure that catches analysts is an API returning HTTP 200 with an error inside the body. `raise_for_status()` passes, `.json()` succeeds, and your DataFrame is empty. Check the payload shape before trusting it, not just the status. For anything beyond a single call, use a `Session`. It reuses the underlying TCP connection across requests, which on a hundred calls is a large difference, and it holds headers in one place: ``` with requests.Session() as s: s.headers.update({"Authorization": f"Bearer {token}"}) resp = s.get(url, params=params, timeout=(5, 30)) ``` The token comes from an environment variable or a secrets store, never from a literal in a notebook that ends up on GitHub. **Likely follow-ups** - The API returns 200 with an error message in the body — how would you catch that? - Why would you use a Session rather than calling requests.get repeatedly? - Where would you put the API key, and where would you definitely not put it? --- ## 3. The API response has fields nested four levels deep and some of them are sometimes missing. How do you get the value out without your script dying? *Easy · Very Common* **Short answer.** Parse with `json.loads` into Python dicts and lists, then walk it with chained `.get()` calls supplying a default at each level, so a missing key returns None instead of raising KeyError. For lists inside the structure, check length before indexing, since `.get()` does not help there. ``` import json payload = json.loads(raw_text) city = (payload.get("data", {}) .get("location", {}) .get("address", {}) .get("city")) ``` Each `.get(key, {})` returns an empty dict when the key is absent, so the next `.get` in the chain has something to call and the whole expression yields `None` rather than raising halfway through. Supplying `{}` as the default rather than `None` is what makes the chaining work. Lists break the pattern, since indexing has no `.get()` equivalent: ``` readings = payload.get("data", {}).get("readings", []) first = readings[0] if readings else None ``` For anything deeper than three or four levels, a small helper is easier to read than a chain: ``` def dig(obj, *keys, default=None): for key in keys: if not isinstance(obj, dict): return default obj = obj.get(key, default if key == keys[-1] else {}) return obj dig(payload, "data", "location", "address", "city") ``` Two things worth raising in an interview. `json.loads` takes a string, `json.load` takes a file object. Mixing them up gives a `TypeError` about expecting a string-like object, and it is the most common small error with this module. And types come back as whatever the JSON says, not what you expect. A field holding `"1200"` in some records and `1200` in others is common in real feeds, and comparison or arithmetic then fails only on the affected rows. Normalise at the boundary where you parse, so the rest of your code deals with one type. JSON has no date type, so timestamps arrive as strings and need explicit parsing before any comparison. **Likely follow-ups** - What's the difference between json.load and json.loads? - A number comes back as a string in some records — where would you handle that? - How would you write this if the nesting were ten levels deep? --- ## 4. When would you use a tuple instead of a list? What does the immutability actually buy you? *Easy · Very Common* **Short answer.** Use a list for a homogeneous, growing collection and a tuple for a fixed record whose positions mean different things. Immutability buys you hashability, so tuples work as dictionary keys and set members, and it signals to a reader that the structure will not change. The convention most teams follow is about meaning, not performance. A list holds many of the same thing and its length varies. A tuple holds a fixed set of fields where position carries meaning. ``` sensor_ids = ["T-01", "T-02", "T-03"] # list: many of one thing reading = ("T-01", 34.2, "2026-08-19") # tuple: one record ``` The concrete capability immutability gives you is hashability. A tuple of immutable values can be a dictionary key or a set member; a list cannot. ``` daily_max = {} daily_max[("T-01", "2026-08-19")] = 41.7 # fine daily_max[["T-01", "2026-08-19"]] = 41.7 # TypeError: unhashable type: 'list' ``` That single property is why tuples turn up as composite keys in caches, lookup tables and grouping logic all over real codebases. The performance argument is usually overstated. Tuples are marginally smaller and construct slightly faster, and unless you are creating crores of them in a hot loop, that difference will not be what makes your job slow. Do not lead with it in an interview; lead with intent and hashability. Once a tuple has more than three fields, positional access becomes a liability. `reading[2]` tells a reader nothing. `NamedTuple` or a frozen dataclass keeps the immutability and adds names: ``` from typing import NamedTuple class Reading(NamedTuple): sensor_id: str celsius: float taken_on: str ``` Still hashable, still immutable, and `r.celsius` survives someone reordering the fields. **Likely follow-ups** - Is a tuple always faster than a list, and does that difference matter in your code? - What would you use instead of a tuple when the fields have names? - Can you use a list as a dictionary key, and why not? --- ## 5. How do you read a value out of a dictionary when the key might not be there? *Easy · Very Common* **Short answer.** Square brackets raise KeyError on a missing key. `.get(key)` returns None instead, and `.get(key, default)` returns whatever default you supply. Use brackets when a missing key is a bug you want to hear about, and `.get()` when absence is expected and has a sensible fallback. ``` settings = {"retries": 3, "timeout": None} settings["region"] # KeyError: 'region' settings.get("region") # None settings.get("region", "apac") # 'apac' ``` Choosing between them is a decision about whether absence is normal. Reading a required configuration key with `.get()` and a silent default means a typo in the config file produces a working program with wrong behaviour, which is far harder to debug than a `KeyError` on startup. Reading an optional field from a parsed API response with brackets means one missing attribute crashes the whole batch. The trap in that example is `timeout`. Both a missing key and a key set to `None` make `.get()` return `None`, so you cannot tell them apart: ``` settings.get("timeout") # None — but the key exists settings.get("nonsense") # None — key doesn't exist "timeout" in settings # True ``` When the difference matters, test with `in` rather than inferring from the returned value. This bites when `None` is a legitimate stored value meaning “no limit” and your code treats it as “not configured” and substitutes a default. One more thing people get wrong: `.get()` never inserts. If you want to read-or-create in one step, that is `.setdefault()`. ``` by_city = {} by_city.setdefault("Pune", []).append("order-118") ``` For accumulating into a dict repeatedly, `collections.defaultdict(list)` reads better and avoids repeating the default on every line. **Likely follow-ups** - What's the difference between .get() returning None and the key existing with a value of None? - When would you reach for defaultdict instead? - What does .setdefault() do that .get() doesn't? --- ## 6. You copied a list of lists and changing the copy also changed the original. Explain what happened and how you'd fix it. *Medium · Very Common* **Short answer.** A shallow copy creates a new outer container holding references to the same inner objects, so mutating an inner object is visible through both copies. `copy.deepcopy` recursively copies everything, giving full independence at the cost of time and memory. ``` import copy template = [["mon", 0], ["tue", 0]] shallow = copy.copy(template) # or template[:] or list(template) shallow[0][1] = 9 print(template) # [['mon', 9], ['tue', 0]] ← changed deep = copy.deepcopy(template) deep[1][1] = 5 print(template) # [['mon', 9], ['tue', 0]] ← safe ``` The shallow copy did create a new outer list. What it did not do is create new inner lists, so `shallow[0]` and `template[0]` are the same object, and mutating through either label is visible through both. This turns up most often with a default structure reused across iterations. A roster template copied per employee, where every employee ends up sharing one shift list, produces a bug where updating one person’s schedule silently updates everyone’s. It looks like a database problem until you find the copy. Deep copy is not free. It walks the entire object graph and reconstructs it, which on a large nested structure inside a loop is genuinely slow. Two alternatives worth mentioning. Rebuild the structure explicitly with a comprehension when you know its shape: ``` fresh = [[day, count] for day, count in template] ``` Or avoid the problem by making the inner elements immutable, since a tuple cannot be mutated through a shared reference at all. Two details for the follow-ups. `deepcopy` handles cyclic references correctly by keeping a memo of objects it has already copied, so it does not recurse forever. And a class can control what happens by defining `__deepcopy__`, which matters for objects holding a database connection or a file handle that should not be duplicated. **Likely follow-ups** - What does deepcopy do when the structure contains a cycle? - Would this problem arise if the inner elements were tuples of strings? - How expensive is deepcopy on a large nested structure, and what would you do instead? --- ## 7. Explain `*args` and `**kwargs`. What are they doing on the way in, and on the way out? *Easy · Very Common* **Short answer.** In a function signature, `*args` collects extra positional arguments into a tuple and `**kwargs` collects extra keyword arguments into a dict. At a call site, the same symbols do the reverse: they unpack a sequence into positional arguments and a mapping into keyword arguments. Same syntax, two directions. In the definition it packs; at the call it unpacks. ``` def log_event(event, *tags, **fields): print(event, tags, fields) log_event("upload", "batch", "s3", size=940, retries=2) # upload ('batch', 's3') {'size': 940, 'retries': 2} payload = {"size": 940, "retries": 2} log_event("upload", "batch", **payload) # unpacking ``` The names are convention only; `*a` and `**kw` work identically. Stick with `args` and `kwargs` because everyone reading your code expects them. Where these earn their place is a wrapper that must forward whatever it receives without knowing the signature it is wrapping. A retry helper, a timing wrapper, a decorator: all of them accept `*args, **kwargs` and pass them straight through, so they work with any function. ``` def with_retry(fn, *args, **kwargs): for attempt in range(3): try: return fn(*args, **kwargs) except TimeoutError: continue raise ``` Two things worth knowing beyond the basics. A bare `*` forces everything after it to be keyword-only, which is a good way to stop callers passing a run of unlabelled booleans: ``` def export(path, *, overwrite=False, compress=True): ... export("out.csv", overwrite=True) # positional not allowed ``` And the cost of `**kwargs` is that it destroys the signature. Callers get no autocomplete, typos become silent extra dict entries rather than errors, and type checkers cannot help. Use it where forwarding is genuinely the point, not to avoid deciding what parameters a function takes. **Likely follow-ups** - What does a bare `*` in a signature mean? - If a caller passes the same argument positionally and by keyword, what happens? - Why does a decorator almost always accept both of these? --- ## 8. What's wrong with writing `def process(records, seen=[])`? Show me what actually happens. *Medium · Very Common* **Short answer.** The default is evaluated once, when the function is defined, not on each call. A default list or dict is therefore shared by every call that omits the argument, and mutations accumulate across calls. Use None as the default and create the container inside the function body. ``` def collect(row, batch=[]): batch.append(row) return batch collect("r1") # ['r1'] collect("r2") # ['r1', 'r2'] ← not ['r2'] collect("r3") # ['r1', 'r2', 'r3'] ``` The list is created once, at `def` time, and stored on the function object itself. You can see it directly: ``` collect.__defaults__ # (['r1', 'r2', 'r3'],) ``` Every call that omits the argument gets that same list, so state leaks between calls that have nothing to do with each other. The correct form is a sentinel: ``` def collect(row, batch=None): if batch is None: batch = [] batch.append(row) return batch ``` `is None` rather than `if not batch`, because an empty list passed deliberately by the caller is falsy and would be silently replaced. In production this rarely announces itself. A parsing helper with `errors={}` accumulates every error from every file processed since the service started, and memory grows steadily while each individual call looks correct. In a long-running worker that is a slow leak nobody attributes to the function. The same evaluate-once rule catches other defaults. `def report(as_of=datetime.now())` freezes the timestamp at import time, so a service running for a week reports the same date forever. Pass `None` and compute inside. Immutable defaults are safe, since there is nothing to mutate: `def f(n=0, label="")` behaves as everyone expects. There is one deliberate use of the behaviour, worth knowing but not worth writing: attaching a mutable default as a cheap per-function cache. `functools.lru_cache` does the same job without surprising the next reader. **Likely follow-ups** - Is `def f(x=[])` ever the behaviour you actually want? - What would you use as a default for a dict parameter? - Does the same problem apply to a default of datetime.now()? --- ## 9. Sort these records by department and then by joining date, newest first. Why does the order you apply the sorts matter? *Easy · Very Common* **Short answer.** Pass a function to `key=` to extract what you want to sort on, and `reverse=True` to flip the order. Python's sort is stable, meaning equal elements keep their existing relative order, so you can sort by a secondary field first and then by the primary field. ``` staff = [ {"name": "Riya", "dept": "ops", "joined": "2021-04-01"}, {"name": "Arun", "dept": "eng", "joined": "2023-07-15"}, {"name": "Meera", "dept": "ops", "joined": "2019-01-09"}, ] by_dept = sorted(staff, key=lambda r: (r["dept"], r["joined"])) ``` A tuple key sorts by the first element and uses the second to break ties, which handles most multi-field cases in one call. Mixed directions are where stability earns its keep. You cannot put `reverse=True` on just one element of a tuple key, so you sort twice, least significant first: ``` rows = sorted(staff, key=lambda r: r["joined"], reverse=True) rows = sorted(rows, key=lambda r: r["dept"]) ``` Because Python’s sort is stable, the second sort preserves the date ordering within each department. Reverse the order of those two statements and the result is wrong. For numeric fields there is a shortcut: negate the value in the key, so `key=lambda r: (r["dept"], -r["salary"])`. `operator.itemgetter` is often faster and clearer than a lambda for straightforward field access: ``` from operator import itemgetter sorted(staff, key=itemgetter("dept", "joined")) ``` Two things to keep straight. `sorted()` returns a new list and works on any iterable; `.sort()` mutates a list in place and returns `None`. Writing `rows = rows.sort()` silently sets `rows` to `None`, and the failure surfaces later as a confusing `TypeError`. And the key function is called exactly once per element, then the results are compared. So an expensive key such as a date parse costs n calls rather than n log n, which is the reason `key=` replaced the old comparison-function style. **Likely follow-ups** - How would you sort ascending on one field and descending on another in a single pass? - What's the difference between `sorted()` and `.sort()`? - Why is `key=` preferred over the old `cmp` style? --- ## 10. Write me a comprehension that builds a dict from these records. Then show me a nested one and tell me where you'd stop. *Easy · Very Common* **Short answer.** The same syntax builds lists, dicts and sets: brackets give a list, braces with a colon give a dict, braces without give a set. Nested loops read in the same order you would write them as statements. Beyond two loops or one condition, a plain for loop is easier to follow. ``` books = [("Ghachar Ghochar", 2013), ("Em and the Big Hoom", 2012)] titles = [t for t, _ in books] # list by_year = {t: y for t, y in books} # dict years = {y for _, y in books} # set ``` Adding a condition filters, and a conditional expression before the `for` transforms: ``` recent = [t for t, y in books if y >= 2013] tags = ["new" if y >= 2013 else "old" for _, y in books] ``` Those two `if`s sit in different positions and do different jobs. Filtering goes after the `for`; choosing between two values goes before it. Getting them confused is a common syntax error. Nesting reads in statement order, outer loop first: ``` shelves = [["a1", "a2"], ["b1"]] flat = [code for shelf in shelves for code in shelf] ``` Read it as `for shelf in shelves:` then `for code in shelf:`, which is exactly the order you would write the loops. People frequently expect the reverse and get an error about an undefined name. Where to stop is a judgement worth voicing. Two loops with a condition is around the limit before a comprehension stops being readable, and a reader having to decode it costs more than the two extra lines a loop would take. There is no rule here, only the next person reading it. One memory point. Brackets build the entire list in memory before returning. Parentheses produce a generator expression instead, which yields lazily and is what you want when feeding a large sequence into `sum` or a loop: ``` total = sum(y for _, y in books) # no intermediate list ``` Comprehensions have their own scope from Python 3 onwards, so the loop variable does not leak out. **Likely follow-ups** - In a nested comprehension, which for loop is the outer one? - What does a comprehension with parentheses instead of brackets give you? - Does the loop variable leak into the surrounding scope? --- ## 11. The date column arrives as `"14-03-2026"` in one file and `"2026/03/14"` in another. Parse both and write them out in a single format. *Medium · Very Common* **Short answer.** `strptime` parses a string into a datetime using a format code; `strftime` goes the other way. The codes are the same in both directions. A format mismatch raises ValueError rather than guessing, which is the behaviour you want, since silent misparsing is far worse. ``` from datetime import datetime FORMATS = ["%d-%m-%Y", "%Y/%m/%d", "%d %b %Y"] def parse_date(text): text = text.strip() for fmt in FORMATS: try: return datetime.strptime(text, fmt).date() except ValueError: continue raise ValueError(f"unrecognised date: {text!r}") parse_date("14-03-2026").isoformat() # '2026-03-14' ``` Trying formats in order and raising when none match is better than a library that guesses, because a guess on ambiguous input is wrong silently. The codes worth memorising: `%d` day, `%m` month number, `%Y` four-digit year, `%y` two-digit, `%b` short month name, `%B` full name, `%H:%M:%S` for time. `%-d` for a non-padded day works on Linux and macOS but not on Windows, which is a portability trap in report formatting. The genuine hazard is ambiguity. `03-04-2026` is 3 April under `%d-%m-%Y` and 4 March under `%m-%d-%Y`, and both parse without error. In a file that mixes conventions, only dates past the 12th tell you which one you have, so roughly 40% of rows are undetectably ambiguous. There is no code that solves this. Ask the source system which convention it uses, and if nobody knows, check whether any value in the file has a first component above 12. Two-digit years are the related trap. Python maps `%y` values 69–99 to the 1900s and 00–68 to the 2000s, so `%y` parsing of `70` gives 1970, which is rarely what a recent data file means. For anything you write back out, use `date.isoformat()` or `%Y-%m-%d`. It sorts correctly as a string, it is unambiguous, and every downstream tool reads it. Local display formatting belongs at the point of display, not in stored data. **Likely follow-ups** - How would you tell 03-04-2026 apart in a file that mixes Indian and American conventions? - What does strptime do with a two-digit year? - Why would you store dates as ISO strings rather than in the local format? --- ## 12. What is a generator, and what do you give up by using one instead of returning a list? *Medium · Very Common* **Short answer.** A function containing `yield` returns a generator: calling it runs no code, and each iteration executes until the next yield and pauses there, keeping local state. You gain constant memory over huge sequences. You give up length, indexing, and the ability to iterate more than once. ``` def parse_log(path): with open(path) as f: for line in f: if line.startswith("ERROR"): yield line.strip() for entry in parse_log("app.log"): # one line in memory handle(entry) ``` The list version builds every matching line before the caller sees the first one. On a 40 GB log file that is the difference between a job that runs and one that gets killed by the memory limit. Calling `parse_log("app.log")` executes nothing. It returns a generator object. The body runs only when something iterates it, advancing to the next `yield` and freezing there with all locals intact. What you give up matters as much as what you gain. ``` gen = parse_log("app.log") len(gen) # TypeError gen[0] # TypeError list(gen) # works, but defeats the purpose for x in gen: ... # consumes it for x in gen: ... # yields nothing, silently ``` That last one is the failure that reaches production. A generator passed to two functions works for the first and gives the second an empty sequence, with no error and no warning. The downstream count comes out as zero and someone spends an afternoon on it. If two passes are needed, materialise once with `list()` and accept the memory cost, or call the generator function again. One subtlety with the file example: the `with` block stays open while the generator is alive, and if the caller abandons it partway, the file is closed when the generator is garbage collected rather than at a predictable point. `yield from` delegates to another iterable, which flattens nested generators without an explicit loop and correctly forwards values sent into the generator. **Likely follow-ups** - What happens if you iterate the same generator twice? - How would you get the length of what a generator will produce? - What does `yield from` do that a loop with yield doesn't? --- ## 13. Walk me through try, except, else and finally. What goes in each one, and why not put everything in the try? *Medium · Very Common* **Short answer.** `try` holds the code that might fail, `except` handles a specific failure, `else` runs only if no exception occurred, and `finally` runs either way for cleanup. Keeping the try block narrow matters, because a wide one catches errors from lines you never intended to protect. ``` try: conn = connect(dsn) except ConnectionError as exc: log.error("could not reach vendor API: %s", exc) raise else: payload = conn.fetch_invoices() # only if connect worked finally: conn.close() if conn else None ``` The `else` block is the part most people never use, and it is what keeps the `try` narrow. Put `fetch_invoices()` inside the `try` and a `ConnectionError` raised deep inside the fetch gets caught by a handler written for the connection step, and your log says the wrong thing about the wrong operation. `finally` runs whether or not an exception occurred, and whether or not the block returned. That makes it the right place for releasing a lock, closing a handle, or restoring a setting. In most cases a `with` block expresses the same intent more clearly, since the context manager owns the cleanup. Three things that separate a working answer from a careful one. **Catch narrowly.** A bare `except:` catches `KeyboardInterrupt` and `SystemExit` as well, so your job cannot be stopped cleanly. `except Exception` is better and still too broad as a default, since a typo raising `NameError` is swallowed and reported as a vendor failure. **Never swallow silently.** `except Exception: pass` is how a nightly pipeline reports success while writing nothing. If you catch it, either handle it meaningfully or log it and re-raise. **Preserve the chain.** When you wrap an error in your own exception type, `raise LoadError("vendor feed failed") from exc` keeps the original traceback attached. Without `from`, the underlying cause is harder to find in the logs. One sharp edge worth knowing: a `return` inside `finally` discards any exception currently propagating, so an error disappears with no trace. **Likely follow-ups** - What's wrong with `except Exception` as your only handler? - If finally contains a return, what happens to an exception in flight? - When would you use `raise ... from` rather than a bare raise? --- ## 14. What does `with` actually guarantee, and how would you write a context manager yourself? *Medium · Very Common* **Short answer.** `with` guarantees that setup runs before the block and cleanup runs after it, whether the block finishes normally, returns, or raises. A context manager is any object with `__enter__` and `__exit__`; the `__exit__` method receives the exception details if one occurred. The guarantee is the point. A `try`/`finally` gives you the same thing, and `with` moves the cleanup logic into the object that owns the resource, so every caller gets it right without remembering. ``` class Stopwatch: def __init__(self, label): self.label = label def __enter__(self): self.start = time.perf_counter() return self # bound to `as` def __exit__(self, exc_type, exc, tb): elapsed = time.perf_counter() - self.start log.info("%s took %.2fs", self.label, elapsed) return False # do not suppress with Stopwatch("model export") as sw: export_weights() ``` `__exit__` receives three arguments describing any exception that escaped the block, all `None` if the block finished cleanly. Returning a truthy value from `__exit__` suppresses that exception, which is occasionally what you want and much more often a mistake. Return `False`, or nothing at all, unless you deliberately mean to swallow it. Whatever `__enter__` returns is what `as` binds. Returning `self` is the common choice; returning `None` is fine when the block does not need a handle. For most cases the generator form is shorter and reads better: ``` from contextlib import contextmanager @contextmanager def temp_setting(obj, **overrides): saved = {k: getattr(obj, k) for k in overrides} for k, v in overrides.items(): setattr(obj, k, v) try: yield obj finally: for k, v in saved.items(): setattr(obj, k, v) ``` The `try`/`finally` around the `yield` is essential. Without it, an exception in the body propagates out of the `yield` and the restoration never runs, which is exactly the guarantee you were trying to provide. One gotcha: opening several resources with a single `with` and commas is fine, but if the second one fails during setup, the first is still cleaned up correctly. Nesting manually with plain assignments does not give you that. **Likely follow-ups** - If the body raises, does your cleanup still run, and how does the manager know? - How would you write one so that it suppresses a specific exception? - What does `contextlib.contextmanager` do to a generator? --- ## 15. Explain `self`, and what the difference is between an attribute set in `__init__` and one set on the class body. *Easy · Very Common* **Short answer.** `self` is the instance, passed automatically as the first argument to every method. An attribute assigned in `__init__` belongs to that instance alone; one assigned in the class body is shared by every instance, and Python falls back to it when the instance has no attribute of that name. ``` class Turbine: site = "Jaisalmer" # class attribute, shared log = [] # class attribute, shared and mutable def __init__(self, tag): self.tag = tag # instance attribute ``` Lookup goes instance first, then class. So `t.site` finds nothing on the instance and falls back to the class, and every turbine reports the same site. That is useful for genuine constants and defaults. Assignment does not follow the same path. Writing `t.site = "Bhuj"` creates an instance attribute that shadows the class one for that object only; the class attribute is untouched and every other instance still sees Jaisalmer. Reading falls back, writing does not. The mutable class attribute is the real trap: ``` a, b = Turbine("T1"), Turbine("T2") a.log.append("fault") b.log # ['fault'] ``` `a.log.append(...)` never assigns, so it never creates an instance attribute. It reaches through to the shared list and mutates it. Two objects that should be independent now share state, and in a long-running service that list also grows without bound. Anything mutable belongs in `__init__` as `self.log = []`. `self` is not a keyword, just a convention. Python passes the instance as the first positional argument to any method called on it, so `t.spin()` is `Turbine.spin(t)`. Rename it and the code works and every reviewer will object. One detail for the follow-up: `__init__` initialises an object that already exists and must return `None`. The object is created by `__new__`, which you rarely touch outside of immutable subclasses and singletons. **Likely follow-ups** - What happens when you assign to an attribute that only exists on the class? - Why is a mutable class attribute a problem? - What does `__init__` return, and what actually creates the object? --- ## 16. Why does every Python project tell you to create a virtual environment first? *Easy · Very Common* **Short answer.** A virtual environment gives each project its own isolated site-packages directory and its own installed versions. Without one, every project shares the system interpreter's packages, so upgrading a library for one project silently changes behaviour in every other project on the machine. The problem is a single shared namespace. One project needs an older release of a plotting library that a colleague’s project cannot use, and there is exactly one place to install it. ``` python -m venv .venv source .venv/bin/activate # Windows: .venvScriptsactivate pip install -r requirements.txt ``` `venv` is in the standard library from Python 3.3 onwards, so nothing needs installing first. Activating it puts that environment’s `python` and `pip` at the front of your PATH, so installs land inside the project directory rather than in the system interpreter. Two failures make this concrete. Installing into the system Python on Linux can break OS tooling that depends on specific package versions. Recent versions of Debian and Ubuntu now refuse a bare `pip install` outside a virtual environment for exactly this reason, and the error message confuses people who have never used one. The subtler one is drift. Code runs on your machine and fails in the pipeline, because your machine picked up a library version installed eighteen months ago for something else. Nobody can reproduce it and nobody can explain it. A per-project environment plus a pinned requirements file removes the whole category. Pin what you install. `pip freeze > requirements.txt` captures exact versions including transitive dependencies, which is what you want for reproducing a deployment. A hand-written file listing only your direct dependencies with ranges is better for a library others will install. Tools like Poetry and uv manage both sides with a lock file, and are worth naming if the interviewer asks what you use. One practical note: keep `.venv/` out of version control and commit the requirements file instead. The environment is derived; the specification is the artefact. **Likely follow-ups** - Two projects need different versions of the same library — what does that look like without a venv? - What's the difference between `pip freeze` and a requirements file you write by hand? - Where does a container fit in if you already have virtual environments? --- ## 17. Write me a decorator from scratch and explain what the `@` symbol is actually doing. *Hard · Very Common* **Short answer.** `@decorator` above a function is shorthand for reassigning the name: the function is passed to the decorator and the name is rebound to whatever comes back. A decorator is any callable that takes a function and returns a replacement, usually a wrapper closing over the original. The syntax hides an assignment. These two are identical: ``` @audit def issue_refund(ticket_id, amount): ... def issue_refund(ticket_id, amount): ... issue_refund = audit(issue_refund) ``` That second form is the whole concept. `audit` receives the original function object and returns something else, and the name now points at the replacement. Writing one: ``` import functools def audit(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): log.info("calling %s", fn.__name__) result = fn(*args, **kwargs) log.info("%s returned", fn.__name__) return result return wrapper ``` Three details carry the weight. `*args, **kwargs` means the wrapper forwards anything, so one decorator works on functions with any signature. `return result` is easy to omit and turns every decorated function into one that returns None, which is a bug that passes import and fails at runtime. And `functools.wraps` copies the original’s `__name__`, `__doc__` and `__wrapped__` onto the wrapper, without which tracebacks, help output and anything doing introspection all report `wrapper`. A decorator taking arguments needs one more layer, because `@retry(times=3)` calls `retry(times=3)` first and applies the result as the decorator: ``` def retry(times): def decorator(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): for _ in range(times - 1): try: return fn(*args, **kwargs) except TimeoutError: continue return fn(*args, **kwargs) return wrapper return decorator ``` Three nested functions is the standard shape and the part people fumble under pressure. Practise writing it. Two things worth saying. Decorators run at import time, when the `def` is executed, so an expensive decorator body costs you on every import. And methods work unchanged: `self` arrives as the first element of `args`, since by then it is an ordinary positional argument. **Likely follow-ups** - Your decorated function's `__name__` now says `wrapper` — how do you fix that? - How would you write a decorator that takes an argument, like `@retry(times=3)`? - Can you decorate a method the same way, and what happens to `self`? --- ## 18. Read a CSV and write a filtered version back out, using the csv module. Why not just split on commas? *Easy · Common* **Short answer.** The csv module handles quoted fields containing commas, embedded newlines and escaped quotes, all of which naive splitting gets wrong. `DictReader` gives you rows as dicts keyed by the header. Always open files with `newline=""` to avoid corrupted line endings on output. ``` import csv with open("donors.csv", newline="", encoding="utf-8") as src, open("major.csv", "w", newline="", encoding="utf-8") as dst: reader = csv.DictReader(src) writer = csv.DictWriter(dst, fieldnames=reader.fieldnames) writer.writeheader() for row in reader: if float(row["amount"]) >= 50000: writer.writerow(row) ``` The argument for the module over `line.split(",")` is one row: ``` "Sharma, Anjali",50000,"Prefers email and phone" ``` Splitting on commas gives you four fields from a three-field row and puts half the note in the wrong column. The address field with a comma in it, and the free-text field with a newline in it, are both routine in real exports. The csv module handles both. `newline=""` is the detail people omit and then cannot explain. Without it on Windows, the writer’s `rn` gets translated again and every row is followed by a blank line. On the reading side it is what allows quoted fields to contain newlines correctly. Pass it on both. Encoding deserves the same attention. Indian-language names, rupee symbols and smart quotes from Excel all fail on a default cp1252 read. Specify `utf-8`, and `utf-8-sig` when the file came from Excel, which prefixes a byte order mark that otherwise turns your first column name into `ufeffdonor_id`. Two more points. `DictReader` reads the first row as the header; if the file has junk above it, call `next(src)` a few times first, or pass an explicit `fieldnames` list. And everything comes back as a string, so numeric comparison needs an explicit conversion, which is where a stray `"N/A"` in an amount column will raise `ValueError` on row 40,000 of an otherwise clean file. **Likely follow-ups** - Your output file has a blank line between every row on Windows — what caused that? - How would you handle a file that starts with three junk lines before the header? - What does DictReader do to duplicate column names? --- ## 19. You need to remove duplicates from a large collection and then check membership repeatedly. Why a set, and is the O(1) lookup claim really true? *Easy · Common* **Short answer.** A set stores elements in a hash table, so membership testing is average O(1) regardless of size, while the same test on a list is O(n). Deduplication is a single constructor call. The costs are that sets hold only hashable objects and do not preserve a meaningful order. The difference shows up as soon as the collection grows. ``` blocked = set(load_blocked_pincodes()) # 90,000 entries if pincode in blocked: # average O(1) reject(pincode) ``` With a list, `in` walks the elements one by one until it finds a match, so checking one lakh incoming pincodes against a 90,000-entry list is roughly nine billion comparisons. Against a set it is one lakh hash lookups. Set operations replace loops you would otherwise write by hand: ``` served = {"560001", "560034", "560076"} requested = {"560034", "411001"} requested & served # {'560034'} both requested - served # {'411001'} requested but not served requested | served # union requested ^ served # in one but not both ``` Three caveats worth raising before the interviewer does. The O(1) is average case. Hash collisions degrade lookups, and in pathological cases where many elements hash to the same bucket, behaviour approaches linear. For ordinary strings and integers this is not something you will meet, but the honest phrasing is “average O(1)”, not “always O(1)”. Only hashable objects can go in. A set of lists raises `TypeError`; a set of tuples is fine. Order is not preserved in any way you should rely on. `set(names)` deduplicates and returns the items in hash order, which will surprise anyone expecting the original sequence. If order matters, `list(dict.fromkeys(names))` deduplicates while keeping first occurrence, since dicts preserve insertion order from Python 3.7 onwards. **Likely follow-ups** - What happens to the ordering of your data when you pass it through a set? - How would you deduplicate while keeping the first occurrence of each item? - Under what circumstances does that O(1) lookup degrade? --- ## 20. You need to read an .xlsx workbook and write a formatted sheet back. How would you do it? *Medium · Common* **Short answer.** `openpyxl` reads and writes .xlsx workbooks, giving access to sheets, cells and formatting. Iterate with `ws.iter_rows(values_only=True)` for data. By default it returns formulas rather than computed values, so open with `data_only=True` when you want the cached results Excel last calculated. ``` from openpyxl import load_workbook, Workbook wb = load_workbook("q2_targets.xlsx", data_only=True) ws = wb["Region Summary"] for region, target, actual in ws.iter_rows( min_row=2, max_col=3, values_only=True): print(region, target, actual) ``` `data_only=True` is the flag that decides what a formula cell gives you. Without it, a cell containing `=SUM(B2:B10)` returns the string `"=SUM(B2:B10)"`, and your numeric conversion fails on exactly the cells someone worked hardest on. The catch worth knowing: `data_only=True` returns the value Excel cached when it last saved the file. If the workbook was generated by a script and never opened in Excel, there is no cached value and you get `None`. openpyxl does not evaluate formulas itself. Writing with basic formatting: ``` from openpyxl.styles import Font out = Workbook() sheet = out.active sheet.title = "Variance" sheet.append(["Region", "Target", "Actual"]) sheet["A1"].font = Font(bold=True) sheet.column_dimensions["A"].width = 18 out.save("variance.xlsx") ``` Two practical points about scale. openpyxl builds the whole workbook in memory, so a large sheet is slow and heavy. `load_workbook(path, read_only=True)` streams rows instead, at the cost of losing random access and most formatting information. For writing large sheets, `write_only=True` does the equivalent. Format matters when choosing the library. openpyxl handles .xlsx and .xlsm only. An old .xls file needs `xlrd`, which dropped .xlsx support in version 2.0, and that mismatch is behind a lot of confusing error messages. For pure data extraction where formatting is irrelevant, pandas wraps openpyxl and is fewer lines, though it gives you no control over styling. One thing to check before writing back: opening and re-saving a workbook with openpyxl can drop charts, pivot tables and some conditional formatting it does not model. **Likely follow-ups** - A cell shows 4200 in Excel but your script reads a formula string — what happened? - How would you handle a workbook too large to open normally? - What would you use instead for an old .xls file? --- ## 21. Forty daily export files sit in a folder. Walk me through processing all of them in one script. *Easy · Common* **Short answer.** Use `pathlib.Path.glob` to match a pattern and iterate the results. Handle each file inside its own try block so one bad file does not abandon the run, and sort the paths explicitly if order matters, because filesystem order is not guaranteed. ``` from pathlib import Path folder = Path("exports/daily") processed, failed = [], [] for path in sorted(folder.glob("attendance_*.csv")): try: rows = parse_attendance(path) except Exception: log.exception("skipping %s", path.name) failed.append(path.name) continue processed.extend(rows) print(f"{len(processed)} rows, {len(failed)} files failed") ``` Three decisions in that loop are the answer to the question. **Per-file error handling.** Without the try, one truncated file on day 23 aborts the whole run and the first twenty-two files’ work is lost. Catching per file and continuing means you get the results you can get, plus a list of what to look at. The counts at the end matter: a silent skip is as bad as a crash. **Explicit sort.** `glob` returns paths in whatever order the filesystem provides, which is not alphabetical and not creation order. If you are appending to a time series or taking the last file as the latest, sort. And note that string sorting puts `file_10` before `file_2`, so zero-padded names or a key extracting the date are what you actually want. **`pathlib` over string paths.** `path.name`, `path.stem` and `path.suffix` beat slicing strings, and the `/` operator builds paths that work on any OS. Two extras. `folder.rglob("*.csv")` recurses into subdirectories, and `**/*.csv` with `glob` does the same. And glob patterns are case-sensitive on Linux and not on Windows, so a script that finds `.CSV` files on your laptop may find nothing on the server. If the filenames carry the date, parse it from `path.stem` rather than trusting file modification time, which changes when someone copies the folder. **Likely follow-ups** - One file in the middle is corrupt — what does your loop do? - How would you pick up files in subfolders too? - Why might the order the files come back in surprise you? --- ## 22. Walk me through slicing. What does `data[::-1]` do, and does slicing give me the original object or a copy? *Easy · Common* **Short answer.** `sequence[start:stop:step]` takes from start up to but not including stop. Negative indices count from the end, and a negative step walks backwards, so `[::-1]` reverses. Slicing a list returns a new list, so modifying the slice does not affect the original. ``` lanes = ["A", "B", "C", "D", "E", "F"] lanes[1:4] # ['B', 'C', 'D'] stop is exclusive lanes[:3] # ['A', 'B', 'C'] lanes[-2:] # ['E', 'F'] last two lanes[::2] # ['A', 'C', 'E'] every second lanes[::-1] # ['F', 'E', ...] reversed ``` The exclusive stop is what makes `lanes[:3] + lanes[3:]` reconstruct the original with no overlap and no gap, which is the reason for the convention. Slicing produces a new list. That is why `copy = original[:]` is a common idiom for making a copy: ``` toll_lanes = lanes[:] toll_lanes.append("G") print(len(lanes)) # 6 — unaffected ``` The copy is shallow, though. The new list is independent, and the objects inside it are the same objects. With a list of lists, mutating an inner list is visible through both. Slices behave differently from indexes at the boundaries, which is a genuine gotcha: ``` lanes[10] # IndexError lanes[3:99] # ['D', 'E', 'F'] — no error lanes[8:12] # [] — no error ``` An out-of-range slice clamps silently. That is convenient in a paging loop and dangerous when it hides an off-by-one, since a wrong index gives you an empty list rather than an exception. Two practical notes. `[::-1]` reverses a copy while `list.reverse()` reverses in place, so pick by whether you need the original. And on a very large list, slicing copies every element, so taking `big[:-1]` inside a loop is quietly quadratic; `itertools.islice` iterates without copying. **Likely follow-ups** - Your slice copy still shares the inner objects — when does that matter? - Why does an out-of-range slice not raise IndexError when an out-of-range index does? - How would you slice every third element starting from the end? --- ## 23. What's the difference between `is` and `==`? And why does comparing small numbers with `is` seem to work? *Medium · Common* **Short answer.** `==` asks whether two objects have equal value; `is` asks whether they are the same object in memory. They give the same answer for small integers and short strings because CPython caches those objects, which misleads people into using `is` for value comparison. Use `is` only for None and other singletons. ``` a = 256 b = 256 a == b # True a is b # True x = 1000 y = 1000 x == y # True x is y # False ``` Nothing changed about the language between those two blocks. CPython pre-creates integer objects from -5 to 256 and reuses them, so both names point at the identical cached object. At 1000 there is no cache, so two separate objects exist with equal value. Short strings behave similarly through interning, and the rules for when interning happens vary by how the string was created and by CPython version. Both behaviours are implementation details of CPython, not guarantees of Python, and code that depends on them can break on another interpreter or another release. The rule that follows: use `==` for value, and `is` only where identity is genuinely the question. In practice that means the singletons. ``` if config is None: # correct if config == None: # works, but non-idiomatic ``` `is None` is preferred for a real reason beyond style. `==` calls the left operand’s `__eq__`, and a class can define that method to return anything it likes, including True when compared with None. `is` cannot be overridden, so it always answers the actual question. The failure this causes in production is a comparison that passes every test on small values and fails on real data. A validation checking `status_code is 200` works throughout development and breaks when someone loads the code path where the value is parsed from JSON. It reads as a mysterious intermittent bug, and static analysis tools now flag it precisely because it is so common. **Likely follow-ups** - Which one would you use to test for None, and why that one? - Two variables both holding the string "batch" — will `is` return True? - What method does `==` actually call under the hood? --- ## 24. What counts as falsy in Python? And why is `if x:` not the same test as `if x is not None:`? *Easy · Common* **Short answer.** Empty containers, zero, empty strings, None and False are all falsy. So `if x:` is false for an empty list and for zero, not only for None. When you mean "the value was not supplied", test `x is not None`, or a legitimate zero gets treated as missing. The falsy set is short and worth knowing exactly: `None`, `False`, zero of any numeric type, empty sequences and mappings (`""`, `[]`, `()`, `{}`, `set()`), and any object whose class defines `__bool__` or `__len__` to say so. Everything else is truthy. The bug this creates is specific and common: ``` def apply_discount(order_total, override=None): if not override: override = DEFAULT_DISCOUNT return order_total * (1 - override) apply_discount(2000, override=0) # applies DEFAULT, not 0 ``` A caller who explicitly asked for zero discount gets the default instead, because `0` is falsy. Nobody notices until a customer is charged a discount that was meant to be waived. The fix is to test for the thing you actually mean: ``` if override is None: override = DEFAULT_DISCOUNT ``` The same shape appears with empty strings, where a deliberately blank field is replaced by a placeholder, and with empty lists, where “query returned no rows” is confused with “query was never run”. That said, `if items:` is idiomatic and correct when you genuinely mean “is this container non-empty”. Prefer it over `if len(items) > 0:`, which says the same thing more loudly. The judgement is about whether zero and empty are meaningful values in your domain. One related behaviour to have ready. `or` and `and` return one of their operands, not a boolean: ``` name = user_input or "guest" # 'guest' if user_input is falsy ``` Convenient, and it carries the same zero-and-empty-string trap. **Likely follow-ups** - A function returns an empty list on success — how would you write the caller's check? - What does `or` return, and how is that different from returning True? - How would you make your own class falsy when it holds no items? --- ## 25. A text column has values like ` Bengaluru `, `BANGALORE` and `bengaluru.`. Clean it up in Python. *Easy · Common* **Short answer.** Chain `.strip()` to remove surrounding whitespace, `.lower()` or `.upper()` to normalise case, and `.replace()` for known noise characters. Do it once in a helper function rather than repeating the chain, and check the distinct values before and after so you know what you actually fixed. Measure the mess before cleaning it. A frequency count of the raw values tells you which problems exist, and cleaning for imagined problems hides the real ones. ``` from collections import Counter Counter(raw_cities).most_common(20) ``` Then normalise in one place: ``` def clean_city(value): if value is None: return None return (value.strip() .replace(".", "") .replace("-", " ") .title()) clean_city(" bengaluru. ") # 'Bengaluru' ``` Putting it in a function matters more than the chain itself. Written inline in three places, one of them will drift and you get two nearly-identical categories in the output. The characters you cannot see are what make this genuinely hard. `.strip()` removes ordinary whitespace, tabs and newlines, but a non-breaking space pasted from Excel is `xa0` and survives. Two values look identical on screen and group separately. ``` s = "Punexa0" len(s) # 5, not 4 s.strip() # 'Punexa0' — unchanged " ".join(s.split()) # 'Pune' — split() handles it ``` `str.split()` with no arguments splits on any run of whitespace including `xa0` and drops empties, which is why the `" ".join(x.split())` idiom is the reliable way to collapse messy whitespace. `split(" ")` splits only on the literal space character and keeps empty strings between consecutive delimiters. Two further points. `.title()` mangles names like `D'Souza` into `D'Souza` inconsistently and lowercases things like `MG Road`, so it suits city names better than free text. And synonyms are a different problem entirely. No string method turns Bangalore into Bengaluru; that needs a mapping dictionary. Knowing the difference between formatting noise and semantic variation is what an interviewer is listening for. **Likely follow-ups** - After cleaning, two values still look identical but compare as different — what would you check? - How would you map Bangalore and Bengaluru to one value? - What does str.split() with no arguments do differently from split(" ")? --- ## 26. Extract the GST number from a free-text remarks field using regex. Walk me through the pattern. *Medium · Common* **Short answer.** Use `re.search` to find a pattern anywhere in the string, with a raw string for the pattern so backslashes stay literal. Groups in parentheses capture the parts you want. Compile the pattern once outside the loop when applying it to many rows. ``` import re GSTIN = re.compile(r"b(d{2})([A-Z]{5}d{4}[A-Z])(d)([A-Z])([A-Z0-9])b") def extract_gstin(remarks): m = GSTIN.search(remarks.upper()) return m.group(0) if m else None extract_gstin("paid vide 29AABCU9603R1ZM on 4th") # '29AABCU9603R1ZM' ``` Reading the pattern piece by piece: `d{2}` is the state code, `[A-Z]{5}d{4}[A-Z]` is the PAN embedded inside it, then a single digit, a letter, and a final alphanumeric. `b` at both ends anchors to word boundaries so a longer alphanumeric blob does not partially match. The raw string prefix is not decoration. Without `r`, Python interprets `b` as a backspace character before the regex engine ever sees it, and the pattern silently stops matching. Use `r"..."` for every pattern. `search` scans the whole string; `match` anchors at position zero and returns None if the pattern starts anywhere else. Using `match` when you meant `search` produces a function that returns None on almost everything, and it is the single most common regex mistake. Compile once when you are applying the pattern across thousands of rows. `re` does cache compiled patterns internally, but the explicit form is clearer and keeps the pattern next to a comment explaining it. Two habits that matter beyond syntax. Test against the failures, not the successes. A pattern validated on five clean samples and applied to 90,000 rows will miss a category you never imagined, and the only way to find out is to look at the rows that returned None: ``` misses = [r for r in remarks if extract_gstin(r) is None] ``` And know when to stop. Regex is right for extracting a well-defined token out of unstructured text. It is wrong for parsing HTML, JSON or anything with nesting, where a real parser exists and the regex will fail on the case you did not think of. **Likely follow-ups** - What's the difference between search and match, and which did you want? - Your pattern works on the sample and misses 40 real rows — how would you find out why? - When would you stop using regex and parse it properly? --- ## 27. Count how often each value appears, and group items into buckets, without pandas. What's in collections? *Medium · Common* **Short answer.** `Counter` counts hashable items in one pass and gives you `.most_common()` for free. `defaultdict` supplies a default value for any missing key, so you can append or increment without checking first. Both live in `collections` and replace loops people otherwise write by hand. ``` from collections import Counter, defaultdict complaints = ["water", "power", "water", "roads", "water"] tally = Counter(complaints) tally["water"] # 3 tally["sewage"] # 0, not KeyError tally.most_common(2) # [('water', 3), ('power', 1)] ``` `Counter` accepts any iterable of hashable items and returns zero for missing keys rather than raising, which removes the guard clause you would otherwise write. `defaultdict` handles the accumulate-into-a-container pattern: ``` by_ward = defaultdict(list) for ward, issue in records: by_ward[ward].append(issue) ``` Without it, every iteration needs `if ward not in by_ward: by_ward[ward] = []` first. You pass the factory itself, not a value: `defaultdict(list)`, not `defaultdict([])`. The behaviour that catches people is that reading a missing key from a `defaultdict` creates it: ``` d = defaultdict(int) if d["unseen"] == 0: # this INSERTS 'unseen' ... len(d) # 1 ``` A membership test written as a lookup silently grows the dict, and in a long-running process over a large key space that is a real memory leak. Use `key in d` to check, or `.get()`, neither of which triggers the factory. Counters support arithmetic, which is occasionally exactly what you want: ``` Counter(this_month) - Counter(last_month) ``` Subtraction drops zero and negative counts entirely, so the result shows only categories that increased. Use `.subtract()` if you need the negatives kept. Two more from the same module worth naming: `deque` for a queue with cheap appends and pops at both ends, and `OrderedDict`, which is largely superseded now that plain dicts preserve insertion order from Python 3.7, though it still has `move_to_end` and order-sensitive equality. **Likely follow-ups** - What happens when you read a key that isn't in a defaultdict? - How would you subtract one Counter from another, and what happens to negatives? - When would a plain dict with .get() be the better choice? --- ## 28. Python can't find a variable inside a function. Where does it look, and in what order? *Medium · Common* **Short answer.** Python resolves a name through four scopes in order: Local, Enclosing function, Global (module level) and Builtins. The first match wins. Assigning to a name anywhere in a function makes it local for that whole function unless you declare it `global` or `nonlocal`. LEGB is the lookup order, and the part that catches people is not the lookup but the assignment rule. ``` tax_rate = 0.18 def price_with_tax(base): print(tax_rate) # UnboundLocalError tax_rate = 0.05 return base * (1 + tax_rate) ``` The error appears on a line that only reads the variable. Python scans the whole function body at compile time, sees an assignment to `tax_rate`, and marks it local for the entire function, including lines above the assignment. At the moment of the `print` it is local and not yet bound. `global` says the name refers to the module-level variable, and `nonlocal` says it refers to the nearest enclosing function’s variable: ``` def make_counter(): count = 0 def record(): nonlocal count # without this: UnboundLocalError count += 1 return count return record ``` `count += 1` is an assignment, so the same rule applies inside the inner function. `nonlocal` points it back out one level. `global` would not work here, since `count` is not at module level. Two things worth saying in an interview. Only functions, classes, modules and comprehensions create scopes. `for`, `if` and `with` do not, so a loop variable is still visible after the loop ends, which surprises people coming from C-family languages. And `global` in production code is usually a smell. Module-level mutable state makes functions untestable and behaviour order-dependent, and in a multi-threaded worker it becomes a race condition. Pass the value in and return the result instead. **Likely follow-ups** - You assign to a name inside a function that also exists at module level — what does Python decide? - When would you reach for `nonlocal` rather than `global`? - Does a for loop or an if block create a new scope? --- ## 29. Group a list of records by a field, without pandas. Two ways, and tell me the difference. *Medium · Common* **Short answer.** A dict of lists built in a single loop works on unsorted input and is what you want most of the time. `itertools.groupby` only groups consecutive equal keys, so the input must be sorted by the same key first, but it streams and never holds everything in memory. The dictionary approach handles any order: ``` from collections import defaultdict groups = defaultdict(list) for trip in trips: groups[trip["depot"]].append(trip) for depot, items in groups.items(): print(depot, len(items)) ``` One pass, no sorting, and the result is a real mapping you can index into afterwards. `itertools.groupby` looks similar and behaves differently: ``` from itertools import groupby from operator import itemgetter trips.sort(key=itemgetter("depot")) for depot, items in groupby(trips, key=itemgetter("depot")): print(depot, len(list(items))) ``` It walks the sequence and starts a new group whenever the key changes. On unsorted input, `["A", "B", "A"]` gives you three groups, two of them for A, which is the bug people hit. It is not broken; it is behaving as documented, and the documentation says sort first. Two more sharp edges with `groupby`. The group it yields is a lazy iterator sharing the underlying sequence, so it is consumed as soon as you advance to the next group. Storing the groups without materialising them gives you empty results: ``` saved = {k: g for k, g in groupby(trips, key=...)} # all empty saved = {k: list(g) for k, g in groupby(trips, key=...)} # fine ``` And the key function must be the same one used for sorting, or the grouping is meaningless. Choose by memory. The dict approach holds every record, which is fine for a few lakh rows and not for a 30 GB file. `groupby` over a sorted stream processes one group at a time and never materialises the whole thing, which is why it exists. Grouping by two fields is a tuple key in both approaches: `key=itemgetter("depot", "shift")`. **Likely follow-ups** - Your itertools.groupby returns one group per record — what went wrong? - How would you group by two fields at once? - Which approach would you pick for a file too big to hold in memory? --- ## 30. Build me a list of functions in a loop, each one multiplying by its loop index. Then tell me why they all do the same thing. *Hard · Common* **Short answer.** A closure captures the variable, not the value it had at definition time. Every function built in the loop refers to the same loop variable, which holds its final value once the loop ends. Bind the value with a default argument or `functools.partial` to fix it. ``` multipliers = [] for factor in [2, 5, 10]: multipliers.append(lambda x: x * factor) [m(3) for m in multipliers] # [30, 30, 30] ← not [6, 15, 30] ``` Nothing is captured at the moment each lambda is created. Each one holds a reference to the enclosing variable `factor`, and looks it up when called. By the time you call them, the loop has finished and `factor` is 10. This is late binding, and it is the correct behaviour for closures generally. A closure that captured values would be useless for the counter pattern, where the whole point is that the inner function sees updates to the outer variable. Two ways to bind the value at definition time. A default argument, which is evaluated once when the lambda is defined: ``` for factor in [2, 5, 10]: multipliers.append(lambda x, f=factor: x * f) ``` Or `functools.partial`, which stores the argument explicitly: ``` from functools import partial from operator import mul multipliers = [partial(mul, f) for f in [2, 5, 10]] ``` Where this bites in real work is callbacks. Registering a handler per queue inside a loop, or building a list of retry functions each bound to a different endpoint, and every one of them ends up pointing at the last item. The symptom is that all your workers process the same partition, which looks like a configuration problem rather than a language one. A comprehension has exactly the same issue, since it also builds the functions before any of them run. And each closure holds its variables in a cell object, visible as `fn.__closure__`, which is worth knowing if an interviewer pushes on the mechanism. **Likely follow-ups** - Does the same problem show up with a list comprehension building lambdas? - How would `functools.partial` solve this, and is it clearer? - What does a closure actually hold — the value or the variable? --- ## 31. When would you use `map` and `filter` with a lambda, and when would you write a comprehension instead? *Easy · Common* **Short answer.** A comprehension is usually clearer for transforming or filtering a collection, and it reads left to right. `map` and `filter` earn their place when you already have a named function to pass, since `map(int, values)` needs no lambda at all. Avoid `map` with a lambda. Compare the same operation three ways on a list of ticket prices: ``` prices = ["450", "1200", "300"] list(map(int, prices)) # clean list(map(lambda p: int(p) * 1.05, prices)) [int(p) * 1.05 for p in prices] # clearer ``` The first is good code. `map` with an existing callable is compact and says exactly what it does. The second is where readability drops: a lambda inside a map means reading right to left through two layers to work out what happens to each item. The comprehension states the transformation first and the source second, which is how people read. Filtering follows the same pattern: ``` list(filter(lambda p: int(p) > 400, prices)) [p for p in prices if int(p) > 400] ``` Combining both in one comprehension is natural and needs no nesting: ``` [int(p) * 1.05 for p in prices if int(p) > 400] ``` Two limits on lambdas that are worth stating. A lambda holds one expression, so no statements, no assignments, no try blocks. A conditional expression is allowed because it is an expression: `lambda p: "high" if p > 1000 else "low"`. And a lambda assigned to a name gains nothing over `def`, while losing a useful name in tracebacks. `charge = lambda x: x * 1.05` shows up as `` when it raises, and PEP 8 recommends `def` there. One behavioural difference worth knowing: in Python 3, `map` and `filter` return lazy iterators, not lists. They consume nothing until iterated and are exhausted after one pass, so a `map` object you iterate twice gives you results the first time and nothing the second. **Likely follow-ups** - `map` returns a lazy object in Python 3 — when does that matter? - Can a lambda contain an if statement, or an assignment? - How would you write a map and filter together as one comprehension? --- ## 32. Sort these records by state ascending and revenue descending, in one go. How? *Medium · Common* **Short answer.** A tuple key sorts by each element in turn, so `key=lambda r: (r["state"], -r["revenue"])` gives ascending state and descending revenue. Negating works only for numbers; for mixed directions on text fields, sort twice and rely on the sort being stable. ``` from operator import itemgetter rows = sorted(outlets, key=lambda r: (r["state"], -r["revenue"])) ``` The tuple is compared element by element, so `state` decides the order and `revenue` only breaks ties within a state. The minus sign flips one element without flipping the whole sort, which `reverse=True` cannot do. Negation only works on numbers. For a text field that needs to run the other way, sort twice, least significant field first: ``` rows = sorted(outlets, key=itemgetter("manager"), reverse=True) rows = sorted(rows, key=itemgetter("state")) ``` Python’s sort is stable, so the second pass preserves the manager ordering inside each state. Swap the two lines and the result is wrong. `itemgetter` is faster and clearer than a lambda for straight field access, and it takes several keys at once: `itemgetter("state", "revenue")` returns a tuple. It has no way to negate, which is why the lambda comes back for mixed directions. Two failures worth having ready. A missing key raises `KeyError` from inside the sort, on some unpredictable element. `key=lambda r: (r["state"], -r.get("revenue", 0))` handles it, and the default you choose decides where those records land. A `None` in the key raises `TypeError: '<' not supported between instances of 'NoneType' and 'str'`, because Python 3 refuses to order values of different types. This is the one that breaks a script in production after months, when one row finally arrives with a null. Push the Nones to one end explicitly: ``` key=lambda r: (r["state"] is None, r["state"]) ``` `False` sorts before `True`, so present values come first and the None rows collect at the end without ever being compared to a string. **Likely follow-ups** - What if the field you're sorting on is missing from some records? - Why does mixing a None into the sort key raise TypeError? - How would you sort by the length of a field rather than its value? --- ## 33. The same member appears several times in the file, keyed on member ID plus visit date. Deduplicate it in plain Python. *Medium · Common* **Short answer.** Build a tuple from the key fields, track which ones you have seen in a set, and keep the first occurrence of each. A set gives O(1) membership so the whole pass is linear. Decide deliberately whether first or last wins, because that changes the data. ``` seen = set() unique = [] for row in visits: key = (row["member_id"], row["visit_date"]) if key in seen: continue seen.add(key) unique.append(row) ``` The tuple is the composite key. Tuples are hashable, so they go into a set directly, and membership testing stays O(1) as the set grows. Doing the same check against a list of already-kept rows would be O(n) per row and quadratic overall, which is fine at a thousand rows and painful at five lakh. Keeping the last occurrence instead is a dict comprehension, since later assignments overwrite earlier ones: ``` latest = {(r["member_id"], r["visit_date"]): r for r in visits} deduped = list(latest.values()) ``` Dicts preserve insertion order from Python 3.7, so the output order follows first appearance even though the values are the last ones. The decision that matters is which copy survives. If the rows are byte-identical it makes no difference. If they differ, one of them is a correction and the other is stale, and picking arbitrarily means silently choosing wrong records. Sort by an update timestamp first and take the last, or raise if the differing rows cannot be reconciled. Two things worth adding. Count what you dropped and log it: ``` print(f"{len(visits) - len(unique)} duplicates removed") ``` A dedup step that silently discards 30% of a file is worth knowing about before the numbers reach a report. And a `None` inside the key is fine for hashing, but two rows with a missing date both key to the same tuple and collapse into one, which may not be what you meant. **Likely follow-ups** - Two rows share the key but differ in the other columns — which one do you keep? - How would you count how many duplicates you removed, and why bother? - What if the key includes a field that's sometimes None? --- ## 34. Before you process a delivered file, what do you check? Write the validation. *Medium · Common* **Short answer.** Check the structure first, then the rows: expected columns present, row count plausible, required fields non-empty, numbers parseable, dates in range. Collect all failures with row numbers rather than raising on the first, so one run tells you everything wrong with the file. Structural checks come first, because a missing column makes every row check meaningless. ``` REQUIRED = {"scheme_code", "nav_date", "nav_value"} missing = REQUIRED - set(reader.fieldnames) if missing: raise ValueError(f"file missing columns: {sorted(missing)}") ``` Then validate rows, accumulating problems rather than aborting: ``` errors, clean = [], [] for n, row in enumerate(reader, start=2): # header is line 1 problems = [] if not row["scheme_code"].strip(): problems.append("blank scheme_code") try: row["nav_value"] = float(row["nav_value"]) except ValueError: problems.append(f"bad nav_value {row['nav_value']!r}") if problems: errors.append((n, problems)) else: clean.append(row) ``` `start=2` matters more than it looks. Reporting “row 4127 failed” against a spreadsheet whose line numbers are off by one wastes someone’s afternoon. Collecting rather than raising on the first failure is the design decision an interviewer listens for. Raising immediately means fixing one problem, rerunning, discovering the next, and repeating twenty times. One run producing a list of every bad row lets whoever owns the file fix it in one pass. The check people skip is the volume check. A file that parses cleanly and contains 400 rows where yesterday had 90,000 is a broken upstream export, and every row-level validation passes. Compare against recent runs and fail loudly on a large deviation. Decide the failure policy explicitly, and say so in the interview. Rejecting the whole file suits financial data where partial loads corrupt totals. Loading the clean rows and quarantining the rest suits event data where completeness matters less than freshness. Write the rejected rows to a file with their reasons either way, so nothing disappears without a trace. **Likely follow-ups** - Do you stop the run on the first bad row or collect them all? - How would you handle a file that's valid but only has a tenth of the usual rows? - Where would you write the rejected rows? --- ## 35. What's the difference between an iterable and an iterator? Which methods does each one need? *Medium · Common* **Short answer.** An iterable defines `__iter__` and can produce an iterator. An iterator defines both `__iter__` and `__next__`, holds the position, and raises StopIteration when exhausted. A list is an iterable that hands out a fresh iterator each time, which is why you can loop over it repeatedly. A `for` loop is doing three things behind the scenes: call `iter()` on the object, call `next()` repeatedly, and stop when `StopIteration` is raised. ``` racks = ["R1", "R2"] it = iter(racks) # list -> list_iterator next(it) # 'R1' next(it) # 'R2' next(it) # StopIteration ``` The list itself has no position. Each `iter()` call creates a new iterator with its own cursor, which is why two nested loops over the same list work independently. An iterator returns itself from `__iter__`, which is what lets you pass one to a `for` loop directly. It also means iterating it consumes it. A file object is its own iterator, so a second loop over an already-read file yields nothing at all, with no error. That is the same silent-empty failure a consumed generator produces. Writing one by hand: ``` class Countdown: def __init__(self, start): self.n = start def __iter__(self): return self def __next__(self): if self.n **Likely follow-ups** - Why can you loop over a list twice but not over a file object twice? - What does `iter()` do when a class only defines `__getitem__`? - Where does StopIteration go in a for loop? --- ## 36. You need the index alongside each item, and you need to walk two lists together. Show me both, and tell me what zip does when the lists differ in length. *Easy · Common* **Short answer.** `enumerate(seq)` yields index-item pairs and takes a `start` argument. `zip(a, b)` yields tuples pairing corresponding elements and stops at the shortest input, silently dropping the tail of the longer one. Pass `strict=True` in Python 3.10 or later to make a length mismatch raise. ``` crops = ["wheat", "gram", "mustard"] for i, crop in enumerate(crops, start=1): print(f"Plot {i}: {crop}") ``` The `start` argument saves the `i + 1` that otherwise appears everywhere in report-numbering code. Zip pairs elements positionally: ``` plots = ["P1", "P2", "P3"] yields = [22.5, 19.1] list(zip(plots, yields)) # [('P1', 22.5), ('P2', 19.1)] ← P3 vanishes ``` No error, no warning. The third plot is gone from the output, and if this is feeding a report, the total is short by one row and nothing indicates why. It is a genuinely common source of quietly wrong results when two sequences come from different sources and one is missing a record. Two ways to guard against it. From Python 3.10, `zip(plots, yields, strict=True)` raises `ValueError` on a mismatch, which is what you want whenever equal length is an invariant. Before 3.10, assert the lengths, or use `itertools.zip_longest` when the shorter sequence should be padded: ``` from itertools import zip_longest list(zip_longest(plots, yields, fillvalue=None)) ``` Combining both is idiomatic and reads well: ``` for i, (plot, y) in enumerate(zip(plots, yields), start=1): ... ``` Note the parentheses around the inner pair. Without them the unpacking fails. Two more things worth knowing. `zip(*rows)` transposes, turning rows into columns, and it is the standard way to unzip a list of pairs back into two tuples. And in Python 3, `zip` and `enumerate` return lazy iterators, so wrap in `list()` if you need to look at the result more than once. **Likely follow-ups** - How would you make zip raise instead of truncating? - What does `zip(*rows)` do? - Can you unzip back into separate sequences, and what do you get? --- ## 37. Format these numbers for a report: a rupee amount with thousands separators, a percentage to one decimal, and a right-aligned column. *Easy · Common* **Short answer.** Put a format spec after a colon inside the braces: `{value:,.2f}` gives two decimals with thousands separators, `{rate:.1%}` multiplies by 100 and appends a percent sign, and `{name:>12}` right-aligns in a twelve-character field. The expression inside the braces can be any Python expression. ``` revenue = 4823950.5 margin = 0.1834 region = "West" f"{revenue:,.2f}" # '4,823,950.50' f"{margin:.1%}" # '18.3%' f"{region:>12}" # ' West' f"{region:15,.0f}" # ' 4,823,950' ``` The `%` spec multiplies by 100 for you, so pass the fraction, not the already-multiplied number. Passing 18.34 with `.1%` gives 1834.0%, and it is a common slip. Combining alignment with grouping is what makes a plain-text table line up: ``` for region, amount in rows: print(f"{region:14,.0f}") ``` Two features worth knowing. The `=` suffix prints the expression alongside its value, which is quicker than typing the label during debugging: ``` count = 412 f"{count=}" # 'count=412' ``` That needs Python 3.8 or later. And `!r` applies `repr()` instead of `str()`, keeping quotes around strings so a trailing space in a value is visible. The one thing f-strings do not give you is Indian digit grouping. `{:,}` produces 4,823,950 rather than 48,23,950. There is no format code for the lakh-crore pattern, so you either write a small helper or use the `babel` library with the `en_IN` locale. Where f-strings are the wrong choice: logging calls. Write `log.info("loaded %s rows", n)` rather than an f-string, so the formatting only happens if that log level is enabled, and so log aggregators can group messages by template. And never build SQL with an f-string; that is parameter binding’s job. **Likely follow-ups** - How would you print the Indian lakh-crore grouping rather than thousands? - What does the `=` sign inside an f-string do? - When would you not use an f-string? --- ## 38. Make me a chart of monthly figures with matplotlib, saved to a file. What trips people up? *Medium · Common* **Short answer.** Use the object-oriented interface: `fig, ax = plt.subplots()`, draw on the axes, then `fig.savefig(path)`. Call `plt.close(fig)` when generating charts in a loop, or figures accumulate in memory and later plots draw on top of earlier ones. ``` import matplotlib matplotlib.use("Agg") # no display needed import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(9, 5)) ax.bar(months, footfall, color="#2b6cb0") ax.set_title("Monthly footfall, FY 2026-27") ax.set_ylabel("Visitors") ax.tick_params(axis="x", rotation=45) fig.tight_layout() fig.savefig("footfall.png", dpi=150) plt.close(fig) ``` Two interfaces exist and mixing them causes confusion. The `plt.plot()` style draws on whatever figure is currently active, which is convenient in a notebook and fragile in a script. The object-oriented style names the figure and axes explicitly, so a function generating charts has no hidden global state. `plt.close(fig)` is the line people omit. Without it, every figure stays in memory, and a loop producing sixty charts either warns about too many open figures or, worse, keeps drawing onto the active one so the last file contains all sixty series overlaid. The `Agg` backend matters on a server. Without a display, matplotlib may fail on import or block waiting for a GUI, and the error message is not obvious. Setting the backend before importing pyplot fixes it, and it must be in that order. Three formatting points that make the difference between a draft chart and one you can send. `tight_layout()` stops rotated labels being cut off in the saved file. This is the usual reason a chart looks fine on screen and clipped in the PNG. `dpi` controls resolution. The default 100 looks soft in a slide deck; 150 to 200 is a better default for anything shared. Formatting the y-axis for large rupee values needs a formatter, since matplotlib will otherwise print scientific notation: ``` from matplotlib.ticker import FuncFormatter ax.yaxis.set_major_formatter( FuncFormatter(lambda v, _: f"{v:,.0f}") ) ``` **Likely follow-ups** - Your script produces twelve charts and the last one has everything on it — what happened? - Why does the chart look different when saved than on screen? - When would you reach for something other than matplotlib? --- ## 39. What's actually wrong with writing `except:` with nothing after it? *Medium · Common* **Short answer.** A bare `except:` catches everything derived from BaseException, including KeyboardInterrupt and SystemExit, so your process cannot be interrupted or shut down cleanly. It also swallows genuine bugs such as typos raising NameError, turning them into whatever your handler pretends went wrong. ``` while True: try: job = queue.pop() proces_job(job) # typo except: log.warning("job failed, retrying") ``` Two things are broken here and neither is visible. The typo raises `NameError` on every single iteration, and the handler reports it as a job failure, so the log fills with retry messages about a queue that is working perfectly. And pressing Ctrl-C raises `KeyboardInterrupt`, which the bare except catches, so the loop keeps running and the only way out is to kill the process. The exception hierarchy is why. `KeyboardInterrupt`, `SystemExit` and `GeneratorExit` inherit from `BaseException` directly, not from `Exception`, precisely so that ordinary handlers do not intercept them. A bare `except:` sits at the `BaseException` level and undoes that design. ``` except Exception: # excludes Ctrl-C and sys.exit except (TimeoutError, ConnectionError): # better still ``` Catch what you can actually do something about. If the handler’s response is “retry the network call”, then catch the network errors, and let a `KeyError` from malformed data propagate to somewhere that knows what to do with it. There are two legitimate uses of a broad catch. A top-level supervisor loop in a long-running worker, where the job must not die because one item failed, and a plugin boundary where you cannot know what user code raises. In both cases the handler must log the full traceback and re-raise or record the failure: ``` except Exception: log.exception("job %s failed", job.id) # includes traceback failed.append(job.id) ``` `log.exception` rather than `log.warning` is the detail that matters. Without the traceback you have a message and no way to find the line. Interviewers ask this because a swallowed exception is the hardest class of bug to debug: the program reports success while doing nothing. **Likely follow-ups** - So what's the narrowest handler you'd write for a network call that might time out? - If you must catch broadly, what do you do inside the handler? - What's the difference between `except Exception` and `except BaseException`? --- ## 40. Your script runs for an hour overnight. Why not just use print to track progress? *Easy · Common* **Short answer.** `logging` gives you timestamps, severity levels, and the ability to route output to a file without touching your code. `print` gives you a line on stdout with no time, no level and no way to turn it down. For a job that runs unattended, the timestamp alone is the difference. ``` import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", handlers=[ logging.FileHandler("reconcile.log"), logging.StreamHandler(), ], ) log = logging.getLogger(__name__) log.info("starting reconciliation for %s", month) log.warning("branch %s missing, skipping", code) log.exception("failed to write output") # includes traceback ``` When the job fails at 3 AM, print output tells you it processed 4,000 records. Logging tells you it processed 4,000 records by 02:14:31, then stalled for forty minutes before the connection dropped. The timestamps are the diagnosis. Levels let you leave detail in the code and turn it on only when needed. Debug lines cost nothing at INFO level, so you do not delete them after fixing a bug and then rewrite them next month. `log.exception` inside an `except` block records the full traceback automatically. That single call is the difference between a log saying “failed to write output” and one telling you which line and which underlying error. Use `%s` placeholders rather than an f-string in the call. The formatting is deferred until the record is actually emitted, so a debug line inside a hot loop costs nothing when the level is INFO. It also lets log aggregators group messages by template rather than treating every interpolated variant as unique. Two habits for a long job. Log progress at intervals, not per iteration: a line every thousand records tells you the rate without producing fifty thousand lines nobody will read. And log the counts at the end, since a job reporting “wrote 0 rows” that exits successfully is otherwise indistinguishable from one that worked. `RotatingFileHandler` keeps a nightly job’s log from growing without bound. **Likely follow-ups** - How would you get warnings to a file but keep info messages on screen? - Why does log.info("done %s", n) beat an f-string in a log call? - What would you log for a loop running fifty thousand times? --- ## 41. You've been handed a 30 GB CSV export and 8 GB of RAM. How do you process it? *Medium · Common* **Short answer.** Iterate the file object directly, which yields one line at a time and holds only that line in memory. Avoid `.read()` and `.readlines()`, both of which pull the whole file in. For fixed-size chunks of binary data, read in a loop with an explicit chunk size. ``` # loads 30 GB into RAM, then dies rows = open("exports/claims.csv").readlines() # streams, one line at a time with open("exports/claims.csv") as f: header = next(f) for line in f: process(line) ``` A file object is its own iterator, so the `for` loop reads a buffered chunk from disk and hands you one line per iteration. Memory stays flat regardless of file size. That `next(f)` for the header works because the same iterator is consumed by both. For CSV specifically, `csv.reader` wraps the same streaming behaviour and handles quoted fields containing newlines, which naive line splitting gets wrong. Reach for it rather than `line.split(",")` on anything that came out of a spreadsheet. Binary or fixed-size reading needs an explicit loop: ``` with open(path, "rb") as f: while chunk := f.read(1024 * 1024): digest.update(chunk) ``` The walrus operator needs Python 3.8 or later; before that it is a `while True` with a break. Two things that decide whether streaming actually works for your problem. Aggregates are fine if they are incremental. Running totals, counts, min and max all work in one pass with constant memory. A median or an exact distinct count needs the whole dataset, so either accept the memory, spill to a temporary file, or use an approximate algorithm. The failure mode people hit anyway is accumulating inside the loop. Streaming the file and appending every parsed row to a list uses the same memory as reading it all at once, just more slowly. Write results out as you go, or aggregate into something bounded. If the work per line is genuinely CPU-heavy, `multiprocessing` over line ranges helps; if it is I/O bound, it mostly will not, since one disk is the constraint. **Likely follow-ups** - What if you need a total that depends on the whole file? - How would you handle a record that spans multiple lines? - Where would you parallelise this, and what stops you? --- ## 42. Your script needs a database password. Where does it go? *Medium · Common* **Short answer.** Not in the script. Read credentials from environment variables, or from a file that is excluded from version control, and fail with a clear message when they are absent. Anything committed to a repository is effectively public, and rotating it later is the only remedy. ``` import os def get_setting(name): value = os.environ.get(name) if value is None: raise RuntimeError( f"{name} is not set; see README for setup" ) return value DB_PASSWORD = get_setting("WAREHOUSE_PASSWORD") ``` Raising with a named variable beats `os.environ["X"]`, whose `KeyError` says nothing about what to do next. For local development, a `.env` file loaded by `python-dotenv` keeps the values out of your shell profile: ``` from dotenv import load_dotenv load_dotenv() # reads .env into os.environ ``` Add `.env` to `.gitignore` on the same commit that creates it, not later. And commit a `.env.example` listing the variable names with dummy values, so a new joiner knows what the script expects without anyone sending credentials over chat. The failure worth describing is what happens after a leak. Deleting the line and committing the fix does nothing: the value is still in the repository history, retrievable by anyone with clone access, and if the repo was ever public it has likely been scraped within minutes by automated tools that watch for exactly this. The only real remedy is rotating the credential. Rewriting history is a separate and much more painful exercise. Notebooks deserve a specific warning, because outputs are saved. A cell that prints a connection string embeds it in the `.ipynb` file even after you clear the variable, and it ends up in the diff. Two scaling notes. In a container, environment variables come from the orchestration layer rather than a file, so the same code works unchanged. And at team scale, a secrets manager gives you rotation and access control that environment variables alone do not, which is worth naming as the direction of travel. **Likely follow-ups** - A credential got committed last month — is deleting the line enough? - How is this different when the script runs in a container? - What would you put in the repo so a new joiner knows what to set? --- ## 43. What's the difference between `__str__` and `__repr__`? Which one shows up when you print a list of your objects? *Medium · Common* **Short answer.** `__repr__` is for developers and should be unambiguous; `__str__` is for end users and should be readable. Printing a container calls `__repr__` on each element, not `__str__`, so define `__repr__` first. `__str__` falls back to `__repr__` when it is missing. ``` class Route: def __init__(self, code, stops): self.code = code self.stops = stops def __repr__(self): return f"Route({self.code!r}, stops={self.stops})" def __str__(self): return f"{self.code} ({self.stops} stops)" r = Route("KA-17", 12) print(r) # KA-17 (12 stops) print([r]) # [Route('KA-17', stops=12)] r # Route('KA-17', stops=12) in the REPL ``` The list case is the one people get wrong. Containers always call `__repr__` on their elements, so a class with only `__str__` defined prints beautifully on its own and shows `<__main__.Route object at 0x7f...>` inside a list, in a debugger, and in a log line that formats a collection. If you write only one of the two, write `__repr__`. The `!r` in the f-string calls `repr()` on the field, which keeps quotes around strings so the output stays unambiguous. `__eq__` changes comparison from identity to whatever you define: ``` def __eq__(self, other): if not isinstance(other, Route): return NotImplemented return self.code == other.code ``` Returning `NotImplemented` for unknown types lets Python try the reflected operation instead of forcing a wrong answer. The consequence people forget: defining `__eq__` sets `__hash__` to None, so instances become unhashable and cannot go in a set or be used as dict keys. If you need both, define `__hash__` over the same fields, and only when those fields are immutable. A hashable object whose hash changes after insertion becomes unfindable in the dict it is sitting in. `@dataclass` generates `__init__`, `__repr__` and `__eq__` from annotations, and `frozen=True` adds a `__hash__`. For plain data-carrying classes it is usually the better starting point. **Likely follow-ups** - If you define `__eq__`, what else do you have to define and why? - What would you use `__hash__` for after that? - How does a dataclass change this whole picture? --- ## 44. When would you tell someone to stop working in a notebook and move the code into a .py file? *Easy · Common* **Short answer.** Move it out when the work repeats, when someone else has to run it, or when it becomes something you rely on being correct. Notebooks are excellent for exploration and poor as production artefacts, mainly because out-of-order execution means the visible output may not match the code. The defining problem is hidden state. Cells can run in any order, and a variable defined in a cell you later edited or deleted still exists in memory. So a notebook can display perfectly correct output that its own code cannot reproduce. The test is one keystroke: restart the kernel and run all cells. If it fails, the notebook was never a description of how those results were produced, and anyone who tries to rerun it next quarter is stuck. Four signals that it is time to move. **It repeats.** Anything rerun every Monday belongs in a script that can be scheduled. Reopening a notebook and clicking through cells is not automation. **Someone else runs it.** A colleague needs to know which cells to run and in what order, which is documentation you will not write. **Correctness matters.** Notebooks are awkward to test and awkward to review, since the `.ipynb` diff is JSON containing outputs and execution counts alongside the code. **The logic is being reused.** Copying a cleaning function between three notebooks means three versions of it, drifting apart. Where notebooks stay the right tool: genuine exploration, anything where you want the chart next to the code that made it, and communicating an analysis to a person rather than a scheduler. The hybrid is usually the answer, and it is what to propose in an interview. Move the functions into a `.py` module and import them into the notebook: ``` from pipeline.clean import normalise_skus ``` The logic is now testable, reviewable and reusable, and the notebook stays a thin exploratory surface over it. `nbconvert` and `papermill` exist for running notebooks as jobs, and they help with mechanics without fixing the reproducibility problem. **Likely follow-ups** - Restart-and-run-all fails but every cell worked individually — what does that tell you? - How would you keep using a notebook while the logic lives elsewhere? - What makes notebooks awkward in code review? --- ## 45. Why does everyone write `if __name__ == "__main__":` at the bottom of a script? *Easy · Common* **Short answer.** Python sets `__name__` to `"__main__"` in the file being run directly, and to the module's name when it is imported. The guard runs your script logic only in the first case, so importing the module for its functions does not execute the whole job as a side effect. ``` # ingest.py def load_readings(path): ... def main(): load_readings("data/august.csv") publish_summary() if __name__ == "__main__": main() ``` Run `python ingest.py` and `__name__` is `"__main__"`, so `main()` fires. Write `from ingest import load_readings` in a notebook or another module, and `__name__` is `"ingest"`, so the guard is false and only the definitions are created. Without it, every import runs the entire job. Someone imports one helper function and the full August ingestion kicks off, writing to the production summary table. It is not a subtle failure, and it is one people meet exactly once. Testing is the everyday version of the same problem. A test module importing your script to test one function triggers the whole pipeline before any test runs, so the suite is slow and has side effects nobody asked for. Three related points. Anything at module level runs on import, guard or not. A database connection or an expensive model load written at the top of the file executes for every importer, so those belong inside `main()` or a function that is called deliberately. `multiprocessing` on Windows and on macOS with the spawn start method re-imports the main module in each child process. Without the guard, each child runs your script again, which spawns more children, and the process count grows until something gives. Keep `main()` thin and the guard thinner. Argument parsing goes inside `main()` rather than at module level, so importing the module does not attempt to read `sys.argv` from whatever process happens to be running. **Likely follow-ups** - What breaks specifically when multiprocessing imports your module on Windows? - Is the guard needed in a file that will only ever be imported? - Where would you put the argument parsing relative to the guard? --- ## 46. The API returns 100 records at a time and there are thousands. How do you get all of them? *Medium · Occasional* **Short answer.** Loop until the API says there is no more, following whichever mechanism it uses: a next-page URL, a cursor token, or offset and limit parameters. Always add a safety bound on iterations, because a bug in the termination condition otherwise loops forever against a live server. Three common styles, and you have to read the docs to know which one you are dealing with. Cursor-based is the most robust and the easiest to loop: ``` def fetch_all(session, url): cursor, pages = None, 0 while pages < 500: # safety bound params = {"limit": 100} if cursor: params["cursor"] = cursor r = session.get(url, params=params, timeout=(5, 30)) r.raise_for_status() body = r.json() yield from body["items"] cursor = body.get("next_cursor") if not cursor: return pages += 1 time.sleep(0.2) # be polite raise RuntimeError("pagination did not terminate") ``` Offset-based uses `?offset=200&limit=100` and stops when a page comes back shorter than the limit or empty. Link-based returns a full URL for the next page in the body or in a `Link` header, and you follow it until it is absent. The safety bound is the part experience teaches you. A termination condition that never becomes true, because the API returns an empty string rather than null for the last cursor, turns into an infinite loop hammering a production endpoint. Bounding the iterations converts a runaway job into an error you can read. Offset pagination has a correctness problem worth mentioning. If records are being inserted while you page, rows shift between pages, so you can see the same record twice and miss another entirely. Cursor pagination is immune to this, which is why APIs are moving to it. Yielding rather than accumulating lets the caller start processing immediately and keeps memory flat on a large pull. And `time.sleep` between calls is not politeness alone; many APIs will rate-limit or ban a client that pulls as fast as it can. For a long run, write each page to disk as you go so a failure at page 300 does not mean starting again. **Likely follow-ups** - What stops your loop if the API keeps returning the same page? - How would you resume a run that died on page 300? - When would you yield pages instead of building one big list? --- ## 47. A request fails roughly one time in twenty. How would you retry it without making things worse? *Medium · Occasional* **Short answer.** Retry only transient failures, wait longer after each attempt, and cap the number of attempts. Exponential backoff with a random jitter prevents many clients retrying in lockstep. Never retry a client error such as 400 or 401, since the same request will fail identically. ``` import random, time import requests def fetch_with_retry(session, url, attempts=4): for n in range(attempts): try: r = session.get(url, timeout=(5, 30)) if r.status_code in (429, 500, 502, 503, 504): raise requests.HTTPError(str(r.status_code)) r.raise_for_status() return r.json() except (requests.Timeout, requests.ConnectionError, requests.HTTPError) as exc: if n == attempts - 1: raise wait = (2 ** n) + random.uniform(0, 1) log.warning("attempt %s failed, sleeping %.1fs", n + 1, wait) time.sleep(wait) ``` Waits go roughly 1s, 2s, 4s, giving a struggling server room to recover. A fixed one-second retry loop does the opposite: it adds load exactly when the service is already failing, and a fleet of clients doing it together is how a partial outage becomes a total one. The jitter matters more than it looks. Without it, every client that failed at the same moment retries at the same moment, and the synchronised wave keeps the server down. A random fraction of a second spreads them out. Which failures to retry is the judgement being tested. Timeouts, connection errors, 429 and 5xx are transient and worth another attempt. A 400 means your request is malformed and will be malformed again; a 401 means your credentials are wrong; a 404 means it is not there. Retrying those wastes time and hides a real bug. If the response carries a `Retry-After` header, honour it rather than using your own backoff. The server is telling you exactly when it will accept you. One thing to raise unprompted: retrying is safe for reads and needs thought for writes. A POST that timed out may have succeeded on the server, so retrying it can create a duplicate record. Either the endpoint supports an idempotency key, or the retry must check whether the operation already happened. For production, `urllib3.util.Retry` mounted on a `requests` adapter handles most of this declaratively, and `tenacity` is the usual library when the logic gets more involved. **Likely follow-ups** - Which HTTP status codes would you retry, and which would you never retry? - Why add randomness to the wait time? - What would you do differently if the request created something on the server? --- ## 48. You've been sent a PDF with the table you need. How do you get it into Python? *Medium · Occasional* **Short answer.** Try `pdfplumber` first, which extracts text and detects table structure from digital PDFs. If the file is a scan, no text layer exists and you need OCR through something like Tesseract. Always verify the output against the source, because extraction quietly mangles merged cells and multi-line rows. ``` import pdfplumber rows = [] with pdfplumber.open("tariff_order.pdf") as pdf: for page in pdf.pages[3:7]: for table in page.extract_tables(): rows.extend(table) ``` `extract_tables` returns a list of tables, each a list of row lists. It works by detecting ruled lines and text alignment, so a table with clean borders extracts well and one held together by whitespace alone does not. The first question to ask is whether the PDF has a text layer. Select some text in a viewer: if you can, extraction will work; if not, it is an image and you need OCR. Running `page.extract_text()` and getting an empty string is the programmatic version of the same check. For scans, `pytesseract` over a rasterised page gives you text, and accuracy varies with scan quality. OCR of a tabular layout frequently loses the column structure entirely, so budget time for cleanup rather than expecting a clean DataFrame. Three things that go wrong even on digital PDFs. Merged cells produce `None` in the missing positions or duplicate the value, depending on the layout. Multi-line cells split into separate rows, so one logical record becomes three. And a header repeated on every page ends up interleaved through your data, needing a filter on the way out. The alternatives are worth naming. `camelot` handles ruled tables well and has a lattice mode for exactly that case. `PyPDF2` and `pypdf` are for page-level operations such as merging and splitting rather than table extraction. The habit that saves you: reconcile a total. Extract the table, sum a numeric column, and compare against the total printed in the PDF. Extraction errors are silent and plausible-looking, and a script that returns 400 rows from a 412-row table gives you no indication anything is wrong. **Likely follow-ups** - The PDF is a scan and your extraction returns nothing — what now? - The table runs across four pages with the header repeated — how do you handle that? - How would you verify the extraction is actually correct? --- ## 49. Two timestamps look comparable but Python refuses to subtract them. What's going on? *Medium · Occasional* **Short answer.** A naive datetime carries no timezone and an aware one does. Python refuses to compare or subtract the two, raising TypeError, because it cannot know what the naive one means. Attach a timezone with `replace(tzinfo=...)` when you know it, or convert with `astimezone`. ``` from datetime import datetime, timezone, timedelta from zoneinfo import ZoneInfo naive = datetime(2026, 8, 19, 14, 30) aware = datetime.now(timezone.utc) aware - naive # TypeError: can't subtract offset-naive and offset-aware datetimes ``` The error is a feature. A naive datetime is just six numbers with no statement about which clock produced them, so any arithmetic against an aware value would require guessing. Two different operations fix it, and confusing them corrupts data. ``` IST = ZoneInfo("Asia/Kolkata") # the value IS IST, just unlabelled — attach the label labelled = naive.replace(tzinfo=IST) # the value is a real instant — convert it to another zone in_ist = aware.astimezone(IST) ``` `replace` changes the label and leaves the digits alone. `astimezone` changes the digits to represent the same instant elsewhere. Using `replace` when you meant `astimezone` shifts every timestamp by five and a half hours with no error at all. `zoneinfo` is standard library from Python 3.9. Before that, `pytz` was the usual choice, and it has a different and easily misused API where you must call `localize()` rather than passing the zone to the constructor. Name the zone rather than the offset. `ZoneInfo("Asia/Kolkata")` carries the historical rules; a hardcoded `timedelta(hours=5, minutes=30)` is a fixed number that happens to be right for India and wrong for anywhere observing daylight saving. The half-hour offset makes India’s case sharper than most. A mistake never shifts by a clean day boundary that someone might spot; it lands mid-morning, which looks like a plausible quiet period rather than a bug. The working rule for pipelines: store UTC, convert at display. `datetime.now()` returns a naive local time and `datetime.now(timezone.utc)` returns an aware instant. Use the second one. **Likely follow-ups** - Why would you store UTC rather than IST in the database? - What does astimezone do to a naive datetime? - Where does India's half-hour offset cause more trouble than most zones? --- ## 50. `0.1 + 0.2` doesn't equal `0.3` in Python. Explain, and tell me what you'd use for rupee amounts. *Medium · Occasional* **Short answer.** Floats are binary fractions, and most decimal values including 0.1 cannot be represented exactly, so tiny errors accumulate. Use `decimal.Decimal` for money, constructed from strings rather than floats, and set an explicit rounding mode for anything involving tax or interest. ``` 0.1 + 0.2 # 0.30000000000000004 0.1 + 0.2 == 0.3 # False ``` Nothing is broken. A float stores a binary fraction, and 0.1 in binary is a repeating expansion, so what gets stored is the nearest representable value. The error is around 10⁻¹⁷ per operation and invisible until it accumulates or until you compare for equality. Summing lakhs of invoice lines makes it visible. The total drifts a few paise from what the ledger says, and the reconciliation fails on an amount nobody can explain. ``` from decimal import Decimal, ROUND_HALF_UP price = Decimal("2499.50") gst = (price * Decimal("0.18")).quantize( Decimal("0.01"), rounding=ROUND_HALF_UP ) gst # Decimal('449.91') ``` Construct from a string. `Decimal(0.1)` inherits the float’s error before Decimal ever sees it, which defeats the purpose entirely. `Decimal("0.1")` is exact. `quantize` sets the number of decimal places and forces you to name a rounding mode, which is the right kind of friction for money. Python’s built-in `round()` uses banker’s rounding, so `round(2.5)` gives 2 and `round(3.5)` gives 4. That is statistically sound and not what an Indian invoice expects, where half rounds up. `ROUND_HALF_UP` is usually the mode a finance team means. Two practical points. Decimal is slower than float, by enough to matter across crores of operations and not enough to matter in a report. And storing amounts as integer paise is a legitimate alternative used by many payment systems, since integers are exact and fast, at the cost of remembering to divide by 100 at every display point. For non-monetary float comparison, use `math.isclose(a, b)` rather than `==`. **Likely follow-ups** - How would you compare two floats when you genuinely have to? - What does Python's round() do with 2.5, and why? - Would you store money as paise in an integer instead? --- ## 51. Your script works on your laptop and breaks on the Linux server with a file-not-found error. What did you write? *Easy · Occasional* **Short answer.** Hardcoded backslashes or an absolute path from your own machine. Use `pathlib.Path` and the `/` operator to join components, which produces the right separator for whatever OS runs the code, and derive locations from the script or a configured root rather than typing them. ``` # breaks outside your machine path = "C:\Users\ayan\data\inventory.csv" # portable from pathlib import Path BASE = Path(__file__).resolve().parent path = BASE / "data" / "inventory.csv" ``` The `/` operator joins path components using the correct separator for the platform, and `Path(__file__).resolve().parent` gives the directory the script itself lives in, so the path works regardless of where the job was launched from. That last part matters: a relative path like `"data/inventory.csv"` resolves against the current working directory, which for a cron job is often the user’s home rather than the project folder. `pathlib` replaces a pile of `os.path` calls with attributes: ``` p = Path("exports/2026-08/stock_count.csv") p.name # 'stock_count.csv' p.stem # 'stock_count' p.suffix # '.csv' p.parent # Path('exports/2026-08') p.exists() p.parent.mkdir(parents=True, exist_ok=True) ``` `exist_ok=True` is what stops a rerun failing because the directory is already there. One difference that catches people even after they switch to forward slashes: Linux filesystems are case-sensitive and Windows is not. `Path("Data/Stock.csv")` opens `data/stock.csv` on your laptop and raises `FileNotFoundError` on the server. Match the case exactly. Two more points. `os.path` still works and is not wrong; `pathlib` is the modern preference and every standard library function accepting a path also accepts a `Path` object from Python 3.6 onwards. And absolute paths belong in configuration, not in code. Read the data root from an environment variable with a sensible default, so the same script runs on a laptop, a server and in a container without editing. **Likely follow-ups** - How would you build a path relative to the script's own location? - What does Path.resolve() do that Path alone doesn't? - Why can the same path work on Windows and fail on Linux even with forward slashes? --- ## 52. When would you define your own exception class rather than raising ValueError? *Medium · Occasional* **Short answer.** Define your own when callers need to distinguish your failure from everything else and handle it specifically. A shared base class for your library lets callers catch all of it in one clause. Built-ins are fine when the meaning genuinely matches, such as a bad argument value. The test is whether a caller would want to catch it separately. If the answer is no, `ValueError` with a clear message is enough and adding a class is noise. ``` class FeedError(Exception): """Base for everything this module raises.""" class SchemaMismatch(FeedError): def __init__(self, expected, found): self.expected = expected self.found = found super().__init__( f"expected {expected} columns, found {found}" ) class FeedUnavailable(FeedError): pass ``` The base class is the part that earns its keep. A caller who does not care which specific thing went wrong writes `except FeedError:` once, and keeps working when you add a fourth subclass next month. Without a shared base, every caller maintains a growing tuple of your exception names. Carrying structured attributes matters more than the message. A retry layer needs to know whether the failure is transient, and parsing that out of a string is fragile: ``` except SchemaMismatch as exc: if exc.found > exc.expected: drop_extra_columns() ``` Two things worth mentioning in an interview. Inherit from a built-in when the semantics genuinely match and callers may already be catching it: a custom lookup failure inheriting from `KeyError` keeps existing code working. Do not inherit from `BaseException`; that is reserved for things that should escape ordinary handlers. And when wrapping a lower-level error, preserve the chain: ``` except ConnectionError as exc: raise FeedUnavailable("vendor feed down") from exc ``` `from exc` attaches the original traceback. Without it, the log shows your exception and nothing about the socket error that caused it, which is exactly the detail you need at 2 AM. **Likely follow-ups** - What would you put in the exception besides a message? - Where would you inherit from something other than Exception? - How do you keep the original error visible when you wrap it in your own? --- ## 53. The same script needs to run for different months and write to different folders. How do you parameterise it? *Medium · Occasional* **Short answer.** `argparse` defines the arguments a script accepts, parses `sys.argv`, converts types, applies defaults, and generates `--help` for free. It also fails with a usable message when an argument is missing, instead of raising IndexError somewhere deep in the script. ``` import argparse from pathlib import Path def build_parser(): p = argparse.ArgumentParser( description="Generate the monthly stock report." ) p.add_argument("--month", required=True, help="reporting month as YYYY-MM") p.add_argument("--outdir", type=Path, default=Path("reports"), help="where to write the output") p.add_argument("--dry-run", action="store_true", help="parse and validate, write nothing") return p def main(): args = build_parser().parse_args() args.outdir.mkdir(parents=True, exist_ok=True) run(args.month, args.outdir, dry_run=args.dry_run) ``` Four things this gives you over reading `sys.argv` by hand. Missing required arguments produce a clear error and a usage line rather than an `IndexError` three functions deep. `type=` converts and validates in one step, so `args.outdir` arrives as a `Path` rather than a string. `action="store_true"` handles flags without value parsing. And `--help` documents the script for whoever inherits it, including you in six months. Note that `--dry-run` becomes `args.dry_run`; argparse converts the hyphen to an underscore. For a value that must be a date, do the conversion in the parser rather than later: ``` def month_arg(text): try: return datetime.strptime(text, "%Y-%m").date() except ValueError: raise argparse.ArgumentTypeError( f"expected YYYY-MM, got {text!r}" ) p.add_argument("--month", type=month_arg, required=True) ``` A bad month now fails immediately with a message naming the problem, rather than after the script has already connected to the database and pulled data. One design point worth voicing: keep parsing inside `main()` rather than at module level, so importing the module for a function does not attempt to read `sys.argv` from whatever process is running. And avoid defaulting the month to “current month” if reruns and backfills are expected, since a relative default makes the script non-reproducible. **Likely follow-ups** - How would you make one argument required and another optional with a default? - What's the advantage of argparse over just reading sys.argv? - Where would you handle an argument that should be a date, not a string? --- ## 54. The report has to land in three inboxes every Monday morning. How would you send it from Python? *Medium · Occasional* **Short answer.** Build the message with `email.message.EmailMessage`, attach the file with `add_attachment`, and send over an authenticated SMTP connection using `smtplib.SMTP_SSL`. Credentials come from the environment, and the send needs its own error handling so a failure is visible rather than silent. ``` import os, smtplib from email.message import EmailMessage from pathlib import Path msg = EmailMessage() msg["Subject"] = f"Weekly stock report — {week_label}" msg["From"] = "reports@example.in" msg["To"] = ", ".join(recipients) msg.set_content( f"Attached: stock position as at {as_of}.n" f"{row_count} SKUs, {low_stock} below reorder level." ) data = Path(report_path).read_bytes() msg.add_attachment( data, maintype="application", subtype="vnd.openxmlformats-officedocument." "spreadsheetml.sheet", filename=Path(report_path).name, ) with smtplib.SMTP_SSL("smtp.example.in", 465) as smtp: smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"]) smtp.send_message(msg) ``` `EmailMessage` handles MIME structure, encoding and headers, which is why it replaced the older `MIMEMultipart` assembly most tutorials still show. Put the headline numbers in the body. A report that arrives with an empty body forces every recipient to open the attachment to learn whether anything needs attention, and a body summarising the key figures means most weeks nobody has to. Two things that go wrong in production. A silent failure. If the send raises inside a scheduled job with no error handling, the job exits non-zero and nobody notices until someone asks where the report is. Catch, log with `log.exception`, and make the job’s failure visible to whatever monitors it. Attachment size. Many mail servers reject anything above roughly 10 to 25 MB, and the rejection often arrives as a bounce rather than an exception at send time. For a large file, upload it somewhere and send a link. Two operational points. App-specific passwords or an OAuth token are usually required now; plain account passwords are widely disabled. And scheduling belongs outside the script, in cron or a scheduler, so the script stays a thing you can run once by hand for testing. **Likely follow-ups** - The attachment is 40 MB — what would you do instead? - How do you stop a failed send from silently doing nothing? - Where would you schedule this, and why not inside the script itself? --- More Data Analyst sets: https://codeayan.com/get-hired/data-analyst All interview prep: https://codeayan.com/get-hired