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.
Write a function that removes duplicates from a list while preserving the first-seen order. It only needs to handle hashable elements. Then explain the time and space complexity of your solution and how you'd adapt it if the input were far too large to hold a full result list in memory.
Sample Answer
Approach
Since the elements are guaranteed hashable (hashable means it can be used as a dict key or set member: numbers, strings, and tuples made entirely of hashable values all qualify; lists, dicts, and sets themselves do not, since they can change after creation), a set gives O(1) average membership checks, so the whole pass stays linear: walk the input once, keep a set of everything already seen, and only append an item to the output the first time it appears.
Code (Python 3.12)
def unique_preserve_order(seq):
"""Return items from seq in first-seen order with duplicates removed.
Assumes every element of seq is hashable.
"""
seen = set()
out = []
for item in seq:
if item not in seen:
seen.add(item)
out.append(item)
return out
print(unique_preserve_order([3, 1, 3, 2, 1, 4]))
# [3, 1, 2, 4]
print(unique_preserve_order(["b", "a", "b", "c"]))
# ['b', 'a', 'c']
print(unique_preserve_order([]))
# []
The identical logic also dedupes an already-sorted input (a "unique_sorted" ask is the same function; sortedness only changes what the output looks like, not the algorithm) and works the same way on any hashable payload, such as deployment IDs:
deployment_ids = ["d-9", "d-3", "d-3", "d-11", "d-9", "d-2"]
print(unique_preserve_order(deployment_ids))
# ['d-9', 'd-3', 'd-11', 'd-2']
Key points
- The
setmembership check is what keeps this linear; checking membership against the growing output list instead (if item not in out) would silently work but degrade to O(n2), since list membership is a linear scan. - Order is preserved because
outis only ever appended to, never reordered; thesetis used purely for the O(1) "have I seen this" check and never influences output order.
Complexity
Time: O(n) average, one hash-table lookup and possible insert per element. Space: O(n) worst case (all elements unique), for both seen and out.
Edge cases
- Empty input returns
[]. - All-duplicate input returns a single-element list.
- Unhashable elements (a
listordictinsideseq) raiseTypeErrorfromitem not in seen/seen.add(item); per the problem statement this function is only contracted to handle hashable elements, so that is expected and correct behavior, not a bug to work around here.
Adapting for input too large to hold a full result list in memory
The seen set still has to track every distinct value you've encountered (there is no way to know something is a duplicate without remembering it), so the real constraint to relax is holding the full result in memory at once, not the full seen-set. Turn the function into a generator that yields as it goes, so a caller can stream results to a sink (a file, a socket, a downstream queue) instead of building out as a list:
def unique_preserve_order_stream(iterable):
seen = set()
for item in iterable:
if item not in seen:
seen.add(item)
yield item
print(list(unique_preserve_order_stream([3, 1, 3, 2, 1, 4])))
# [3, 1, 2, 4]
This removes the O(n) out list (memory now scales only with the number of distinct items seen so far, in seen, plus whatever the consumer of the generator chooses to retain), and it lets a downstream writer start consuming the first unique item immediately instead of waiting for the whole input to be processed. If even seen itself cannot fit in memory (billions of distinct values), the exact-uniqueness guarantee has to be traded for an approximate one (a probabilistic structure such as a Bloom filter, which can have false positives), which is a genuinely different problem from what is asked here and worth naming as the next escalation rather than silently downgrading correctness.
Given a shared counter incremented by several threads in a tight loop with count += 1, why does the final count come out wrong even though the GIL exists? Provide a corrected, efficient version, and explain which Python operations are actually safe to share across threads without a lock and which aren't.
Sample Answer
Direct answer
The Global Interpreter Lock (GIL) guarantees that only one thread executes Python bytecode at a time, but it does not make count += 1 a single, uninterruptible step. That statement compiles to several bytecode operations (load the current value, add one, store the new value), and the GIL can hand control to another thread in between any two of them. Two threads can both read the same old value before either writes back the incremented one, so an increment gets lost. The fix is to protect the whole read-modify-write with a lock (or have each thread accumulate locally and merge once), not to assume the GIL alone provides atomicity.
Structured elaboration
Why the GIL doesn't save you: the GIL prevents two threads from running Python bytecode simultaneously, but threads still interleave, control passes back and forth between them constantly. count += 1 is not one bytecode instruction; it is a small sequence, and the interpreter can switch threads between any of those instructions. If thread A reads count as 5, then control passes to thread B, which also reads 5, increments to 6, and writes back, then control returns to thread A, which still has 5 in hand, increments to 6, and writes 6 again, one increment from B is silently lost.
What's actually safe to share without a lock, and what isn't:
- Safe: a single, atomic operation on an immutable value, for example rebinding a name to point at a brand-new object (
x = new_value) is effectively atomic at the bytecode level because CPython's reference-count updates (CPython keeps a running count, on every object, of how many names or containers currently point to it, and updates that count whenever a name is bound or rebound; that single count update is what happens atomically here) for the old and new objects happen under the GIL without another thread's bytecode interleaving inside that singleSTOREstep. - Not safe: any compound operation, no matter how short it looks in source:
count += 1,d[k] = d.get(k, 0) + 1,list.appendfollowed later by readinglen(list)to decide something,if not cache: cache = build()(check-then-act). All of these are read-then-write (or read-then-branch-then-write) and can interleave. - The rule of thumb: if the line reads a shared value and then writes back something derived from what it read, it needs a lock, regardless of how atomic-looking the syntax is.
This is the same defect wearing three different costumes, and the fix is identical each time (protect the read-modify-write, or the check-then-act, with a lock):
- Shared counter (this question):
count += 1from multiple threads. - Shared dict:
d[k] = d.get(k, 0) + 1is exactly as compound as the counter case; two threads can race on the same key and lose an update the same way. - Lazy singleton:
if instance is None: instance = build()is a check-then-act; two threads can both seeNone, both callbuild(), and end up with two different instances (or worse, two objects doing conflicting side effects during construction) unless the check and the assignment are both inside the same lock.
Worked example
The race, made reliably observable (verified on CPython 3.12). A natural count += 1 loop rarely shows lost updates in a short demo, because the read and the write-back usually land close enough together that no thread switch happens to fall between them, forcing a time.sleep(0) between the read and the write makes the interleaving deterministic instead of leaving it to chance:
import threading, time
count = 0
def worker(n):
global count
for _ in range(n):
temp = count # read
time.sleep(0) # forces a thread switch between read and write-back
count = temp + 1 # write
threads = [threading.Thread(target=worker, args=(200,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(count, "expected", 800)
Three separate runs of this produced 225, 270, and 238 against an expected 800: the exact number is not reproducible run to run (it depends on the OS thread scheduler), but it is reliably and substantially below 800 every time, which is the point being demonstrated: lost updates, not a specific count.
Corrected, efficient version: each thread accumulates a local total (no shared state, no lock needed for that part) and merges into the shared counter once at the end, so lock contention happens once per thread instead of once per increment:
import threading
count = 0
count_lock = threading.Lock()
def worker(n):
global count
local = 0
for _ in range(n):
local += 1 # purely local, no synchronization needed
with count_lock:
count += local # one synchronized update per thread
threads = [threading.Thread(target=worker, args=(200_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(count) # 800000, exactly, on every run
This produced exactly 800000 across repeated runs, matching 4 * 200_000 precisely, because there is now only one lock-protected read-modify-write per thread instead of one per increment.
The same lesson applied to a shared dict and a lazy singleton, both verified on CPython 3.12:
import threading
# Shared dict: same compound read-modify-write as the counter.
counts_lock = threading.Lock()
counts = {}
def bump(key, n):
for _ in range(n):
with counts_lock:
counts[key] = counts.get(key, 0) + 1
threads = [threading.Thread(target=bump, args=("hits", 50_000)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counts["hits"]) # 200000, exactly
# Lazy singleton: same check-then-act race, fixed with double-checked locking.
class Config:
_instance = None
_lock = threading.Lock()
@classmethod
def get_instance(cls):
if cls._instance is None: # fast path, no lock in the common case
with cls._lock:
if cls._instance is None: # re-check: another thread may have
cls._instance = cls() # already built it while we waited
return cls._instance
Trade-offs & pitfalls
- Complexity: the corrected counter does O(n) work per thread with O(1) lock acquisitions per thread (not per increment), versus O(n) lock acquisitions in a naive "lock every increment" fix; batching the merge is what keeps contention low.
- A naive fix that locks every single increment is correct but pays full lock overhead n times per thread; that overhead, not correctness, is the usual reason it gets flagged as inefficient.
- The single-check
if instance is None: instance = build()singleton pattern looks obviously wrong once you frame it as check-then-act, but it is one of the most common places this bug actually ships, because it is rarely exercised under real contention until traffic spikes. - Common wrong turn: reaching for
collections.Counteror a plaindictand assuming built-in types are internally synchronized against interleaved compound updates from multiple threads; they are not, their individual C-level operations may be atomic, butget-then-setacross two separate operations is not. - Edge case: the demo above uses an inserted
time.sleep(0)to force the race to show up reliably; without it, whether a given run of an unprotectedcount += 1loop actually shows a wrong answer depends on thread count, iteration count, and the OS scheduler, so an unprotected version passing a quick manual test is not evidence it is safe.
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.
Write code that safely runs an external command via subprocess: avoiding shell injection, enforcing a timeout, and capturing stdout/stderr without leaving zombie or orphaned child processes behind.
Sample Answer
Approach
Never build a shell command by string interpolation; pass the executable and its arguments as a list, which bypasses the shell entirely so a user-supplied value can never be reinterpreted as shell syntax. Resolve the executable with shutil.which so exactly which binary runs is known and explicit. Start the child in its own new session (start_new_session=True on POSIX) so it becomes the leader of its own process group, then wait on it with a timeout; if it times out, kill the whole process group, not just the one child process, so anything that child itself spawned is killed too instead of being orphaned and left running. (A process group is a set of processes the operating system lets you signal all at once, as a single unit, instead of one at a time. An orphan is a still-running child process left behind after its parent exits without cleaning it up: it does not disappear, it just gets reparented to the operating system's init process and keeps running unsupervised. A zombie is the opposite kind of loose end: a process that has already finished running, but whose exit status the parent has not yet collected via wait/communicate, so the OS keeps a small record of it around until that collection happens; failing to collect it is how zombies accumulate.)
Code (Python 3.12)
import os
import signal
import subprocess
import shutil
def run_cmd_safe(cmd, timeout=10):
'''Run cmd (a list of args, never a shell string) and return
(returncode, stdout, stderr). Kills the whole process group on
timeout, not just the direct child, so any process the command
itself spawned does not become an orphan.
'''
if not cmd or not isinstance(cmd, list):
raise ValueError("cmd must be a non-empty list of arguments")
exe = shutil.which(cmd[0])
if exe is None:
raise FileNotFoundError(f"{cmd[0]!r} not found on PATH")
cmd = [exe, *cmd[1:]]
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
start_new_session=True, # proc.pid becomes a new process-group leader
)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL) # kill the whole group, not just proc
stdout, stderr = proc.communicate() # reap the process, avoid a zombie
raise TimeoutError(f"command timed out after {timeout}s: {cmd}") from None
return proc.returncode, stdout, stderr
rc, out, err = run_cmd_safe(["echo", "ok"], timeout=5)
print(rc, out.strip())
# 0 ok
try:
run_cmd_safe(["python3", "-c", "import time; time.sleep(5)"], timeout=0.3)
except TimeoutError as e:
print("caught:", e)
# caught: command timed out after 0.3s: [...]
Why the process-group kill matters (a correction to a common pattern)
A common but incomplete pattern catches subprocess.TimeoutExpired and calls e.process.kill(). Verified on Python 3.12: TimeoutExpired does not carry a process attribute at all (dir() on a caught instance shows args, cmd, output, stderr, stdout, timeout, nothing else), so that line raises AttributeError and never kills anything. Even keeping the process handle separately and calling proc.kill() only sends the kill signal to the direct child's process ID, not to anything that child itself spawned. If the command forks or execs a further subprocess (a shell script backgrounding a helper process, for example), killing only the direct child leaves that grandchild running as an orphan, exactly the failure mode the question asks to avoid.
This follows directly from standard POSIX process semantics (see man 2 kill, man 2 setsid): a signal sent to one process ID never propagates to that process's own children, they simply get reparented to the operating system's init process and keep running on their own schedule. start_new_session=True is what makes the child (and anything it spawns) share one process group under proc.pid, so os.killpg(proc.pid, signal.SIGKILL) (SIGKILL is the unconditional, uncatchable kill signal) reaches all of them in one call instead of only the direct child. Note that subprocess.run() itself already calls process.kill() and waits on it internally on timeout, so it never leaves a zombie for the direct child; the gap it does not close is exactly this grandchild-orphan case, which is why the version above uses Popen with start_new_session and killpg directly rather than subprocess.run.
Key points
- Passing arguments as a list, never
shell=Truewith a formatted string, is what actually removes the shell-injection risk;shlex.quotecan make a shell string safer, but a list removes the entire injection class rather than mitigating it. start_new_session=Truemakesproc.piddouble as the process group ID, which is whyos.killpg(proc.pid, ...)needs no separate lookup.communicate(timeout=...)is used instead ofwait(timeout=...)because it also drains the stdout/stderr pipes as it waits; waiting without draining a pipe can deadlock once the child fills the OS pipe buffer and blocks on writing to it.- After killing on timeout,
proc.communicate()(no timeout argument) is called once more to reap the process, avoiding a zombie entry, and to collect whatever partial output the child had already produced.
Complexity
Not meaningful in the big-O sense here; the cost is dominated by the external process's own runtime and by OS-level process management calls, not by anything in the Python wrapper. The wrapper's own overhead, setting up a couple of pipes, is negligible next to spawning an entire process.
Edge cases
- Executable not found:
shutil.whichreturnsNone, soFileNotFoundErroris raised beforePopenis ever called, an immediate, clear failure instead of a confusing error surfaced from exec. - Timeout with output already produced:
communicate()after the kill still returns whatever the child had already written to its pipes before being killed. - Non-POSIX (Windows):
start_new_sessionandos.killpgare POSIX-only; the Windows equivalent is creating the process withCREATE_NEW_PROCESS_GROUPand sendingCTRL_BREAK_EVENT, or killing the process tree by process ID (PID) withtaskkill /T /F, since Windows has no directkillpgequivalent. - Command exits normally with a non-zero return code: this is not an exception at all here (
checkis not set), the caller inspectsreturncode; only a timeout raises.
Write a function that converts a messy numeric string, things like '1,234.56', '$1.2M', 'NaN', or an empty string, into a float, returning None for anything unparseable. Would you check the input's shape before converting (look before you leap) or just try the conversion and catch the exception (ask forgiveness)? Justify your choice here.
Sample Answer
Approach
Ask forgiveness, not permission: normalize away the known messy formatting (thousands separators, a currency symbol, a K/M/B magnitude suffix, parenthesized negatives), then attempt the actual conversion with float() inside a try/except ValueError, returning None on failure. Look-before-you-leap (LBYL) would mean writing a regex or a hand-rolled validator that decides in advance whether the string looks convertible, which in practice means re-implementing everything float()'s own parser already does correctly, just to decide whether to call it; that duplicated logic is extra surface area that can drift from what float() actually accepts, and it does not save the try/except anyway, since malformed input mixing multiple messy features ("$1,2M3") will still need a runtime check. EAFP here means: only do the normalization steps that resolve known, named messy formats, then let Python's own float parser be the single source of truth on whether the result is actually a valid number.
Code (Python 3.12)
import re
_SUFFIX_MULTIPLIER = {"K": 1e3, "M": 1e6, "B": 1e9}
_CURRENCY_CHARS = re.compile(r"[$,\s]")
def parse_number(raw):
'''Convert a messy numeric string to float; return None if unparseable.
Handles thousands separators (1,234.56), a leading currency symbol ($),
a K/M/B magnitude suffix, and parenthesized negatives ((3.5K) == -3500).
Treats 'nan' and '' as missing data (None), not as float('nan'): in this
pipeline a NaN token means "value absent", not "the value is not-a-number".
'''
if raw is None or not isinstance(raw, str):
return None
text = raw.strip()
if not text or text.lower() == "nan":
return None
negative = text.startswith("(") and text.endswith(")")
if negative:
text = text[1:-1]
multiplier = 1.0
if text and text[-1].upper() in _SUFFIX_MULTIPLIER:
multiplier = _SUFFIX_MULTIPLIER[text[-1].upper()]
text = text[:-1]
text = _CURRENCY_CHARS.sub("", text)
try:
value = float(text)
except ValueError:
return None
value *= multiplier
return -value if negative else value
cases = ["1,234.56", "$1.2M", "NaN", "", " 42 ", "(3.5K)", "abc", None, "3.1e2"]
for c in cases:
print(repr(c), "->", parse_number(c))
# Variant: average a batch of messy values while ignoring missing ones.
def average_ignoring_missing(raw_values):
parsed = [v for v in (parse_number(x) for x in raw_values) if v is not None]
return sum(parsed) / len(parsed) if parsed else None
print(average_ignoring_missing(["10", "", "20", "NaN", "30"]))
Output:
'1,234.56' -> 1234.56
'$1.2M' -> 1200000.0
'NaN' -> None
'' -> None
' 42 ' -> 42.0
'(3.5K)' -> -3500.0
'abc' -> None
None -> None
'3.1e2' -> 310.0
20.0
Key points
- Every normalization step (strip whitespace, strip a currency symbol, strip a magnitude suffix, strip parentheses) is applied unconditionally and cheaply; only the final
float(text)call is wrapped intry/except, so the EAFP boundary is drawn as tightly as possible around the one operation whose success genuinely cannot be known in advance without duplicating its logic. "NaN"is deliberately treated asNone(missing), not passed through tofloat("nan")(which would succeed and produce an actual NaN float): the two are different data-quality signals, "this field was never populated" versus "this field holds the floating-point value NaN," and conflating them would let a NaN silently poison a downstreamsum()or comparison (nan != nanin every comparison, including equality, which produces confusing bugs if it leaks into arithmetic unexpectedly).isinstance(raw, str)up front is LBYL for exactly one thing: rejecting non-string input (alist, anintalready) before doing string operations on it that would raiseAttributeError/TypeErrorrather than theValueErrorthis function is designed to swallow. Mixing that one type check into an otherwise EAFP function is a normal, common pattern, not a contradiction: LBYL and EAFP are not mutually exclusive within a single function, the choice is made per failure mode.
Complexity and edge cases
O(k) where k is the length of the string, dominated by the regex substitution and float()'s own parsing.
- Whitespace-only strings (
" ") become""after.strip()and correctly returnNone. - A currency symbol combined with a suffix and a thousands separator all at once (
"$1,234.5K") is handled correctly because the three normalization steps are independent and composable. - Variant: averaging while ignoring missing values (shown in the code above via
average_ignoring_missing): parse every value with the sameparse_number, filter out theNones, then average what remains, so a missing reading never silently counts as zero and never crashes the average. - Variant: a safe money sum:
floataccumulates binary floating-point rounding error across many additions, which is unacceptable for currency; the EAFP shape stays the same but the target type changes todecimal.Decimal, which represents decimal fractions exactly:
import re
from decimal import Decimal, InvalidOperation
_CURRENCY_CHARS = re.compile(r"[$,\s]")
def parse_money(raw):
'''Like parse_number but returns Decimal for exact currency arithmetic.'''
if raw is None or not isinstance(raw, str):
return None
text = _CURRENCY_CHARS.sub("", raw.strip())
if not text:
return None
try:
return Decimal(text)
except InvalidOperation:
return None
prices = ["$19.99", "$5.01"]
total = sum((parse_money(p) for p in prices), start=Decimal("0"))
print(total) # 25.00, exact
Unlock Full Question Bank
Get access to all Python Programming interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.