Python Developer Interview Questions (Part 2): Multithreading, Async, FastAPI, Django and Flask
Python interview questions for 2–4 years: multithreading, multiprocessing, asyncio, FastAPI, Django, Flask and real scenarios, with code and real output.
Python developer interview questions for 2–4 years experience with real code and output: core Python, OOP, decorators and 15 coding problems.
If you have been writing Python for two to four years, you are in an interesting spot. You are past the “what is a list” questions, but most companies are not yet asking you to design a distributed system on a whiteboard. Instead, the interview checks one thing: do you really understand the language you use every day, and can you write clean, working code under a little pressure?
This guide collects the Python developer interview questions for 2–4 years experience that come up again and again in product companies, service companies and startups. Every concept comes with a short example and its real output, produced by running the code with Python 3.13. Read the code, guess the output, then check your guess. That small habit is the fastest way to stop being surprised in an interview.
At this level you are expected to explain why Python behaves the way it does, write correct and readable code without an IDE’s help, choose the right data structure, and talk about trade-offs such as speed, memory and readability.
| Area | Fresher level | 2–4 years level |
|---|---|---|
| Lists and dicts | Knows the methods | Knows the time complexity and when to use a set, deque or Counter instead |
| Functions | Writes functions | Explains closures, decorators, *args/**kwargs and mutable default arguments |
| OOP | Writes classes | Uses classmethods, dataclasses, dunder methods and understands the MRO |
| Errors | Uses try/except | Designs exception hierarchies and cleans up resources with context managers |
| Coding round | Brute force | Optimal solution with hash maps, two pointers or sliding window, and explains Big-O |
| Concurrency | Has heard of threads | Knows the GIL, threads vs processes vs asyncio (Part 2) |
| Frameworks | Has built a CRUD app | Knows the request lifecycle, ORM performance, testing and deployment (Part 2) |
This guide comes in two parts. Part 1 (this article) covers core Python and coding problems. Part 2 covers multithreading, async programming, FastAPI, Django, Flask and real-world scenarios.
| Part | Section | Questions |
|---|---|---|
| Part 1 | Section 1: Core Python concepts | Q1–Q15 |
| Part 1 | Section 2: Python coding problems with solutions | Q16–Q30 |
| Part 2 | Section 3: Multithreading, multiprocessing and async programming | Q31–Q42 |
| Part 2 | Section 4: FastAPI, Django and Flask | Q43–Q55 |
| Part 2 | Section 5: Scenario-based and problem-solving questions | Q56–Q60 |
Interview Preparation Tip: before you read each output, cover it and predict it. Interviewers love “what does this print?” questions, and the only way to get fast at them is practice. You can also press Run on most examples to try them in your browser and change them.
Short answer: Mutable objects (list, dict, set) can be changed after creation; immutable objects (int, str, tuple, frozenset) cannot. Default argument values are created once, when the function is defined, so a mutable default is shared by every call.
def add_item(item, basket=[]): # the same list is reused on every call
basket.append(item)
return basket
print(add_item("apple"))
print(add_item("banana")) # surprise: apple is still there
def add_item_safe(item, basket=None): # the standard fix
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item_safe("apple"))
print(add_item_safe("banana"))
['apple']
['apple', 'banana']
['apple']
['banana']
This bug is one of the most asked Python interview questions because it shows up in real code: a cache, a list of errors or a request payload that silently grows between calls. Always use None as the default and create the object inside the function.
Short answer: == compares values (it calls __eq__); is compares identity, meaning both names point to the same object in memory.
a = [1, 2, 3]
b = [1, 2, 3]
c = a
print(a == b) # same values
print(a is b) # different objects
print(a is c) # same object
print(id(a) == id(c))
value = None
print(value is None) # the correct way to check for None
True
False
True
True
True
Interview trap: x = 256; y = 256; x is y is often True because CPython caches small integers (−5 to 256), but that is an implementation detail. Never use is to compare numbers or strings; use it only for None, True, False and sentinel objects.
Short answer: A shallow copy creates a new outer object but shares the nested objects. A deep copy recursively copies everything, so changes to nested data never leak back to the original.
import copy
config = {"name": "api", "ports": [8000, 8001]}
shallow = copy.copy(config)
deep = copy.deepcopy(config)
shallow["ports"].append(9000) # changes the shared inner list
deep["ports"].append(7000) # changes only the deep copy
print("original:", config)
print("shallow: ", shallow)
print("deep: ", deep)
original: {'name': 'api', 'ports': [8000, 8001, 9000]}
shallow: {'name': 'api', 'ports': [8000, 8001, 9000]}
deep: {'name': 'api', 'ports': [8000, 8001, 7000]}
Slicing (items[:]), list(items), dict(d) and d.copy() are all shallow copies. Use copy.deepcopy when the structure contains lists or dicts that you plan to modify.
Short answer: *args collects extra positional arguments into a tuple and **kwargs collects extra keyword arguments into a dict. Parameters after * must be passed by keyword; parameters before / must be passed by position.
def log(level, /, *messages, sep=" ", **context):
text = sep.join(messages)
extra = " ".join(f"{k}={v}" for k, v in context.items())
print(f"[{level}] {text} {extra}".strip())
log("INFO", "User", "logged", "in", user_id=42, ip="10.0.0.5")
log("WARN", "Disk", "almost", "full", sep="_")
try:
log(level="INFO") # level is positional-only
except TypeError as err:
print("TypeError:", err)
[INFO] User logged in user_id=42 ip=10.0.0.5
[WARN] Disk_almost_full
TypeError: log() missing 1 required positional argument: 'level'
Keyword-only arguments make calls self-explaining (connect(host, timeout=5) instead of connect(host, 5)), and positional-only arguments let you rename a parameter later without breaking callers. Both are common in well-designed libraries.
Short answer: A list comprehension builds the whole list in memory immediately. A generator expression produces items lazily, one at a time, so it uses almost no memory, but you can loop over it only once.
import sys
squares_list = [n * n for n in range(1_000_000)]
squares_gen = (n * n for n in range(1_000_000))
print("list size:", sys.getsizeof(squares_list), "bytes")
print("generator size:", sys.getsizeof(squares_gen), "bytes")
print("sum from generator:", sum(squares_gen))
print("second sum:", sum(squares_gen)) # already exhausted
evens = {n for n in range(10) if n % 2 == 0} # set comprehension
lengths = {word: len(word) for word in ["py", "java"]} # dict comprehension
print(evens, lengths)
list size: 8448728 bytes
generator size: 200 bytes
sum from generator: 333332833333500000
second sum: 0
{0, 2, 4, 6, 8} {'py': 2, 'java': 4}
Rule of thumb: use a list when you need indexing, length or several passes; use a generator for large or streaming data that you process once, such as reading a big log file line by line.
Short answer: An iterator is any object with __iter__ and __next__. A generator is the easiest way to create one: a function with yield that pauses and resumes. yield from delegates to another iterable.
class Countdown: # a hand-written iterator
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
def countdown(start): # the same thing as a generator
while start > 0:
yield start
start -= 1
def chain(*iterables):
for it in iterables:
yield from it # delegate to each iterable
print(list(Countdown(3)))
print(list(countdown(3)))
print(list(chain([1, 2], "ab", countdown(2))))
gen = countdown(2)
print(next(gen), next(gen))
print(next(gen, "done")) # default instead of StopIteration
[3, 2, 1]
[3, 2, 1]
[1, 2, 'a', 'b', 2, 1]
2 1
done
Short answer: A decorator is a function that takes a function and returns a new function, usually wrapping the original with extra behaviour. A decorator with arguments is one more level: a function that returns a decorator. Always use functools.wraps.
import functools
import time
def timed(label): # outer: receives the decorator's argument
def decorator(func): # middle: receives the function
@functools.wraps(func) # keeps func.__name__ and docstring
def wrapper(*args, **kwargs): # inner: runs on every call
start = time.perf_counter()
result = func(*args, **kwargs)
ms = (time.perf_counter() - start) * 1000
print(f"[{label}] {func.__name__} finished in {'under' if ms < 50 else 'over'} 50 ms")
return result
return wrapper
return decorator
@timed("orders")
def total(prices):
"""Return the total price."""
return sum(prices)
print(total([120, 80, 49.5]))
print(total.__name__, "-", total.__doc__)
[orders] total finished in under 50 ms
249.5
total - Return the total price.
Real uses: logging, timing, caching (functools.lru_cache), retries, permission checks in web frameworks (@login_required in Django, route decorators in Flask and FastAPI).
Short answer: A closure is an inner function that remembers variables from the enclosing scope even after that scope has finished. Closures capture variables, not values, so functions created in a loop all see the final value of the loop variable.
def make_multiplier(factor):
def multiply(x):
return x * factor # factor is remembered
return multiply
double = make_multiplier(2)
print(double(21))
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # all see the last i
funcs = [lambda i=i: i for i in range(3)] # bind the current value
print([f() for f in funcs])
42
[2, 2, 2]
[0, 1, 2]
Short answer: Python looks up names in this order: Local, Enclosing, Global, Built-in. Assigning to a name makes it local unless you declare it global (module level) or nonlocal (enclosing function).
counter = 0
def make_counter():
count = 0
def increment():
nonlocal count # modify the enclosing variable
count += 1
return count
return increment
def bump_global():
global counter
counter += 1
inc = make_counter()
print(inc(), inc(), inc())
bump_global(); bump_global()
print("global counter:", counter)
def broken():
try:
counter += 1 # assignment makes counter local
except UnboundLocalError as err:
print("UnboundLocalError:", err)
broken()
1 2 3
global counter: 2
UnboundLocalError: cannot access local variable 'counter' where it is not associated with a value
Short answer: An instance method receives the object (self), a class method receives the class (cls) and is often used as an alternative constructor, and a static method receives nothing automatically: it is a plain function kept inside the class.
from datetime import date
class Employee:
company = "LearnFrenzy"
def __init__(self, name, birth_year):
self.name = name
self.birth_year = birth_year
def age(self, today=date(2026, 11, 11)): # instance method
return today.year - self.birth_year
@classmethod
def from_csv(cls, row): # alternative constructor
name, year = row.split(",")
return cls(name.strip(), int(year))
@staticmethod
def is_valid_name(name): # utility, no self or cls
return name.replace(" ", "").isalpha()
emp = Employee.from_csv("Asha Verma, 1998")
print(emp.name, emp.age(), Employee.company)
print(Employee.is_valid_name("Asha Verma"), Employee.is_valid_name("R2-D2"))
Asha Verma 28 LearnFrenzy
True False
Because from_csv uses cls, it also works for subclasses: Manager.from_csv(...) returns a Manager, not an Employee. That is the key reason to prefer a classmethod over a staticmethod for constructors.
Short answer: Dunder methods such as __repr__, __eq__, __lt__ and __len__ let your objects work with Python’s built-in operations. @dataclass generates the common ones for you, which removes a lot of boilerplate.
from dataclasses import dataclass, field
@dataclass(order=True, frozen=True)
class Version:
major: int
minor: int
patch: int = 0
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"
@dataclass
class Cart:
owner: str
items: list = field(default_factory=list) # never a shared mutable default
def __len__(self):
return len(self.items)
versions = [Version(3, 12), Version(3, 9, 7), Version(3, 13, 1)]
print(sorted(versions)) # order=True gives comparison operators
print(max(versions)) # __str__ is used by print
print(Version(3, 12) == Version(3, 12, 0))
cart = Cart("asha")
cart.items.append("book")
print(cart, len(cart))
try:
versions[0].major = 4 # frozen=True makes it immutable
except Exception as err:
print(type(err).__name__)
[Version(major=3, minor=9, patch=7), Version(major=3, minor=12, patch=0), Version(major=3, minor=13, patch=1)]
3.13.1
True
Cart(owner='asha', items=['book']) 1
FrozenInstanceError
Short answer: The Method Resolution Order (MRO) is the order in which Python searches classes for a method. It uses the C3 linearisation algorithm. super() calls the next class in the MRO, not simply the parent, which is what makes cooperative multiple inheritance work.
class Base:
def save(self):
print("Base.save")
class LoggingMixin(Base):
def save(self):
print("LoggingMixin.save")
super().save()
class ValidationMixin(Base):
def save(self):
print("ValidationMixin.save")
super().save()
class Order(LoggingMixin, ValidationMixin):
def save(self):
print("Order.save")
super().save()
Order().save()
print([cls.__name__ for cls in Order.__mro__])
Order.save
LoggingMixin.save
ValidationMixin.save
Base.save
['Order', 'LoggingMixin', 'ValidationMixin', 'Base', 'object']
Notice that LoggingMixin’s super() goes to ValidationMixin, its sibling, and Base.save runs only once. This “diamond” pattern is exactly how Django class-based views combine mixins such as LoginRequiredMixin.
Short answer: else runs only if no exception happened, and finally always runs, even after return. raise NewError(...) from err chains exceptions, so the original cause is kept in the traceback.
class PaymentError(Exception):
"""Business-level error for the payment flow."""
def charge(amount):
try:
if amount <= 0:
raise ValueError("amount must be positive")
result = f"charged {amount}"
except ValueError as err:
raise PaymentError("could not charge the customer") from err
else:
print("else: no error")
return result
finally:
print("finally: always runs")
print(charge(500))
try:
charge(-1)
except PaymentError as err:
print("caught:", err, "| cause:", repr(err.__cause__))
else: no error
finally: always runs
charged 500
finally: always runs
caught: could not charge the customer | cause: ValueError('amount must be positive')
Short answer: A context manager sets something up and guarantees clean-up, even if an error happens, through __enter__ and __exit__. The with statement uses it; files, locks and database transactions are the classic examples.
from contextlib import contextmanager
class Transaction:
def __init__(self, name):
self.name = name
def __enter__(self):
print(f"BEGIN {self.name}")
return self
def __exit__(self, exc_type, exc, tb):
if exc_type:
print(f"ROLLBACK {self.name} ({exc})")
else:
print(f"COMMIT {self.name}")
return True # True = the exception is handled here
@contextmanager
def temporary_setting(settings, key, value):
old = settings.get(key)
settings[key] = value
try:
yield settings
finally:
settings[key] = old # always restored
with Transaction("order-1"):
print("insert order")
with Transaction("order-2"):
raise ValueError("stock not available")
settings = {"debug": False}
with temporary_setting(settings, "debug", True) as s:
print("inside:", s)
print("after:", settings)
BEGIN order-1
insert order
COMMIT order-1
BEGIN order-2
ROLLBACK order-2 (stock not available)
inside: {'debug': True}
after: {'debug': False}
Short answer: CPython frees most objects through reference counting: when the count reaches zero the object is released at once. A cyclic garbage collector cleans up reference cycles. __slots__ removes the per-instance __dict__, which saves memory when you create millions of small objects.
import gc
import sys
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
class SlimPoint:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
p, s = Point(1, 2), SlimPoint(1, 2)
print("Point has __dict__:", hasattr(p, "__dict__"))
print("SlimPoint has __dict__:", hasattr(s, "__dict__"))
print("dict size per Point:", sys.getsizeof(p.__dict__), "bytes")
try:
s.z = 3
except AttributeError as err:
print("AttributeError:", err)
a = []
a.append(a) # a reference cycle
del a
print("objects collected by gc:", gc.collect() > 0)
Point has __dict__: True
SlimPoint has __dict__: False
dict size per Point: 296 bytes
AttributeError: 'SlimPoint' object has no attribute 'z' and no __dict__ for setting new attributes
objects collected by gc: True
| Concept | Remember this |
|---|---|
| Mutable default | Use None and create the object inside the function |
| is vs == | is = same object; == = same value |
| Copy | Shallow shares nested objects; deepcopy copies everything |
| Parameters | / positional-only, * keyword-only, *args tuple, **kwargs dict |
| Generators | Lazy, low memory, single pass |
| Decorators | Three levels when they take arguments; always functools.wraps |
| Closures | Capture variables, not values (late binding) |
| Scopes | LEGB; nonlocal for enclosing, global for module level |
| classmethod | Alternative constructors that also work for subclasses |
| dataclass | Generates __init__, __repr__, __eq__, ordering, frozen |
| MRO | super() = next class in the MRO |
| Exceptions | else on success, finally always, raise ... from keeps the cause |
| Memory | Reference counting + cyclic GC; __slots__ saves memory |
The coding round at 2–4 years is less about rare algorithms and more about clean, correct code with the right data structure. For every problem: say the brute-force idea in one sentence, then write the optimal solution, test it with an edge case and state the time and space complexity.
How to solve any coding question in an interview
────────────────────────────────────────────────
1. Clarify → inputs, outputs, edge cases (empty, duplicates, negatives)
2. Example → walk through one small example by hand
3. Brute → say the simple O(n²) idea out loud
4. Optimise → hash map? two pointers? sliding window? sorting? heap?
5. Code → clean names, small helper functions
6. Test → normal case + edge case, read the code once more
7. Big-O → time and space, and why
Approach: keep only letters and digits, lower-case them, and compare the string with its reverse. An interviewer may then ask for a version without extra memory, so show the two-pointer solution too.
def is_palindrome(text: str) -> bool:
cleaned = [ch.lower() for ch in text if ch.isalnum()]
return cleaned == cleaned[::-1]
def is_palindrome_two_pointers(text: str) -> bool:
left, right = 0, len(text) - 1
while left < right:
if not text[left].isalnum():
left += 1
elif not text[right].isalnum():
right -= 1
elif text[left].lower() != text[right].lower():
return False
else:
left, right = left + 1, right - 1
return True
for s in ["A man, a plan, a canal: Panama", "race a car", ""]:
print(repr(s), is_palindrome(s), is_palindrome_two_pointers(s))
'A man, a plan, a canal: Panama' True True
'race a car' False False
'' True True
Complexity: O(n) time for both; O(n) extra space for the first, O(1) for the two-pointer version.
Approach: count every character with collections.Counter, then scan the string again and return the first character with count 1. Dicts keep insertion order, which keeps the code simple.
from collections import Counter
def first_unique(text: str):
counts = Counter(text)
for ch in text:
if counts[ch] == 1:
return ch
return None
print(first_unique("swiss"))
print(first_unique("learnfrenzy"))
print(first_unique("aabb"))
w
l
None
Complexity: O(n) time, O(k) space where k is the number of distinct characters.
Approach: the brute force checks every pair, O(n²). The optimal solution stores each number’s index in a dict and checks whether the complement (target - n) has already been seen.
def two_sum(nums, target):
seen = {} # value -> index
for i, n in enumerate(nums):
if target - n in seen:
return [seen[target - n], i]
seen[n] = i
return []
print(two_sum([2, 7, 11, 15], 9))
print(two_sum([3, 2, 4], 6))
print(two_sum([1, 2, 3], 10))
[0, 1]
[1, 2]
[]
Complexity: O(n) time, O(n) space. This “trade memory for speed with a hash map” idea solves a surprising number of interview problems.
Approach: two words are anagrams when their sorted letters are equal, so the sorted word works as a dictionary key. defaultdict(list) keeps the code short.
from collections import defaultdict
def group_anagrams(words):
groups = defaultdict(list)
for word in words:
groups["".join(sorted(word))].append(word)
return list(groups.values())
print(group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))
[['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]
Complexity: O(n · k log k) time, where k is the longest word. A follow-up answer: use a 26-letter count tuple as the key to make it O(n · k).
Approach: push every opening bracket onto a stack; for a closing bracket, the top of the stack must be the matching opener. At the end the stack must be empty.
def is_balanced(text: str) -> bool:
pairs = {")": "(", "]": "[", "}": "{"}
stack = []
for ch in text:
if ch in "([{":
stack.append(ch)
elif ch in pairs:
if not stack or stack.pop() != pairs[ch]:
return False
return not stack
for s in ["{[()()]}", "([)]", "((", "print(items[0])"]:
print(f"{s!r:20} -> {is_balanced(s)}")
'{[()()]}' -> True
'([)]' -> False
'((' -> False
'print(items[0])' -> True
Complexity: O(n) time, O(n) space. Real-world use: validating expressions, templates and JSON-like input.
Approach: a recursive generator with yield from handles any depth and keeps memory low. Mention that very deep nesting can hit the recursion limit, and an explicit stack avoids that.
def flatten(items):
for item in items:
if isinstance(item, (list, tuple)):
yield from flatten(item)
else:
yield item
def flatten_iterative(items):
stack, result = [iter(items)], []
while stack:
for item in stack[-1]:
if isinstance(item, (list, tuple)):
stack.append(iter(item))
break
result.append(item)
else:
stack.pop()
return result
data = [1, [2, [3, [4, 5]], 6], (7, 8), []]
print(list(flatten(data)))
print(flatten_iterative(data))
[1, 2, 3, 4, 5, 6, 7, 8]
[1, 2, 3, 4, 5, 6, 7, 8]
Approach: Counter.most_common(k) is the Pythonic answer; internally it uses a heap. Be ready to write the heap version with heapq.nlargest if the interviewer asks.
import heapq
from collections import Counter
logs = ["login", "search", "login", "checkout", "search", "login", "logout", "search", "search"]
counts = Counter(logs)
print(counts.most_common(2))
print(heapq.nlargest(2, counts.items(), key=lambda kv: kv[1]))
[('search', 4), ('login', 3)]
[('search', 4), ('login', 3)]
Complexity: O(n log k) with a heap, O(n) to count. For very large k, sorting all counts, O(n log n), is equally fine.
Approach: sort by start time, then walk through the intervals and either extend the last merged interval or start a new one. This is a favourite for scheduling and booking systems.
def merge_intervals(intervals):
merged = []
for start, end in sorted(intervals):
if merged and start <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
return merged
print(merge_intervals([[1, 3], [2, 6], [8, 10], [15, 18]]))
print(merge_intervals([[9, 12], [1, 4], [4, 5]]))
[[1, 6], [8, 10], [15, 18]]
[[1, 5], [9, 12]]
Complexity: O(n log n) because of sorting, O(n) for the output.
Approach: a sliding window. Move the right end forward; when a character repeats, jump the left end past its previous position. A dict remembers the last index of each character.
def longest_unique_substring(s: str):
last_seen, left, best, best_start = {}, 0, 0, 0
for right, ch in enumerate(s):
if ch in last_seen and last_seen[ch] >= left:
left = last_seen[ch] + 1
last_seen[ch] = right
if right - left + 1 > best:
best, best_start = right - left + 1, left
return best, s[best_start:best_start + best]
for s in ["abcabcbb", "bbbbb", "pwwkew", ""]:
print(repr(s), longest_unique_substring(s))
'abcabcbb' (3, 'abc')
'bbbbb' (1, 'b')
'pwwkew' (3, 'wke')
'' (0, '')
Complexity: O(n) time, O(k) space. The sliding window pattern also solves “maximum sum of k consecutive items” and many string problems.
Approach: an OrderedDict keeps keys in usage order. On every access move the key to the end; when the cache is full, remove the first (least recently used) key. Then mention that functools.lru_cache does this for function results.
from collections import OrderedDict
from functools import lru_cache
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key)
return self.data[key]
def put(self, key, value):
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.capacity:
self.data.popitem(last=False)
cache = LRUCache(2)
cache.put("a", 1); cache.put("b", 2)
cache.get("a") # "a" is now most recent
cache.put("c", 3) # evicts "b"
print(list(cache.data.items()), cache.get("b"))
@lru_cache(maxsize=128)
def slow_square(n):
return n * n
slow_square(4); slow_square(4); slow_square(5)
print(slow_square.cache_info())
[('a', 1), ('c', 3)] -1
CacheInfo(hits=1, misses=2, maxsize=128, currsize=2)
Complexity: O(1) for both get and put. In a real system you would use Redis for a shared cache across servers.
Approach: plain recursion is elegant but exponential; memoisation makes it linear; an iterative loop is linear with constant memory. Counting the calls makes the difference obvious.
from functools import lru_cache
calls = 0
def fib_recursive(n):
global calls
calls += 1
return n if n < 2 else fib_recursive(n - 1) + fib_recursive(n - 2)
@lru_cache(maxsize=None)
def fib_memo(n):
return n if n < 2 else fib_memo(n - 1) + fib_memo(n - 2)
def fib_iterative(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
print(fib_recursive(25), "with", calls, "calls")
print(fib_memo(25), "with", fib_memo.cache_info().misses, "real computations")
print(fib_iterative(25), "| fib(90) =", fib_iterative(90))
75025 with 242785 calls
75025 with 26 real computations
75025 | fib(90) = 2880067194370816120
Answer to give: “For production I would use the iterative version: O(n) time and O(1) memory, no recursion limit.”
This is the kind of practical problem service and product companies like, because it looks like real work. Approach: parse each line, count requests and errors per endpoint with two Counters, then calculate percentages.
from collections import Counter
LOGS = """\
2026-11-11 10:00:01 GET /api/orders 200 120ms
2026-11-11 10:00:02 GET /api/orders 500 30ms
2026-11-11 10:00:03 POST /api/login 200 80ms
2026-11-11 10:00:04 GET /api/orders 200 95ms
2026-11-11 10:00:05 POST /api/login 401 12ms
2026-11-11 10:00:06 GET /api/products 200 60ms
"""
requests, errors = Counter(), Counter()
for line in LOGS.splitlines():
date, time, method, path, status, duration = line.split()
requests[path] += 1
if int(status) >= 400:
errors[path] += 1
for path, total in requests.most_common():
rate = errors[path] / total * 100
print(f"{path:15} requests={total} errors={errors[path]} error_rate={rate:.1f}%")
/api/orders requests=3 errors=1 error_rate=33.3%
/api/login requests=2 errors=1 error_rate=50.0%
/api/products requests=1 errors=0 error_rate=0.0%
Follow-up questions to expect: “What if the file is 50 GB?” (read it line by line with a generator, never read() the whole file) and “What if a line is malformed?” (catch ValueError, count it as bad input and continue).
Approach: slicing in a generator works everywhere. On Python 3.12+ you can also use itertools.batched, which works with any iterable, not just lists.
from itertools import batched
def chunks(items, size):
for i in range(0, len(items), size):
yield items[i:i + size]
records = list(range(1, 11))
print(list(chunks(records, 4)))
print(list(batched(records, 4))) # Python 3.12+, returns tuples
[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10]]
[(1, 2, 3, 4), (5, 6, 7, 8), (9, 10)]
Real use: sending records to an API or a database in batches of 500 instead of one huge request.
Approach: walk through the override dict; when both values are dicts, merge them recursively, otherwise the override wins. Return a new dict instead of changing the inputs.
def deep_merge(base: dict, override: dict) -> dict:
result = dict(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = deep_merge(result[key], value)
else:
result[key] = value
return result
defaults = {"db": {"host": "localhost", "port": 5432}, "debug": False, "workers": 2}
production = {"db": {"host": "db.internal"}, "workers": 8}
print(deep_merge(defaults, production))
print(defaults["db"]["host"]) # the input is unchanged
print({**defaults, **production}["db"]) # a shallow merge loses the port
{'db': {'host': 'db.internal', 'port': 5432}, 'debug': False, 'workers': 8}
localhost
{'host': 'db.internal'}
Approach: the sum of 1..n is n(n+1)/2; subtract the actual sum. Mention the XOR trick as an alternative that avoids very large sums in other languages.
from functools import reduce
from operator import xor
def missing_number(nums):
n = len(nums) + 1
return n * (n + 1) // 2 - sum(nums)
def missing_number_xor(nums):
n = len(nums) + 1
return reduce(xor, range(1, n + 1)) ^ reduce(xor, nums)
data = [1, 2, 4, 5, 6]
print(missing_number(data), missing_number_xor(data))
3 3
Complexity: O(n) time and O(1) space for both.
| Problem | Pattern | Time | Space |
|---|---|---|---|
| Palindrome | Two pointers | O(n) | O(1) |
| First unique character | Counter + second pass | O(n) | O(k) |
| Two Sum | Hash map of complements | O(n) | O(n) |
| Group anagrams | Sorted word as dict key | O(n·k log k) | O(n·k) |
| Balanced brackets | Stack | O(n) | O(n) |
| Flatten list | Recursive generator / explicit stack | O(n) | O(depth) |
| Top K frequent | Counter + heap | O(n log k) | O(n) |
| Merge intervals | Sort + linear scan | O(n log n) | O(n) |
| Longest unique substring | Sliding window | O(n) | O(k) |
| LRU cache | OrderedDict | O(1) per operation | O(capacity) |
| Fibonacci | Iteration / memoisation | O(n) | O(1) |
| Missing number | Math sum / XOR | O(n) | O(1) |
Counter, defaultdict, deque, heapq, bisect and itertools show experience, but be ready to explain what they do inside.seen, left, right and counts are better than a, b and c.Continue with Part 2: Python Multithreading, Async, FastAPI, Django and Flask Interview Questions covers threads, processes, asyncio, the GIL in practice, the three big web frameworks and real-world scenario questions (Q31–Q60).
Most Python interviews at this level are won on the basics done really well: knowing why a mutable default leaks, choosing a dict over a list for lookups, writing a small decorator without googling, and explaining the complexity of your solution. If you can predict every output in this guide, you are ready for the core Python round.
Work through these Python developer interview questions for 2–4 years experience with the code open next to you: run each example, change it, and try to break it. Then move on to Part 2 for concurrency and web frameworks. If you are also preparing for AI roles, our AI Agent Developer interview questions build directly on these Python skills.
Did an interviewer ask you a Python question that is not on this list? Share it in the comments below, and we will add it to help other developers.
One short email when we publish something new — interview questions, Salesforce guides and career tips. No spam, unsubscribe in one click.
Python interview questions for 2–4 years: multithreading, multiprocessing, asyncio, FastAPI, Django, Flask and real scenarios, with code and real output.
Agentforce interview questions for Python developers (2–4 years): Apex actions, Agentforce Python SDK, Agent API, Azure AI and real scenarios, with code.
AI Agent Developer interview questions with answers and code: Python, FastAPI, RAG, tool calling and Generative AI for 2–4 years of experience.
Questions, corrections or your own experience — they all help the next reader.