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.
You have a large CSV (50 GB) to process with Pandas-style operations but not enough RAM. Describe an approach using Python and libraries to compute per-key aggregates (sum, count, mean) that scales. Provide code snippets or a step plan.
Sample Answer
Plan: do out-of-core aggregation (out-of-core means: process data too big to fit in memory by working on it piece by piece, rather than loading the whole 50 GB file at once) by streaming CSV in chunks or using Dask. Maintain per-key aggregates (sum, count) and compute mean = sum/count.
Chunked Pandas approach:
from collections import defaultdict
import pandas as pd
sums = defaultdict(float)
counts = defaultdict(int)
for chunk in pd.read_csv('big.csv', chunksize=10_000_00):
grp = chunk.groupby('key')['value'].agg(['sum','count'])
for k, row in grp.iterrows():
sums[k] += row['sum']; counts[k] += row['count']
# finalize
result = {k: (sums[k], counts[k], sums[k]/counts[k]) for k in sums}
Worked example (traced on a tiny 6-row file so the shapes are concrete)
Suppose big.csv held just:
key,value
a,10
b,20
a,30
c,5
b,15
a,50
Running the chunked-Pandas code above against this file (even split across multiple small chunks, since each chunk's partial sum/count is added into the running totals rather than overwriting them) produces:
print(result)
# {'a': (90.0, 3, 30.0), 'b': (35.0, 2, 17.5), 'c': (5.0, 1, 5.0)}
Each tuple is (sum, count, mean): key a appears three times with values 10, 30, 50 (sum 90, mean 30.0), key b twice with 20 and 15 (sum 35, mean 17.5), and key c once with 5 (sum 5, mean 5.0). Because the loop only ever adds each chunk's partial sum/count into the running totals, splitting this same 6-row file into 2 chunks of 3 rows each, or processing it as one chunk, produces the identical result dict; only how many times the loop body runs changes, not the answer.
Dask approach: Dask is a library that mirrors the Pandas API (dask.dataframe looks and behaves like pandas.DataFrame) but splits the data into partitions and runs the same operations across them in parallel, so the .groupby().agg() code you already know scales past what fits in one machine's RAM. Use dask.dataframe.read_csv and ddf.groupby('key').agg({'value':['sum','count']}).compute(): Dask handles partitioning and parallelism.
Trade-offs:
- Pure chunking: simple, low memory, single-threaded unless parallelized; needs dictionary sized by number of unique keys.
- Dask: parallel, scales across cores/machines, but adds scheduler overhead and cluster configuration.
Notes: If unique keys are huge, consider external grouping: this is a rare fallback, needed only once the number of distinct keys is itself too large for the sums/counts dictionaries to fit in memory (a separate problem from the original 50 GB of rows, which the chunking above already handles). Two concrete versions of it: sorting by key on disk (writing all rows out sorted so equal keys become adjacent, letting you aggregate in one streaming pass with no dict at all), or using a local key-hash partitioning to spill to disk (hashing each key to decide which of several on-disk partition files it belongs to, so each partition file can later be aggregated on its own, fully in memory, one at a time).
Implement a safe file-based lock in Python usable across processes on the same machine. The API should support acquire(timeout) and release, and avoid race conditions if two processes try to create the lock simultaneously. Discuss platform differences (Unix vs Windows).
Sample Answer
Approach summary
Use an atomic filesystem operation to create a lockfile and store owner PID. "Atomic" here means the operating system guarantees that checking whether the file already exists and creating it happen as a single, indivisible step: the kernel resolves any race internally, so it is impossible for two processes to both be told "it didn't exist, you just created it" for the same file. Concretely, os.open(path, O_CREAT|O_EXCL) either creates the file and returns a valid file descriptor (this process is now the owner), or, if the file already exists, raises OSError with errno.EEXIST and creates nothing, there is no window in between where a second process could sneak in and also succeed. On Unix use os.open with O_EXCL|O_CREAT; on Windows use msvcrt.locking or CreateFile with exclusive flags. Implement acquire(timeout) with retries and stale-lock detection via PID and age.
Implementation (Unix-first, cross-platform fallback)
import os, time, errno
from pathlib import Path
class FileLock:
def __init__(self, path):
self.path = Path(path)
def acquire(self, timeout=10):
end = time.time()+timeout
while time.time()<end:
try:
fd = os.open(self.path, os.O_CREAT|os.O_EXCL|os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except OSError as e:
if e.errno!=errno.EEXIST: raise
time.sleep(0.1)
return False
def release(self):
try: self.path.unlink()
except FileNotFoundError: pass
Worked trace: two processes racing to acquire the same lock, simulated in one process for illustration (the real guarantee comes from the OS's atomic O_CREAT|O_EXCL, not from anything special in this simulation, but the sequence of return values is exactly what would happen with two real, separate processes):
lock_a = FileLock("/tmp/demo.lock")
lock_b = FileLock("/tmp/demo.lock")
print(lock_a.acquire(timeout=1)) # True: lock_a's os.open call wins the race, file now exists
print(lock_b.acquire(timeout=0.3)) # False: lock_b's os.open calls all hit EEXIST and it
# gives up once the 0.3s timeout elapses
lock_a.release()
print(lock_b.acquire(timeout=1)) # True: now that lock_a released (deleted the file),
# lock_b's next os.open call succeeds
The first acquire call's os.open(..., O_CREAT|O_EXCL) either wins outright (file did not exist, now it does, True) or loses outright (EEXIST, False after retries time out); there is no third outcome where both processes believe they created the file, which is exactly the race the atomic system call rules out by construction.
Platform notes
- Unix: O_EXCL is atomic across processes. Use fcntl.flock for advisory locks (a lock that only stops other processes if they also choose to check for it before touching the file; unlike a mandatory, OS-enforced lock, the OS itself does not block a process that simply ignores the lock and opens the file directly) when needed.
- Windows: O_EXCL behaves differently; prefer msvcrt.locking or pywin32 CreateFile for exclusive access.
Stale locks
Check PID from file and whether process exists; remove stale if safe. For production, add jitter, robust error handling, and optional directory-level locking for network filesystems.
Write a function merge_sorted_iterables(iters, key=None) that merges multiple sorted iterables (which may be generators) into a single generator yielding elements in sorted order. Use only standard library modules. Explain memory characteristics.
Sample Answer
Approach: Use a min-heap (a min-heap is a tree-shaped data structure that always keeps its smallest element accessible at the top/root, retrievable in O(1), and restores that property in O(log n) after every push or pop; that is what lets this algorithm always grab the smallest available head-of-iterable next without rescanning every input from scratch) to pull the smallest next element from heads of each iterable. Support key by pushing (key(val), idx, val, iterator). Works with generators and streams and only holds one item per input plus heap overhead.
Implementation:
import heapq
import itertools
def merge_sorted_iterables(iters, key=None):
key = (lambda x: x) if key is None else key
heap = []
for idx, it in enumerate(map(iter, iters)):
try:
val = next(it)
heapq.heappush(heap, (key(val), idx, val, it))
except StopIteration:
continue
while heap:
_, idx, val, it = heapq.heappop(heap)
yield val
try:
nxt = next(it)
heapq.heappush(heap, (key(nxt), idx, nxt, it))
except StopIteration:
continue
Worked example, verified on CPython 3.12:
print(list(merge_sorted_iterables([[1, 4, 7], [2, 3, 9]])))
# [1, 2, 3, 4, 7, 9]
Tracing the heap: it starts holding one entry per input, (1, 0, 1, iter1) and (2, 1, 2, iter2), the smallest head of each list. Popping the smaller one yields 1 and immediately pushes that same iterator's next value, 4, back in, so the heap always holds exactly one "candidate next value" per iterable that still has values left. Popping the current minimum of that small set is always correct because every value not yet pulled from a given iterable is guaranteed to be >= the value currently sitting in the heap for it, since each input is already sorted; the full sequence of pops is 1, 2, 3, 4, 7, 9, matching the printed output exactly.
Key points:
- Memory: O(m) where m = number of input iterables (one element per iterator + heap metadata).
- Works with infinite streams and generators.
- Stable per iterator due to idx tie-breaker.
Edge cases: empty iterables, differing element types (ensure key handles them), expensive key (consider caching keys).
You need to package a Python library used in data science which includes compiled C extensions and optional GPU support. Outline a cross-platform build and distribution strategy that simplifies installation for users on Linux, macOS, and Windows. Include CI steps and how you'd support pip installs.
Sample Answer
What a wheel is, first: a wheel (file extension .whl) is a pre-built, ready-to-install package file, so pip install mypkg just unpacks it, with no compiler or build step required on the user's own machine. Without wheels, pip has to compile the package's C extensions locally every time, which is slow and fails constantly on machines that lack the right compiler and headers. A concrete trace of the whole flow: a user on 64-bit Linux running Python 3.12 types pip install mypkg[cuda]; pip resolves the closest matching wheel filename it can find on PyPI, something like mypkg-1.0.0-cp312-cp312-manylinux2014_x86_64.whl, downloads it, and installs it directly with no compilation at all. manylinux2014 in that filename is a compatibility tag: it tells pip this wheel was built against an old-enough baseline of Linux system libraries that it will run correctly on nearly any modern Linux distribution, not just the exact one it was built on.
Strategy: provide manylinux and macOS/Windows wheels for common Python versions; fall back to source+build for edge cases. Offer GPU optional extras (extra tags like package[cuda]).
Build & CI:
- Linux: use GitHub Actions with cibuildwheel (a CI tool that automates building a correctly-tagged, pip-installable wheel for every OS/Python-version combination you need, instead of hand-writing that build matrix yourself) to produce manylinux2014 wheels for x86_64 and aarch64; build both CPU and GPU wheels (GPU via CUDA toolkits in separate matrix jobs).
- macOS: use cibuildwheel to build macOS universal2 wheels (a single wheel file containing binaries for both Intel and Apple Silicon Macs, so pip does not need two separate mac wheel variants).
- Windows: use cibuildwheel on windows-latest to build wheels.
- Run tests in each job, run integration tests with/without GPU.
Packaging:
- Use cython/setuptools or scikit-build with CMake (scikit-build bridges Python's packaging tools with CMake, the standard build system for compiled C/C++ code, so the compiled extension gets built the same way on every platform) for portability. Produce wheels with bundled libs where license allows.
- Publish wheels to PyPI and use tags: mypkg, mypkg[cuda] ==> extra_requires (Python packaging's mechanism for optional, named dependency sets: a user who runs
pip install mypkg[cuda]pulls in the extra CUDA-related dependencies, while a plainpip install mypkgskips them) that pin appropriate CUDA runtime and optional GPU wheels.
User installs:
- pip will pick wheel; if none, pip falls back to build from source (document build deps). Provide conda-forge (a large, community-maintained collection of prebuilt Conda packages) packages for easier GPU/runtime management.
Notes: sign wheels, provide concise install docs, and CI nightly builds for new Python/OS combos. If you can only do one thing first, do this: get cibuildwheel producing manylinux2014 plus macOS plus Windows CPU wheels through GitHub Actions and publish them to PyPI, since that alone lets the overwhelming majority of users pip install mypkg with zero compiler needed; GPU-specific wheels, conda-forge packages, and wheel signing are all valuable follow-ups, not blockers to a usable first release.
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.