Python Backend Developer Top 100 Interview Questions
100 Python backend developer interview questions with structured answers, comparison tables, flow diagrams, worked calculations and runnable code. Covers the language core, concurrency and asyncio, web request handling, databases, caching, testing, security, performance and system design, from the basics to production scenarios.
Questions 1-20 of 100
Q1.What does a Python backend developer own beyond writing API endpoints?
A Python backend developer owns the whole path from a request to stored data and back, including how that path behaves under load and failure. Endpoints are only the visible part of the job.
- Domain logic: turning business rules into clear, tested code that does not depend on the web layer.
- Data access: schemas, queries, transactions and migrations that keep data correct as the system changes.
- Integration: calling other services and queues with timeouts, retries and clear error handling.
- Performance: knowing where the interpreter is slow, when to use concurrency, and how to measure before optimizing.
- Security: validating input, protecting secrets, binding query parameters and keeping dependencies patched.
- Operations: logging, metrics, health checks, graceful shutdown and packaging the service so it runs the same everywhere.
Good Python backend engineers also make the codebase easy for the next person: consistent structure, type hints that tools can check, and tests that run quickly. The language makes it easy to write code fast, so discipline about structure matters more, not less.
Q2.What happens when the Python interpreter runs a module?
The interpreter compiles the source into bytecode, then a virtual machine executes that bytecode one instruction at a time. Python code is never run directly as text.
flowchart LR A["Source file"] --> B["Parse into syntax tree"] B --> C["Compile to bytecode"] C --> D["Cache bytecode for imported modules"] C --> E["Virtual machine executes bytecode"] E --> F["Objects and results in memory"]- Parsing: the source becomes an abstract syntax tree, and syntax errors are reported at this stage.
- Compilation: the tree becomes bytecode for each function and the module body.
- Caching: for imported modules, the reference interpreter writes the bytecode to a cache folder, so later imports skip compilation when the source has not changed.
- Execution: the evaluation loop runs the bytecode, creating objects, calling functions and handling exceptions.
Why it matters in practice:
- Importing a module runs its top-level code, so slow work at import time slows every process start.
- Recent versions specialize frequently executed bytecode for speed, but heavy numeric loops still belong in native code or optimized libraries.
Q3.How does Python pass arguments to functions?
Python passes references to objects, sometimes called pass-by-object-reference or call by sharing. The function gets a new name bound to the same object the caller passed.
- Mutating the object inside the function, such as appending to a list, is visible to the caller, because both names point to one object.
- Rebinding the parameter to a new object, such as
tags = [], only changes the local name; the caller's variable is untouched. - Immutable objects such as integers, strings and tuples cannot be changed in place, so a function can never alter the caller's value through them.
Practical rules for backend code:
- Do not mutate input arguments unless the function's name and documentation say so; return a new value instead.
- Copy input collections before changing them when the caller may reuse them.
- Be careful with objects shared across requests, such as module-level lists or dictionaries, since any request can change them for all others.
Example
The list is changed by mutation, while rebinding inside the second function has no effect on the caller.
def add_tag(tags): tags.append("new") def replace_tags(tags): tags = ["other"] items = ["a"] add_tag(items) replace_tags(items) print(items) # ['a', 'new']Q4.Why is a mutable default argument a common bug, and how do you fix it?
Default argument values are evaluated once, when the function is defined, not on every call. A mutable default such as a list or dictionary is therefore shared by every call that does not pass that argument.
- Each call that relies on the default mutates the same object, so data leaks from one call into the next.
- In a web service, this can mix data between requests from different users, which is both a bug and a privacy risk.
- The fix is to use
Noneas the default and create a fresh object inside the function.
Related traps:
- Defaults computed from the current time, such as a timestamp, are also fixed at definition time.
- Class attributes that are mutable are shared by all instances in the same way.
- Static analysis tools and linters flag mutable defaults, so turn that rule on in the project.
Example
The first function shares one list across calls; the fixed version creates a new list each time.
def add_item_bad(item, bucket=[]): bucket.append(item) return bucket def add_item(item, bucket=None): if bucket is None: bucket = [] bucket.append(item) return bucket print(add_item_bad("a")) # ['a'] print(add_item_bad("b")) # ['a', 'b'] print(add_item("a")) # ['a'] print(add_item("b")) # ['b']Q5.How do lists, tuples, sets and dictionaries differ, and when do you use each?
They are the four core built-in collections, and they differ in ordering, mutability and lookup speed. Choosing the right one is often the simplest performance and correctness win in Python code.
List Tuple Set Dictionary Ordered, mutable sequence Ordered, immutable sequence Unordered group of unique items Key-value mapping that keeps insertion order Allows duplicates Allows duplicates Drops duplicates Keys are unique Membership check scans every item Membership check scans every item Average constant-time membership Average constant-time lookup by key Use for ordered items you change Use for fixed records and as dictionary keys Use for uniqueness and fast membership Use for lookups by key When to use which:
- Use a list for ordered data you append to or sort, such as rows returned from a query.
- Use a tuple for a fixed group of values, such as a composite key, because it is hashable when its items are.
- Use a set to remove duplicates or to test membership many times.
- Use a dictionary to index records by an identifier instead of searching a list repeatedly.
Q6.How does a dictionary look up a key, and what makes an object hashable?
A dictionary is a hash table: it computes the key's hash, uses it to find a slot, then confirms the match with equality. That is why lookups take constant time on average.
- Hash: Python calls the key's
__hash__method to get an integer. - Probe: the hash picks a slot; if the slot holds a different key, the table probes further slots.
- Compare: keys with the same hash are compared with
__eq__to find the exact match.
What makes an object hashable:
- It has a hash that never changes while it is used as a key.
- Objects that compare equal must have equal hashes.
- Built-in immutable types such as strings, numbers and tuples of hashable items qualify; lists, sets and dictionaries do not.
Practical consequences:
- If you define
__eq__on a class, also define__hash__, or Python makes instances unhashable. - Changing an object's fields after using it as a key can make it impossible to find again.
- Many keys with colliding hashes degrade lookups toward linear time, which is why hash randomization protects against attackers who choose keys.
- Hash: Python calls the key's
Q7.What is the difference between `is` and `==` in Python?
==checks equality, meaning whether two objects have the same value as defined by their__eq__method.ischecks identity, meaning whether two names refer to the very same object in memory.==isChecks equality through the __eq__methodChecks identity: the very same object [1, 2] == [1, 2]is True[1, 2] is [1, 2]is FalseUse for numbers, strings and collections Use for None,True,Falseand your own sentinel objectsWhy
issometimes appears to work for values:- The reference interpreter caches some small integers and may reuse some short strings, so
a is bcan be true for them by accident. - That behavior is an implementation detail and changes between versions and situations, so code that relies on it is a bug waiting to happen.
Interview tip: explain why
x is Noneis preferred overx == None; a class can override__eq__to return anything, but identity with the singleNoneobject cannot be faked.- The reference interpreter caches some small integers and may reuse some short strings, so
Q8.What is the difference between a shallow copy and a deep copy?
A shallow copy creates a new outer container but keeps references to the same inner objects. A deep copy recursively copies every nested object, so the copy shares nothing mutable with the original.
Shallow copy Deep copy New outer container, same inner objects Recursively copies every nested object list(items),dict(config), slicing orcopy.copycopy.deepcopy, which handles shared and circular referencesBest when inner objects are immutable, such as strings Best when you will modify nested structures Pitfalls:
- Changing a nested list through a shallow copy also changes it in the original, which surprises many developers.
- A per-request copy of a configuration template needs a deep copy if handlers change nested values.
- Deep copies are slower and use more memory, so for large structures consider building a new object from only the fields you need.
Example
Appending through the shallow copy changes the original's inner list, while the deep copy stays independent.
import copy config = {"retries": 3, "hosts": ["a", "b"]} shallow = copy.copy(config) deep = copy.deepcopy(config) shallow["hosts"].append("c") shallow["retries"] = 5 print(config["hosts"]) # ['a', 'b', 'c'] print(config["retries"]) # 3 print(deep["hosts"]) # ['a', 'b']Q9.How do generators work, and why are they useful in backend code?
A generator is a function that uses
yieldto produce values one at a time. Calling it returns an iterator, and eachnextcall runs the function until the nextyield, then pauses with its local state saved.- Memory: only the current item exists in memory, so you can process files, query results or streams far larger than available RAM.
- Pipelines: small generators can be chained, each doing one step such as parsing, filtering or batching.
- Laziness: work happens only when a consumer asks for the next item, and stops early if the consumer stops.
Things to watch:
- A generator can be consumed only once; iterating it again yields nothing.
- Exceptions inside a generator surface when the consumer asks for the item that failed.
- Resources opened inside a generator, such as a file, should be managed with
with, so they close even if the consumer stops early.
Example
Two chained generators filter error lines without ever building an intermediate list.
def stripped(lines): for line in lines: yield line.strip() def errors_only(lines): for line in lines: if " ERROR " in line: yield line log = ["10:00 INFO start\n", "10:01 ERROR db timeout\n", "10:02 INFO ok\n", "10:03 ERROR retry failed\n"] pipeline = errors_only(stripped(log)) print(next(pipeline)) # 10:01 ERROR db timeout print(list(pipeline)) # ['10:03 ERROR retry failed']Q10.How do list comprehensions and generator expressions differ?
They share almost the same syntax, square brackets for a list comprehension and parentheses for a generator expression, but they evaluate differently. One builds the whole result immediately; the other produces items on demand.
List comprehension Generator expression Builds the whole list immediately Produces items one at a time, when asked Memory grows with the size of the result Memory stays small and constant Can be iterated many times and indexed Can be iterated only once Best when you need all results or reuse them Best for streaming large data into one consumer When to use which:
- Use a generator expression when passing the result straight to a function that consumes it once, such as
sum(order.total for order in orders). - Use a list comprehension when you need the length, indexing, sorting or several passes over the data.
- Keep both short and readable; when the logic needs several conditions or nested loops, a normal loop or a named generator function is clearer.
- Use a generator expression when passing the result straight to a function that consumes it once, such as
Q11.How do decorators work, and how would you write a retry decorator?
A decorator is a function that takes a function and returns a replacement, usually a wrapper that adds behavior before or after calling the original. The
@namesyntax is shorthand forfunc = name(func).- Wrapping: the wrapper accepts
*argsand**kwargsso it works for any signature. - Metadata:
functools.wrapscopies the original name and docstring onto the wrapper, which keeps logs, debugging and documentation correct. - Parameters: a decorator that takes its own arguments, such as
@retry(times=3), is a function that returns the actual decorator.
Common backend uses: retries, timing, caching, access checks and registering route handlers.
Rules for a retry decorator:
- Retry only errors that are likely to be temporary, such as connection failures, never validation errors.
- Wait longer after each attempt and cap the number of attempts.
- Re-raise the last error so callers still see the failure.
- Retry only operations that are safe to repeat.
Example
The decorator retries only connection errors, so the flaky call succeeds on its third attempt.
import functools import time def retry(times, delay=0.0, exceptions=(Exception,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, times + 1): try: return func(*args, **kwargs) except exceptions: if attempt == times: raise time.sleep(delay * 2 ** (attempt - 1)) return wrapper return decorator calls = {"count": 0} @retry(times=3, exceptions=(ConnectionError,)) def fetch_profile(user_id): calls["count"] += 1 if calls["count"] < 3: raise ConnectionError("temporary failure") return {"id": user_id, "name": "Asha"} print(fetch_profile(7)) # {'id': 7, 'name': 'Asha'} print(calls["count"]) # 3 print(fetch_profile.__name__) # fetch_profile- Wrapping: the wrapper accepts
Q12.How do context managers work, and when would you write your own?
A context manager defines setup and cleanup around a block of code used with
with. Python calls__enter__at the start and__exit__at the end, even when the block raises an exception.sequenceDiagram participant C as Calling code participant M as Context manager participant B as With block C->>M: __enter__ M-->>C: Resource C->>B: Run the block B-->>C: Returns or raises C->>M: __exit__ with the exception details, or None M-->>C: Cleanup done, true only to suppress the error- Guaranteed cleanup: files, locks, database transactions and network connections are released however the block exits.
- Exception handling:
__exit__receives the exception and can log it, roll back a transaction or, rarely, suppress it by returning true. - Simple version: the
contextlib.contextmanagerdecorator turns a generator with oneyieldinto a context manager, with cleanup in afinallyblock.
When to write your own:
- Pairing operations that must always happen together, such as acquire and release or begin and commit.
- Temporarily changing state, such as a setting in a test, and restoring it afterwards.
- Timing a block of code for metrics.
For several resources at once,
contextlib.ExitStackmanages a variable number of them and closes each one in reverse order.Example
The timing context manager records a duration even though the block raises an error.
import time from contextlib import contextmanager @contextmanager def timed(label, results): start = time.perf_counter() try: yield finally: results[label] = time.perf_counter() - start results = {} try: with timed("query", results): raise TimeoutError("database timeout") except TimeoutError as error: print("caught:", error) # caught: database timeout print("query" in results) # TrueQ13.How does variable scope work in Python, and what do `global` and `nonlocal` do?
Python resolves a name by searching four scopes in order: local, enclosing function, global module and built-in. A name assigned anywhere inside a function is local to that function unless declared otherwise.
globallets a function assign to a module-level name. In backend code it usually signals shared mutable state, which is risky across requests and threads.nonlocallets an inner function assign to a variable of its enclosing function, which is how closures keep counters or caches.- Closures capture variables, not values, so an inner function sees the variable's value at the time it runs.
A classic trap: functions created in a loop all see the loop variable's final value, because they share one variable. Binding the current value as a default argument, or using a factory function, captures it at creation time.
Assigning to a name before reading it in the same function makes it local everywhere in that function. Reading it first then raises an
UnboundLocalError.Example
The first list of functions shares one loop variable; binding a default captures each value, and
nonlocalupdates the enclosing counter.handlers = [lambda: i for i in range(3)] print([handler() for handler in handlers]) # [2, 2, 2] handlers = [lambda i=i: i for i in range(3)] print([handler() for handler in handlers]) # [0, 1, 2] def counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment next_id = counter() next_id() print(next_id()) # 2Q14.How should a Python service raise, chain and handle exceptions?
Raise specific, meaningful exceptions where an error is detected, and handle them only where you can do something useful, usually at the boundary that turns them into a response. Swallowing errors silently is worse than crashing.
- A small hierarchy: define a base error for the service and subclasses such as
NotFoundErrorandConflictError, so callers can catch by category. - Chaining: use
raise DomainError(...) from errorwhen translating a low-level failure, so the original cause stays in the traceback. - Catch narrowly: catch the exceptions you expect, never a bare
except:, which also catches interrupts and system exits. - Log once: log the full traceback at the boundary that handles the error, not at every level it passes through.
- Clean up: use
withorfinallyfor resources rather than relying on exception handlers.
Python favors EAFP, "easier to ask forgiveness than permission": try the operation and handle the failure, instead of checking every condition first. This also avoids race conditions between a check and the action.
When several concurrent tasks fail together, recent versions group them in an
ExceptionGroup, handled withexcept*.- A small hierarchy: define a base error for the service and subclasses such as
Q15.When do you use a dataclass, a named tuple, a typed dictionary or a plain class?
All four describe structured data, but they differ in mutability, behavior and whether the structure exists at runtime or only for type checkers.
Dataclass Named tuple Typed dictionary Plain class Generates init, repr and comparison methods from annotated fields An immutable tuple with named fields A dictionary shape described for type checkers Full manual control over behavior Mutable by default, or frozen on request Immutable, and unpackable like a tuple An ordinary mutable dictionary at runtime Mutable unless you prevent it No runtime type checking of fields No runtime type checking No runtime checking at all Validates only what you code Best for internal domain objects Best for small fixed records Best for JSON-like payloads Best for rich behavior and invariants When to use which:
- Use a dataclass as the default for internal records; mark it frozen when instances should not change.
- Use a named tuple when you need tuple behavior, such as unpacking or use as a dictionary key.
- Use a typed dictionary when the data really is a dictionary, such as a decoded request body.
- Use a plain class when the object enforces rules in its methods.
Remember that type annotations are not validation; incoming data still needs explicit checks.
Q16.What do type hints do at runtime, and how do teams use them?
At runtime, type hints do almost nothing: Python stores them as annotations but does not enforce them. Their value comes from tools that read them, such as static type checkers, editors and libraries that build validation or serialization from them.
- Static checking: a type checker in the build catches wrong argument types, missing
Nonehandling and typos before the code runs. - Documentation: signatures show what a function expects and returns, which helps every reader.
- Structural typing: a
Protocoldescribes the methods an object needs, so any matching class fits without inheritance. - Generics: type variables describe relationships, such as a function that returns the same type it received.
How teams adopt them:
- Type public functions and new code first, rather than the whole codebase at once.
- Run the type checker in the pipeline, starting lenient and tightening over time.
- Validate external input explicitly, because annotations alone never reject bad data at runtime.
Avoid
Anyas a habit; it switches off checking for everything it touches.- Static checking: a type checker in the build catches wrong argument types, missing
Q17.How do special methods let your classes work with built-in operations?
Special methods, often called dunder methods for their double underscores, are hooks that Python calls for built-in syntax and functions. Implementing them makes your objects behave like built-in types.
- Representation:
__repr__gives an unambiguous developer view used in logs and debuggers;__str__gives a user-facing one. - Comparison and hashing:
__eq__defines equality, and__hash__must be consistent with it for use in sets and dictionary keys. - Operators:
__add__,__lt__and friends leta + band sorting work. - Containers:
__len__,__iter__,__contains__and__getitem__supportlen, loops,inand indexing. - Context management:
__enter__and__exit__supportwith.
Rules that avoid subtle bugs:
- Return
NotImplementedfrom a comparison when the other object is of an unsupported type, so Python can try the reverse operation. - Keep the fields used by
__hash__immutable. - Always define a useful
__repr__; it pays off in every log line.
Example
A small money type supports addition, equality, hashing and a readable representation.
class Money: def __init__(self, cents, currency): self.cents = cents self.currency = currency def __repr__(self): return f"Money({self.cents}, {self.currency!r})" def __eq__(self, other): if not isinstance(other, Money): return NotImplemented return (self.cents, self.currency) == (other.cents, other.currency) def __hash__(self): return hash((self.cents, self.currency)) def __add__(self, other): if self.currency != other.currency: raise ValueError("currency mismatch") return Money(self.cents + other.cents, self.currency) total = Money(250, "INR") + Money(100, "INR") print(total) # Money(350, 'INR') print(total == Money(350, "INR")) # True print(len({Money(1, "INR"), Money(1, "INR")})) # 1- Representation:
Q18.How does method resolution order work with multiple inheritance?
The method resolution order is the sequence of classes Python searches when looking up an attribute. Python computes it with an algorithm called C3 linearization, which keeps every class before its parents and preserves the order you list the bases in.
ClassName.__mro__shows the order explicitly.super()does not mean "the parent"; it means the next class in the resolution order of the actual object.- That is what makes cooperative multiple inheritance work: each class calls
super(), and every class in the chain runs exactly once.
Practical guidance:
- Use multiple inheritance mainly for small mixins that add one behavior, such as auditing or serialization.
- Make every class in a cooperative chain accept and pass on
**kwargs, so constructors with different arguments still work. - Prefer composition, holding a collaborator as an attribute, when the classes are not genuinely variations of one type.
If Python cannot build a consistent order, it raises a
TypeErrorwhen the class is defined.Example
Each
savecall passes along the resolution order, so all four classes run once and in a predictable sequence.class Base: def save(self): return ["Base"] class Audited(Base): def save(self): return ["Audited"] + super().save() class Cached(Base): def save(self): return ["Cached"] + super().save() class Repository(Audited, Cached): def save(self): return ["Repository"] + super().save() print([cls.__name__ for cls in Repository.__mro__]) # ['Repository', 'Audited', 'Cached', 'Base', 'object'] print(Repository().save()) # ['Repository', 'Audited', 'Cached', 'Base']Q19.When would you use an abstract base class instead of a protocol?
Both define an interface, but an abstract base class works through inheritance and runtime enforcement, while a protocol works through structural typing: any class with the right methods qualifies.
Abstract base class Protocol Subclasses must inherit from it explicitly Any class with matching methods fits, without inheritance Instantiating a subclass with missing abstract methods raises an error Checked by static type checkers; runtime checks only when marked runtime-checkable Can share implementation through concrete methods Describes the required shape only Fits when you own the class hierarchy Fits for third-party classes and loosely coupled code When to use which:
- Use an abstract base class for a plugin system you control, where shared helper methods and a hard runtime check are valuable.
- Use a protocol for dependencies such as "anything with a
send(message)method", so tests can pass simple fakes and existing classes fit without changes.
A runtime-checkable protocol only checks that the methods exist, not their signatures, so do not rely on it as validation.
Q20.How does Python manage memory?
The reference interpreter frees most objects through reference counting, and a separate cyclic garbage collector cleans up groups of objects that refer to each other. You rarely free memory yourself, but you can easily keep objects alive by accident.
- Reference counting: every object tracks how many references point to it, and it is freed as soon as the count reaches zero.
- Cycle collection: objects that reference each other, such as a parent and child pointing to one another, never reach zero on their own, so the collector finds and frees unreachable cycles periodically.
- Memory pools: small objects come from internal pools, so freed memory is often reused by Python rather than returned to the operating system immediately.
How leaks happen in backend services:
- Caches and module-level collections that grow without limits.
- References held in closures, registered callbacks or long-lived task objects.
- Large objects kept alive by a traceback stored in a log record or variable.
The standard
tracemallocmodule compares memory snapshots to show which lines allocate the memory that keeps growing.