LearnFrenzy
Sign in Take a free test

Interview 26 min read

Python Developer Interview Questions for 2–4 Years Experience (Part 1): Core Python and Coding Problems

Python developer interview questions for 2–4 years experience with real code and output: core Python, OOP, decorators and 15 coding problems.

Python Developer Interview Questions for 2–4 Years Experience (Part 1): Core Python and Coding Problems
On this page 10 sections
  1. What You’ll Learn
  2. What Interviewers Expect from a Python Developer with 2–4 Years of Experience
  3. Python Developer Interview Roadmap (Part 1 and Part 2)
  4. Section 1: Core Python Interview Questions for Experienced Developers (Q1–Q15)
  5. Quick Revision: Q1–Q15
  6. Section 2: Python Coding Interview Questions with Solutions (Q16–Q30)
  7. Quick Revision: Coding Patterns from Q16–Q30
  8. Tips for the Python Coding Round
  9. Final Thoughts
  10. Conclusion

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.

What You’ll Learn

  • Core Python interview questions for experienced developers: mutability, copying, scopes, closures, decorators and generators
  • Object-oriented Python: class methods, dunder methods, dataclasses and the MRO
  • Exceptions, context managers and memory management
  • 15 Python coding problems with solutions, output and time complexity
  • In Part 2: multithreading, multiprocessing, async programming, FastAPI, Django, Flask and scenario-based questions

What Interviewers Expect from a Python Developer with 2–4 Years of Experience

In one line:

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.

