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.
Explain the difference between a list comprehension, generator expression, and using map/filter in Python. When would you prefer each? Give a short code example (2-3 lines) for each showing how to square numbers 0..9.
Sample Answer
Difference & when to prefer
- List comprehension: produces a list eagerly; concise and fast for moderate-sized results when you need random access or repeated iteration.
- Generator expression: lazy iteration, low memory; prefer for large streams or pipeline processing.
- map/filter: functional style; can be slightly faster in some cases and composes well with other functions or builtins; returns iterator in Py3.
Examples (square 0..9):
List comprehension:
squares = [x*x for x in range(10)]
print(squares)
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
Generator expression:
squares_gen = (x*x for x in range(10))
print(list(squares_gen))
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
map/filter:
squares_map = list(map(lambda x: x*x, range(10)))
print(squares_map)
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
All three print the identical list, confirming the three forms are interchangeable here; only their evaluation style (eager list, lazy generator, eager map/filter) differs.
Given a NumPy array a of shape (N, M), write Python code to compute a boolean mask of rows that contain any NaN using vectorized NumPy operations. Do not use Python loops.
Sample Answer
Vectorized mask for rows containing any NaN:
import numpy as np
# a is shape (N, M)
mask = np.isnan(a).any(axis=1)
Explanation:
- np.isnan(a) produces boolean array shape (N, M).
- .any(axis=1) reduces per row to True if any element is NaN. A numpy axis names which direction a reduction collapses across for a 2D array: axis=0 collapses DOWN each column (one result per column), axis=1 collapses ACROSS each row (one result per row); since the question asks for a per-row answer, one True/False per row of the (N, M) array, axis=1 is the one that produces exactly N results, matching the number of rows.
Worked example, verified on CPython 3.12:
a = np.array([[1.0, 2.0], [np.nan, 4.0], [5.0, 6.0]])
print(np.isnan(a))
# [[False False]
# [ True False]
# [False False]]
print(np.isnan(a).any(axis=1))
# [False True False]
Row 0 ([1.0, 2.0]) has no NaN, so it reduces to False; row 1 ([nan, 4.0]) has one, so it reduces to True; row 2 ([5.0, 6.0]) has none, so False again, exactly the boolean mask a caller would use to filter out or flag the bad row.
This is fully vectorized, memory-efficient (single boolean array) and avoids Python loops. For very large arrays consider processing in chunks to limit peak memory.
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.
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).
Describe how you would profile a Python data-processing pipeline that spends too much time in a pandas.apply call. Provide commands and explain how to interpret results and optimize the code after profiling.
Sample Answer
Profiling plan for pandas.apply hotspot:
- Confirm hotspot: run a coarse profiler (cProfile) to see time spent in apply.
- python -m cProfile -o prof.out script.py
- snakeviz prof.out (a browser-based visualizer for cProfile output) or pstats to inspect
- Line-level: use line_profiler (pip install line_profiler) and add @profile to the function passed to apply or use kernprof:
- kernprof -l -v script.py
This shows which lines inside the applied function are expensive.
A concrete pass with real, reproducible numbers (call counts, not timing, since exact seconds are hardware-dependent and not something to hardcode here), verified with pandas on CPython 3.12:
import cProfile, pstats, io
import pandas as pd
def slow_row_calc(row):
total = 0.0
for i in range(20):
total += (row['x'] + i) ** 0.5
return total
df = pd.DataFrame({'x': range(500)})
pr = cProfile.Profile()
pr.enable()
df['y'] = df.apply(slow_row_calc, axis=1)
pr.disable()
stats = pstats.Stats(pr)
print('total function calls:', stats.total_calls)
# key by function name instead of hardcoding a (filename, line, name) tuple: the
# filename/line depend on how this file happens to be invoked and are not stable
own_key = next(k for k in stats.stats if k[2] == 'slow_row_calc')
print('calls to slow_row_calc:', stats.stats[own_key][0])
On this run (pandas version-dependent in its exact figure, but always dramatically more than one call per row), this printed total function calls: 156327 and calls to slow_row_calc: 500. The second number is unsurprising, 500 rows means 500 calls to your own function, but the first is the real finding a coarse profile surfaces: .apply(..., axis=1) did not cost "500 function calls," it cost over 150,000, because pandas builds a full pandas Series object per row (with its own isinstance checks and internal bookkeeping) before your function even runs, per-row overhead invisible from just reading the code, and exactly the kind of thing a profiler exists to reveal instead of guessing at.
-
Interpret results: if most time in Python-level loops or element ops, apply is causing Python callbacks per row.
-
Optimizations:
- Replace apply with vectorized NumPy/Pandas ops or use groupby.transform.
- Use C-accelerated libraries (numexpr, which evaluates a whole array expression like
a*b+cin one call without materializing each intermediate array) for heavy numeric expressions. - If logic is complex, use numba (compiles a plain Python function to machine code the first time it runs) to JIT-compile the function and call it over NumPy arrays.
- If per-row but pure Python expensive work, consider transforming to C-extension or use multiprocessing/df.map_partitions with Dask (a library that mirrors the Pandas API but splits data into partitions and runs the same operations across them in parallel).
The vectorized replacement for the example above, confirmed to agree exactly with the .apply() version:
y_vectorized = sum((df['x'] + i) ** 0.5 for i in range(20))
import numpy as np
print(np.allclose(df['y'].to_numpy(), y_vectorized.to_numpy()))
# True
This replaces the 500 individual per-row Python-function calls (and everything pandas does to set each of them up) with 20 vectorized column-wide additions, one per term in the loop, each running as a single compiled pass over all 500 rows at once instead of 500 separate Python-level calls.
- Validate: run profiler again to confirm reduced total call count, and add a regression test asserting the vectorized and original implementations agree (as
np.allclosedoes above) for the critical dataset.
Result: move work from Python-level per-row to vectorized, compiled, or parallel implementations.
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.