Python Programming Questions
Python as an interview language: core syntax, data types and built-in collections, comprehensions, iterators and generators, idiomatic style, and the standard library, extending into data-oriented and automation use of the language and its common libraries. Covers writing correct, Pythonic code and reasoning about the language's semantics. The most heavily exercised language surface in this category across engineering and data roles.
Design a small library API in Python for vectorized string transformations on large Pandas Series that avoids creating multiple temporaries for chained operations (e.g., s.str.lower().str.replace(...).str.strip()). Sketch API and explain implementation strategies to minimize allocations.
Sample Answer
Requirements & idea: Provide a lazy, composable API that records string ops and applies them in a single pass to avoid temporaries. Offer a lightweight proxy object wrapping Series with an operation pipeline executed in-place or chunked.
API sketch, with a working _apply_pipeline (the earlier sketch left this function unimplemented; here it actually runs each recorded op vectorized, once, over the whole chunk, rather than materializing an intermediate Series between every step):
class StrChain:
def __init__(self, series):
self.series = series
self.ops = []
def lower(self):
self.ops.append(('lower', None)); return self
def replace(self, pat, repl):
self.ops.append(('replace', (pat, repl))); return self
def strip(self):
self.ops.append(('strip', None)); return self
def compute(self, chunk_size=10_000):
# apply ops chunk-wise to avoid temporaries
return _apply_pipeline(self.series, self.ops, chunk_size)
def _apply_pipeline(series, ops, chunk_size):
parts = []
for start in range(0, len(series), chunk_size):
chunk = series.iloc[start:start + chunk_size]
for op_name, arg in ops:
if op_name == 'lower':
chunk = chunk.str.lower()
elif op_name == 'replace':
pat, repl = arg
chunk = chunk.str.replace(pat, repl, regex=False)
elif op_name == 'strip':
chunk = chunk.str.strip()
parts.append(chunk)
return type(series)(pd.concat(parts)) if parts else series
_apply_pipeline walks the recorded op list once per chunk (default: the whole Series in one chunk, for anything that fits in memory), calling the real vectorized Series.str method for each recorded op in sequence; "chunk-wise" here means each chunk only ever holds one intermediate Series at a time (reassigned to chunk on each step) rather than every stage's output existing simultaneously the way s.str.lower().str.replace(...).str.strip() chained directly would briefly do.
Worked example, verified with pandas on CPython 3.12:
import pandas as pd
s = pd.Series([' Hello World ', ' FOO-BAR ', ' Already lower '])
result = StrChain(s).lower().replace('-', ' ').strip().compute()
print(list(result))
Output:
['hello world', 'foo bar', 'already lower']
Each string is lowercased, has - replaced with a space, and is stripped of surrounding whitespace, in that recorded order, confirming the chain actually runs end to end and produces the same result s.str.lower().str.replace('-', ' ', regex=False).str.strip() would, just without materializing three separate full-Series temporaries to get there.
Implementation strategies:
- Represent ops as vectorized functions (use Series.str methods or numpy.char).
- Apply pipeline per chunk: read chunk, apply all ops in sequence in-place (reuse buffers), write out to result array or new Series with preallocated dtype.
- For unicode/regex heavy ops, compile regex ahead.
- Use numba (compiles a plain Python function to machine code the first time it runs) or cython (compiles Python-like code all the way down to C, with explicit type declarations, for the most control and the largest potential speedup) for the hot path once profiling shows plain vectorized
Series.strops are the actual bottleneck; for most chains, the vectorized ops above are already enough and neither is needed by default.
Minimize allocations:
- Reuse a single buffer per chunk; preallocate numpy object/bytes arrays when possible.
- Fuse operations into one pass (e.g., lower+strip -> single routine, implemented as one
numpy.charor Cython function operating character-by-character instead of two separate full passes over the data).
Trade-offs: chunking adds overhead but limits peak memory; fusing ops increases implementation complexity.
Your team wants one new automation tool that will be owned by a few engineers, run unattended every day, and occasionally do CPU-heavy parsing on large inputs. How would you choose between Python and Go for the implementation, and what factors would matter most beyond raw speed?
Sample Answer
My decision
I would lean Go for this tool if the parsing is truly CPU-heavy, meaning the machine spends most of its time computing rather than waiting on disk, and the team wants one deployable binary. Go's compiled binary, static typing, and built-in concurrency fit unattended daily jobs well.
What matters beyond raw speed
- team familiarity and onboarding
- library support for the file formats you need
- packaging and deployment simplicity
- error handling and testability
- memory footprint and startup time
- how often the rules change
When I would pick Python instead
If the job mostly glues together existing Python libraries, or if the engineers need to change parsing rules every week, Python may be faster to evolve and easier to read.
Worked example
If the tool reads 500 large files every morning, I would favor Go when parsing and parallel file handling are the bottlenecks. If the same tool is edited often by a small team that values quick iteration over strict compilation, Python may win because maintenance cost matters more than raw throughput.
So I choose the language that minimizes total operating cost, not just the fastest loop.
Implement a thread-safe LRU cache decorator in Python without using functools.lru_cache (you may use threading primitives). The decorator should accept a maxsize and be safe for concurrent access by multiple threads. Discuss complexity and potential contention points.
Sample Answer
Approach: implement LRU with a dict for storage and a doubly-linked list for order; use threading.RLock (a lock that the SAME thread can safely acquire again without deadlocking itself, unlike a plain threading.Lock, in case the cache logic ever needs to re-enter the lock while already holding it) for concurrency. Decorator returns wrapper that locks around lookups and updates, minimizing lock hold time.
Implementation:
import threading
from functools import wraps
def lru_cache(maxsize=128):
def deco(func):
cache = {}
head = tail = None
lock = threading.RLock()
class Node:
__slots__=('key','val','prev','next')
def __init__(self,k,v):
self.key=k;self.val=v;self.prev=self.next=None
def _move_to_front(node):
nonlocal head, tail
if node is head:
return # already the most-recently-used entry, nothing to do
# unlink node from wherever it currently sits
if node.prev:
node.prev.next = node.next
if node.next:
node.next.prev = node.prev
if node is tail:
tail = node.prev
# relink it at the head (the most-recently-used end)
node.prev = None
node.next = head
if head:
head.prev = node
head = node
if tail is None:
tail = node
@wraps(func)
def wrapper(*args, **kwargs):
nonlocal head, tail # wrapper reassigns both below on eviction; without
# this declaration LEGB makes them locals instead
key=(args,tuple(sorted(kwargs.items())))
with lock:
node=cache.get(key)
if node:
_move_to_front(node); return node.val
val=func(*args, **kwargs)
with lock:
if key in cache: return cache[key].val
node=Node(key,val); cache[key]=node; _move_to_front(node)
if len(cache)>maxsize:
# cache is over capacity: evict the true tail, the
# least-recently-used entry, from both the linked list
# and the dict
lru_node = tail
tail = lru_node.prev
if tail:
tail.next = None
else:
head = None
del cache[lru_node.key]
return val
return wrapper
return deco
Worked example: verified on CPython 3.12, calling the decorated function through a full eviction cycle so the policy can actually be watched working, not just taken on faith.
calls = []
@lru_cache(maxsize=2)
def square(n):
calls.append(n)
return n * n
print(square(1)) # 1 (miss: cache empty, computed and cached; order (MRU->LRU): [1])
print(square(2)) # 4 (miss: computed and cached; order: [2, 1])
print(square(1)) # 1 (hit: served from cache, 1 moves back to the front; order: [1, 2])
print(square(3)) # 9 (miss: cache was full at {1, 2}; since 1 was just reused, 2 is now
# the least-recently-used entry and gets evicted to make room for 3; order: [3, 1])
print(square(2)) # 4 (miss again: 2 was evicted in the previous step, so this recomputes
# instead of hitting the cache; order: [2, 3])
print(calls) # [1, 2, 3, 2] -- 2 appears twice: proof it was actually evicted and
# had to be recomputed, not just a claim
Complexity: O(1) average get/set.
Contention: a single global lock, one shared threading.RLock protecting the whole cache, serializes cache access: only one thread at a time can even check whether something is cached, regardless of which key it wants, which becomes a bottleneck under heavy concurrent traffic. Two ways to reduce that: a read-mostly optimistic check (read the dict for a hit without taking the lock first, since a plain dict read is safe to race on for a snapshot lookup, and only take the lock to confirm the hit and update the linked-list ordering, so the common cache-hit path spends less time holding the lock), or shard locks (split one cache into several smaller caches, each with its own separate lock, and route each key to one shard by hashing it, e.g. shard = hash(key) % num_shards; two threads reading keys that land in different shards no longer contend for the same lock at all, at the cost of maxsize now being enforced per shard rather than globally).
Explain dependency management approaches for Python: requirements.txt with pip, pip-tools, Pipenv, Poetry, and Conda. For a reproducible data-science project, which would you pick and why?
Sample Answer
Overview of approaches:
- requirements.txt + pip: Simple, exact package names; use pip freeze to pin. Reproducible only if you pin hashes (pip hash or requirements with hashes).
- pip-tools (pip-compile): Maintains human-editable top-level requirements.in and generates fully pinned requirements.txt (including sub-deps).
- Pipenv: Combines virtualenv + Pipfile/Pipfile.lock (a lock file records the exact version, and a hash, of every package actually installed, including indirect dependencies pulled in by your direct ones, so a second install reproduces the identical environment instead of silently picking up newer, still-technically-compatible versions); locking for reproducibility but has had stability/performance issues historically.
- Poetry: Modern tool for dependency resolution, pyproject.toml, and lock file; good for packaging and reproducible installs across environments.
- Conda: Manages packages and binaries (C libs) and environments; best when native dependencies (numpy, scipy) are needed.
Which of these is baseline knowledge versus niche: for most purposes, pip + a lockfile-pinned requirements.txt and Poetry are the two worth knowing well; pip-tools and Pipenv are reasonable alternatives worth recognizing as "another lockfile-based tool" rather than needing a strong independent opinion on; Conda (and micromamba, below) only becomes necessary once native, non-Python dependencies enter the picture.
My pick for reproducible data-science project: Poetry + Conda hybrid depending on needs.
- If heavy native deps: use Conda to create base env (python, BLAS, the compiled linear-algebra library numpy/scipy rely on internally for fast matrix math, and something Poetry itself cannot install since it only manages Python-level packages, not compiled system libraries), then Poetry for Python deps inside that env, or use micromamba (a faster, lighter-weight, drop-in alternative to the
condacommand line tool, installing from the same package channels) + poetry. Concretely:conda create -n proj python=3.12 numpy scipyto get the compiled base right, thenpoetry initandpoetry add pandasinside that environment to layer the pure-Python dependencies on top with a proper lockfile. Poetry gives clear lockfile, semantic project layout, and reproducible installs. pip-tools is a lighter alternative if you prefer pip. Ensure CI records exact lockfile and Python version.
Design an experiment to compare memory usage and speed of three methods to join two large tables (both fit on disk but not memory): (1) pandas.merge on chunked reads, (2) using SQLite on-disk join, (3) using Dask. Describe metrics to collect, how to ensure fairness, and how to present results.
Sample Answer
Goal: compare memory and wall-clock of three join methods on two large disk-resident tables.
A concrete illustrative scale, to make the deliverable tangible (placeholder numbers for the experiment's INPUT sizes, not a claimed result; the actual measured numbers only come from running it): joining a 5,000,000-row table to a 50,000,000-row table on a shared user_id column, on a machine with 8 GB of RAM available to the process. That size difference (10x) is deliberately chosen so a hash join (build an in-memory lookup table keyed by the join column from the SMALLER side, then scan the larger side and look up each row's match against it, rather than one built from the bigger side) has an obvious "small side" to build its lookup table from.
Metrics:
- Peak RAM (process) via psutil
- Total wall-clock and CPU time
- I/O throughput and disk usage
- Return correctness (row counts, spot checksums)
Fairness:
- Same machine, same storage medium, same join keys/indexes
- Fixed dataset snapshots and repeat runs per method
- Use warm/cold cache measurements (warm cache: the operating system already has the file's data sitting in memory from a previous read, so the next read is fast; cold cache: the OS has to actually go to disk, which is slower and closer to a fresh, worst-case run; measuring both matters because a method that looks fast on a warm cache can look very different the first time it touches a cold file)
Experiment plan:
- Prepare synthetic datasets with controlled sizes and cardinalities, using the illustrative 5M/50M scale above (or whatever scale matches the real workload) as a concrete starting point
- Method (1): pandas: read table A in chunks, build index on join key for B or stream smaller table and use dict-based hash join (build an in-memory Python dict keyed by the join column from the smaller table, then scan the larger table row by row and look each row's key up in that dict, an O(n+m) approach instead of an O(n⋅m) nested comparison)
- Method (2): SQLite: load both tables into temp DB, create indexes, run JOIN
- Method (3): Dask (a library that mirrors the Pandas API but splits data into partitions and runs the same operations, including merge/join, across them in parallel, so a join too large for one machine's memory can still complete by processing partitions and spilling intermediate results to disk when needed): persist datasets on disk, execute dask.dataframe.merge with appropriate partitions
Presentation:
- Plot peak memory vs runtime per method (error bars from repeats)
- Table with throughput, disk I/O, and CPU
- Discuss trade-offs: simplicity, indexing cost, parallelism, disk spill behavior (disk spill: what happens when an operation's intermediate data no longer fits in RAM and the engine starts writing partial results to disk to keep going instead of crashing with an out-of-memory error, trading speed for the ability to finish at all)
Success criteria: lowest peak memory with acceptable runtime; reproducible scripts + configs
Unlock Full Question Bank
Get access to all 46 Python Programming interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.