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.
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.
Why can a tuple be used as a dictionary key or set member while a list can't? Walk me through what makes an object hashable in Python, and what invariant must hold between hash and eq for a type to be safely usable as a key.
Sample Answer
Direct answer
A tuple can be a dict key or set member because it is immutable and, as long as everything inside it is itself immutable, hashable; a list cannot, because lists are mutable and Python refuses to hash mutable containers. The invariant that makes any object safely usable as a hash-table key is: if two objects compare equal (a == b), they must produce the same hash (hash(a) == hash(b)); the reverse is not required (unequal objects may share a hash, that is just a collision to be resolved). Hashability in CPython means "defines __hash__", and that hash value must never change for the lifetime of the object while it is a key or set member, which is why mutable types either omit __hash__ or, for user classes, have it silently disabled the moment you define __eq__ without also defining __hash__.
Structured elaboration
Why mutability breaks hashing. A dict finds a key by computing hash(key) once at insertion time and using it to pick a slot. If the key's value (and therefore its hash) could change after insertion, the entry would sit in the wrong slot for its new hash, and a later lookup with the same object would compute a different hash and look in the wrong place, silently failing to find it, or corrupting the table depending on how the collision chain unwound. Python avoids this whole class of bug by making list, dict, and set unhashable, and making tuple hashable only when its contents are hashable (a tuple is a fixed-size, ordered container, but it is only as hashable as what's inside it).
The __hash__/__eq__ contract. For a type to be safely usable as a dict key or set member:
a == bimplieshash(a) == hash(b). This is the invariant that actually matters: if you break it, two "equal" objects can land in different hash-table slots, and a lookup by one will fail to find the entry stored under the other, even though the container logically already has that key.hash(a)must be stable for the object's lifetime as a key (an object should not become externally observably different, from the hash table's perspective, after insertion).- Python's default behavior for user classes reflects this: if you define
__eq__without defining__hash__, Python sets__hash__toNonefor that class (making instances unhashable), because the compiler cannot verify your custom__eq__still satisfies rule 1 against the default identity-based hash.
Where this shows up with tuples.
(1, 2)is hashable: both elements are immutable ints.(1, [2, 3])is NOT hashable: it contains a list, sohash()raisesTypeError, even though the outer tuple itself cannot be mutated.frozensetis the immutable, hashable counterpart toset, useful as a key when you need "a set of things" as the key itself.collections.namedtuple(andtyping.NamedTuple) are still plain tuples under the hood, so they remain hashable under the same rule and add named-field readability with no extra runtime cost over a positional tuple.
Worked example
t = (1, 2)
print(hash(t))
# some integer, e.g. -3550055125485641917
try:
hash([1, 2])
except TypeError as e:
print("TypeError:", e)
# TypeError: unhashable type: 'list'
try:
hash((1, [2, 3])) # tuple containing a list is still unhashable
except TypeError as e:
print("TypeError:", e)
# TypeError: unhashable type: 'list'
Defining __eq__ without __hash__ makes a class unhashable, demonstrating the contract directly:
class Point:
def __init__(self, x):
self.x = x
def __eq__(self, other):
return self.x == other.x
# no __hash__ defined -> Python sets __hash__ = None here
try:
hash(Point(1))
except TypeError as e:
print("TypeError:", e)
# TypeError: unhashable type: 'Point'
Using a tuple as a composite grouping key (a common real use):
from collections import defaultdict
rows = [
{"user_id": 1, "date": "2025-11-01", "value": 10},
{"user_id": 1, "date": "2025-11-01", "value": 5},
{"user_id": 2, "date": "2025-11-02", "value": 7},
]
totals = defaultdict(int)
for r in rows:
key = (r["user_id"], r["date"]) # tuple: immutable and hashable
totals[key] += r["value"]
# totals == {(1, '2025-11-01'): 15, (2, '2025-11-02'): 7}
Trade-offs & pitfalls
- A tuple is only hashable if all of its elements are; a tuple of lists, dicts, or sets is not, and you will find this out at runtime with a
TypeError, not at write time. - If you define
__eq__on a class and want instances to remain usable as dict keys or set members, you must also define a compatible__hash__(or explicitly reuse the default with__hash__ = object.__hash__if identity equality is acceptable); forgetting this is a common way custom value objects silently become unhashable. - Violating the "equal implies equal hash" invariant (defining
__eq__and a__hash__that disagrees with it) does not raise an error; it produces "impossible" bugs wherekey in some_dictreturnsFalsefor a key that logically should match, because the two objects hashed into different slots. namedtupleandfrozensetare the two common upgrades once a plain tuple's positional-only access, or a plain tuple's ordering, respectively, stops being convenient, both without giving up hashability.
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.
What does if __name__ == '__main__': actually do in Python? Show a small script that behaves differently when run directly versus imported as a module, and give a real reason you'd use this idiom.
Sample Answer
Direct answer
Every module has a variable named __name__ that Python sets automatically. When a file is run directly (python script.py), Python sets that file's __name__ to the string "__main__". When the same file is instead imported by another module, __name__ is set to the module's own name (its filename, without .py) instead. The idiom if __name__ == "__main__": lets a single file define reusable functions and also double as a runnable script, without the "run as a script" code executing a second time whenever something else imports it.
Structured elaboration
Why this matters: every top-level statement in a module runs once, immediately, the instant the module is first imported or run, not lazily on first use. Any code sitting outside a function, and outside the __name__ guard, always executes on import whether that was intended or not.
The real reasons to reach for it: a module holds both library functions and a demo or command-line interface (CLI) entry point in the same file, or a script that a test suite imports (to reach one function inside it) must not also re-run its "main" logic just because it was imported rather than executed.
Worked example
Verified on CPython 3.12. script_a.py:
def preprocess(data):
'''Lowercase every string in data.'''
return [s.lower() for s in data]
def _demo():
sample = ["Hello", "WORLD"]
print("Preprocessed:", preprocess(sample))
print("module __name__ is:", __name__)
if __name__ == "__main__":
_demo()
Running it directly:
$ python script_a.py
module __name__ is: __main__
Preprocessed: ['hello', 'world']
Importing it from elsewhere instead:
import script_a
print(script_a.preprocess(["Hi"]))
module __name__ is: script_a
['hi']
The print("module __name__ is: ...") line, sitting outside any function and outside the guard, runs in both cases (verified: it prints in both the direct-run and the import case, only with a different value each time); _demo() runs only in the direct-run case, verified above.
Trade-offs & pitfalls
- Common wrong turn: putting expensive top-level work (loading a large model, opening a database connection, reading a large file) directly at module level, outside any function and outside the
__name__guard. That cost is paid on every import, including in contexts that never intended to run it, a test suite, a REPL session, or another script importing just one helper function. - The guard only protects the code inside its own
ifblock; a stray statement at true module level, outside every function and outside the guard, still executes on import regardless, the guard does not retroactively protect the rest of the file. - A common structure for CLIs keeps parsing and dispatch inside an ordinary function (
def main(): ...) and reduces the guard to a single call,if __name__ == "__main__": main(), so the logic itself stays importable and unit-testable as a plain function, separate from the decision to run it right now.
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.
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.