AreaFresher level2–4 years level
Lists and dictsKnows the methodsKnows the time complexity and when to use a set, deque or Counter instead
FunctionsWrites functionsExplains closures, decorators, *args/**kwargs and mutable default arguments
OOPWrites classesUses classmethods, dataclasses, dunder methods and understands the MRO
ErrorsUses try/exceptDesigns exception hierarchies and cleans up resources with context managers
Coding roundBrute forceOptimal solution with hash maps, two pointers or sliding window, and explains Big-O
ConcurrencyHas heard of threadsKnows the GIL, threads vs processes vs asyncio (Part 2)
FrameworksHas built a CRUD appKnows the request lifecycle, ORM performance, testing and deployment (Part 2)

Python Developer Interview Roadmap (Part 1 and 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.

PartSectionQuestions
Part 1Section 1: Core Python conceptsQ1–Q15
Part 1Section 2: Python coding problems with solutionsQ16–Q30
Part 2Section 3: Multithreading, multiprocessing and async programmingQ31–Q42
Part 2Section 4: FastAPI, Django and FlaskQ43–Q55
Part 2Section 5: Scenario-based and problem-solving questionsQ56–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.

Section 1: Core Python Interview Questions for Experienced Developers (Q1–Q15)

Q1 What is the difference between mutable and immutable objects? Why is a mutable default argument dangerous?

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"))

Output

['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.

Q2 What is the difference between is and ==?

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

Output

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.

Q3 What is the difference between a shallow copy and a deep copy?

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)

Output

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.

Q4 Explain *args, **kwargs, keyword-only and positional-only parameters.

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)

Output

[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.

Q5 What is the difference between a list comprehension and a generator expression?

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)

Output

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.

Q6 What is the difference between an iterator and a generator? What does yield from do?

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

Output

[3, 2, 1]
[3, 2, 1]
[1, 2, 'a', 'b', 2, 1]
2 1
done

Q7 How do decorators work? Write a decorator that takes arguments.

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__)

Output

[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).

Q8 What is a closure? Explain the late-binding problem in loops.

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])

Output

42
[2, 2, 2]
[0, 1, 2]

Q9 What are LEGB scope rules? When do you need global and nonlocal?

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()

Output

1 2 3
global counter: 2
UnboundLocalError: cannot access local variable 'counter' where it is not associated with a value

Q10 What is the difference between an instance method, @classmethod and @staticmethod?

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"))

Output

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.

Q11 What are dunder (magic) methods? How do dataclasses help?

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__)

Output

[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

Q12 What is the MRO? How does super() work with multiple inheritance?

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__])

Output

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.

Q13 How do try, except, else and finally work together? What is exception chaining?

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__))

Output

else: no error
finally: always runs
charged 500
finally: always runs
caught: could not charge the customer | cause: ValueError('amount must be positive')

Q14 What is a context manager? Write one with a class and with contextlib.

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)

Output

BEGIN order-1
insert order
COMMIT order-1
BEGIN order-2
ROLLBACK order-2 (stock not available)
inside: {'debug': True}
after: {'debug': False}

Q15 How does Python manage memory? What are __slots__?

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)

Output

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

Quick Revision: Q1–Q15

ConceptRemember this
Mutable defaultUse None and create the object inside the function
is vs ==is = same object; == = same value
CopyShallow shares nested objects; deepcopy copies everything
Parameters/ positional-only, * keyword-only, *args tuple, **kwargs dict
GeneratorsLazy, low memory, single pass
DecoratorsThree levels when they take arguments; always functools.wraps
ClosuresCapture variables, not values (late binding)
ScopesLEGB; nonlocal for enclosing, global for module level
classmethodAlternative constructors that also work for subclasses
dataclassGenerates __init__, __repr__, __eq__, ordering, frozen
MROsuper() = next class in the MRO
Exceptionselse on success, finally always, raise ... from keeps the cause
MemoryReference counting + cyclic GC; __slots__ saves memory

Section 2: Python Coding Interview Questions with Solutions (Q16–Q30)

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

Q16 Check whether a string is a palindrome, ignoring case, spaces and punctuation.

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))

Output

'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.

Q17 Find the first non-repeating character in a string.

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"))

Output

w
l
None

Complexity: O(n) time, O(k) space where k is the number of distinct characters.

Q18 Two Sum: return the indices of two numbers that add up to a target.

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))

Output

[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.

Q19 Group anagrams together.

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"]))

Output

[['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).

Q20 Check whether brackets in a string are balanced.

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)}")

Output

'{[()()]}'           -> True
'([)]'               -> False
'(('                 -> False
'print(items[0])'    -> True

Complexity: O(n) time, O(n) space. Real-world use: validating expressions, templates and JSON-like input.

Q21 Flatten a deeply nested list.

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))

Output

[1, 2, 3, 4, 5, 6, 7, 8]
[1, 2, 3, 4, 5, 6, 7, 8]

Q22 Find the top K most frequent elements.

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]))

Output

[('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.

Q23 Merge overlapping intervals.

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]]))

Output

[[1, 6], [8, 10], [15, 18]]
[[1, 5], [9, 12]]

Complexity: O(n log n) because of sorting, O(n) for the output.

Q24 Find the length of the longest substring without repeating characters.

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))

Output

'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.

Q25 Implement an LRU cache.

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())

Output

[('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.

Q26 Write Fibonacci in three ways. Which one would you use?

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))

Output

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.”

Q27 Parse log lines and report the error rate per endpoint.

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}%")

Output

/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).

Q28 Split a list into chunks of size n.

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

Output

[[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.

Q29 Deep-merge two dictionaries (for example, default and environment config).

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

Output

{'db': {'host': 'db.internal', 'port': 5432}, 'debug': False, 'workers': 8}
localhost
{'host': 'db.internal'}

Q30 Find the missing number in a list containing 1 to n.

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))

Output

3 3

Complexity: O(n) time and O(1) space for both.

Quick Revision: Coding Patterns from Q16–Q30

ProblemPatternTimeSpace
PalindromeTwo pointersO(n)O(1)
First unique characterCounter + second passO(n)O(k)
Two SumHash map of complementsO(n)O(n)
Group anagramsSorted word as dict keyO(n·k log k)O(n·k)
Balanced bracketsStackO(n)O(n)
Flatten listRecursive generator / explicit stackO(n)O(depth)
Top K frequentCounter + heapO(n log k)O(n)
Merge intervalsSort + linear scanO(n log n)O(n)
Longest unique substringSliding windowO(n)O(k)
LRU cacheOrderedDictO(1) per operationO(capacity)
FibonacciIteration / memoisationO(n)O(1)
Missing numberMath sum / XORO(n)O(1)

Tips for the Python Coding Round

  • Think aloud. A silent candidate with a perfect answer often scores lower than one who explains a good approach clearly.
  • Use the standard library. Counter, defaultdict, deque, heapq, bisect and itertools show experience, but be ready to explain what they do inside.
  • Name things well. seen, left, right and counts are better than a, b and c.
  • Always test an edge case: empty input, one element, duplicates, negative numbers.
  • State Big-O without being asked. It shows you think about scale.

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).

Final Thoughts

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.

Conclusion

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.

Written by

Saurabh Samir Technology & Learning Content Lead

Hey, I'm Saurabh Samir, a Software Developer and technology enthusiast with a passion for sharing knowledge on Python, JavaScript, React, Node.js, Salesforce, Agentforce, AI agents and Software Engineering. Through LearnFrenzy, I aim to simplify complex concepts, provide practical coding insights, and help developers prepare for technical interviews and grow their careers. Feel free to leave a comment below if you have any questions or feedback.

View profile

Get new articles in your inbox

One short email when we publish something new — interview questions, Salesforce guides and career tips. No spam, unsubscribe in one click.

We will email you a link to confirm. Unsubscribe any time.

Keep reading

More in Interview

Comments

Questions, corrections or your own experience — they all help the next reader.

Leave a comment

Comments are reviewed before they appear.

Search LearnFrenzy

Esc

Quick links