Python Interview Questions and Answers
35 hand-picked Python interview questions with
detailed answers. Open the interactive version above to search, filter
by difficulty, run code, bookmark questions and track your progress.
What is Python and what are its key features?
Python is a high-level, interpreted, dynamically typed language known for readability and a large standard library. Key features:
- Interpreted — runs via the interpreter (CPython is the reference); no separate compile step for development.
- Dynamically typed — types are checked at runtime, not declared upfront.
- Multi-paradigm — supports OOP, functional (map/filter/lambdas), and procedural styles.
- Batteries included — rich stdlib for files, HTTP, JSON, dates, threading, asyncio, etc.
- Strong ecosystem — Django, FastAPI, Flask, pandas, NumPy, pytest, Celery.
List vs tuple — when do you use each?
List — mutable, ordered sequence. Use when you need to add, remove, or change elements.
Tuple — immutable, ordered sequence. Use for fixed collections (coordinates, DB rows, dict keys), as return values, and when you want hashability (if all elements are hashable).
Both preserve insertion order (since Python 3.7+ for dicts; lists/tuples always did).
coords = (12.9, 77.6) # fixed — tuple
items = ["a", "b"] # will grow/shrink — list
items.append("c")
Dictionary vs set — key differences.
dict — key-value mappings; keys must be hashable and unique; O(1) average lookup by key. Use for records, caches, counts (with Counter).
set — unordered collection of unique hashable elements; O(1) average membership test. Use for deduplication, union/intersection, fast 'in' checks.
seen = set()
if user_id not in seen:
seen.add(user_id)
freq = {}
freq[key] = freq.get(key, 0) + 1
What is mutable vs immutable in Python?
Immutable — object cannot change after creation: int, float, str, tuple, frozenset, bytes. Operations create new objects.
Mutable — object can be modified in place: list, dict, set, custom objects (by default).
This matters for default arguments, copying, and thread safety.
s = "hi"
s += "!" # new str object; old "hi" unchanged
lst = [1, 2]
lst.append(3) # same list object, mutated in place
== vs is — what's the difference?
== compares values (calls __eq__). is compares identity — whether two names refer to the same object in memory.
Use is only for singletons like None, True, False. Never use is for strings or numbers (small-int caching can fool you).
a = [1, 2]
b = [1, 2]
a == b # True (same values)
a is b # False (different list objects)
x = None
x is None # correct idiom
Explain *args and **kwargs.
*args collects extra positional arguments into a tuple. **kwargs collects extra keyword arguments into a dict.
Order in a signature: positional params → *args → keyword-only params → **kwargs.
Also used to unpack when calling: func(*items), func(**options).
def log(level, *msgs, **meta):
for m in msgs:
print(level, m, meta)
log("INFO", "started", "ok", user=42)
opts = {"timeout": 5}
connect(host, **opts)
What is a list comprehension and when would you use it?
A concise way to build a list from an iterable with optional filtering: [expr for item in iterable if condition].
Also exists for dicts ({k: v for ...}) and sets. Prefer comprehensions over manual append loops for simple transforms — they're faster and more readable.
For large data or lazy evaluation, use a generator expression: (expr for item in iterable).
squares = [x * x for x in range(10)]
evens = [x for x in nums if x % 2 == 0]
# generator — no list allocated
sum(x * x for x in range(1_000_000))
lambda, map, and filter — how do they work?
lambda — anonymous one-expression function: lambda x: x * 2.
map(fn, iterable) — applies fn to each item; returns an iterator.
filter(fn, iterable) — keeps items where fn is truthy.
In modern Python, list comprehensions often replace map/filter for readability, but map/filter shine when composing with other iterators.
nums = [1, 2, 3, 4]
list(map(lambda x: x * 2, nums)) # [2, 4, 6, 8]
list(filter(lambda x: x % 2 == 0, nums)) # [2, 4]
# often clearer:
[x * 2 for x in nums]
What is a decorator in Python?
A decorator is a callable that wraps another function to extend or modify its behaviour without changing its source. Syntax: @decorator above a function definition.
Under the hood: @dec def f(): ... is equivalent to f = dec(f). Decorators are used for logging, timing, auth checks, caching (@lru_cache), and route registration in Flask/FastAPI.
from functools import wraps
def retry(fn):
@wraps(fn)
def wrapper(*a, **kw):
for _ in range(3):
try: return fn(*a, **kw)
except Exception: pass
raise
return wrapper
@retry
def fetch(): ...
What is a generator and how does yield work?
A generator is an iterator produced by a function containing yield. Each yield pauses the function, returns a value, and resumes on the next next() call. State (local variables, instruction pointer) is preserved between yields.
Generators are lazy — they produce values one at a time without building the full collection in memory. Ideal for large files, infinite sequences, and pipeline processing.
def read_lines(path):
with open(path) as f:
for line in f:
yield line.strip() # one line at a time
for line in read_lines("huge.log"):
process(line)
What is a context manager and the with statement?
A context manager guarantees setup and teardown around a block — typically acquiring and releasing a resource. The with statement calls __enter__ at the start and __exit__ at the end (even if an exception occurs).
Common uses: file handles, DB connections, locks, temporary directories. contextlib.contextmanager lets you write one with a generator and yield.
with open("data.txt") as f:
data = f.read()
# file closed automatically
from contextlib import contextmanager
@contextmanager
def timer():
start = time.perf_counter()
yield
print(time.perf_counter() - start)
How does exception handling work in Python?
try / except catches exceptions; else runs if no exception; finally always runs (cleanup). Catch specific types — except ValueError: — not bare except:.
Raise with raise ValueError('msg') or raise to re-raise. Custom exceptions subclass Exception. Use exception chaining: raise NewError() from original.
try:
val = int(user_input)
except ValueError as e:
logger.warning("bad input: %s", e)
val = 0
finally:
cleanup()
Explain classes, __init__, and inheritance in Python.
A class defines attributes and methods. __init__ is the constructor — called when an instance is created. self refers to the instance.
Inheritance — class Child(Parent):. Override methods; call parent with super().__init__(...) or super().method().
Python supports multiple inheritance; method resolution uses the MRO (C3 linearization).
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def speak(self):
return f"{self.name} says woof"
@staticmethod vs @classmethod vs @property.
@staticmethod — function in a class namespace; no self or cls. Utility grouping.@classmethod — receives cls; common for alternative constructors like from_dict(cls, data).@property — exposes a method as a read-only (or read-write with setter) attribute.
class User:
def __init__(self, email): self.email = email
@classmethod
def from_row(cls, row): return cls(row["email"])
@property
def domain(self): return self.email.split("@")[1]
__str__ vs __repr__ — what's the difference?
__repr__ — unambiguous, developer-facing representation; goal: eval(repr(obj)) should recreate the object when possible. Shown in the REPL and debuggers.
__str__ — human-readable, user-facing. Used by print() and str(). Falls back to __repr__ if not defined.
class Point:
def __init__(self, x, y): self.x, self.y = x, y
def __repr__(self): return f"Point({self.x}, {self.y})"
def __str__(self): return f"({self.x}, {self.y})"
What is the mutable default argument trap?
Default argument values are evaluated once at function definition time, not per call. A mutable default (list, dict, set) is shared across all calls.
Classic bug: def f(items=[]) — every call appends to the same list. Fix: use None and create a new object inside the function.
# BUG
def add(item, bucket=[]):
bucket.append(item)
return bucket
# FIX
def add(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
Shallow copy vs deep copy.
Assignment (b = a) — same object, two names.
Shallow copy — new container, but nested objects are still shared (copy.copy(), list.copy(), dict.copy()).
Deep copy — recursively copies nested objects (copy.deepcopy()).
import copy
a = [[1, 2], [3, 4]]
b = copy.copy(a) # outer list new, inner lists shared
c = copy.deepcopy(a) # fully independent
What is duck typing?
“If it walks like a duck and quacks like a duck, it's a duck.” Python cares about behaviour (methods/attributes), not the object's declared type. Any object with the right methods works — no inheritance required.
Modern Python adds typing.Protocol for structural subtyping — formalising duck typing for type checkers.
def save(writer):
writer.write(data) # anything with .write() works
# file, StringIO, custom class — all fine
What is the GIL and how does it affect concurrency?
The Global Interpreter Lock (GIL) is a mutex in CPython that allows only one thread to execute Python bytecode at a time. It simplifies memory management (reference counting) but limits CPU-bound parallelism with threads.
Implications:
- I/O-bound work (network, disk) — threads are fine; threads release the GIL while waiting.
- CPU-bound work — use
multiprocessing (separate processes, separate GILs) or offload to C extensions / NumPy. - Async I/O —
asyncio for many concurrent I/O operations on one thread.
Threading vs multiprocessing vs asyncio — when to use each?
| Approach | Best for | Notes |
|---|
| threading | I/O-bound, blocking libraries | GIL limits CPU parallelism; simple for concurrent HTTP/DB calls |
| multiprocessing | CPU-bound on multiple cores | Separate memory; use Pool or Process |
| asyncio | High-concurrency I/O | Single-threaded event loop; needs async libraries (aiohttp, asyncpg) |
# asyncio
async def fetch_all(urls):
async with aiohttp.ClientSession() as s:
tasks = [s.get(u) for u in urls]
return await asyncio.gather(*tasks)
What are type hints and do they affect runtime?
Type hints annotate expected types for parameters and return values: def greet(name: str) -> str:. They are not enforced at runtime by default — they're for static analysis (mypy, pyright), IDE autocomplete, and documentation.
Use Optional[T], list[str] (3.9+), Union, TypedDict, Protocol for richer contracts.
from typing import Optional
def find_user(id: int) -> Optional[dict]:
...
def process(items: list[str]) -> int:
return len(items)
What are dataclasses?
@dataclass auto-generates __init__, __repr__, __eq__ from class attributes. Reduces boilerplate for data-holding objects.
Options: frozen=True (immutable), slots=True (memory), order=True (comparison). For validation at scale, pair with Pydantic models.
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
What is a virtual environment and why use one?
A virtual environment is an isolated Python installation with its own packages, separate from the system Python. Created with python -m venv .venv; activated before installing deps.
Prevents version conflicts between projects. Dependencies are listed in requirements.txt or pyproject.toml and installed with pip install -r requirements.txt.
python -m venv .venv
source .venv/bin/activate # Linux/Mac
.venv\Scripts\activate # Windows
pip install -r requirements.txt
Flask vs FastAPI vs Django — when would you pick each?
- Flask — minimal microframework; you choose ORM, auth, structure. Good for small APIs, prototypes, full control.
- FastAPI — modern async API framework; automatic OpenAPI docs, Pydantic validation, high performance. Great for REST/JSON microservices.
- Django — batteries-included (ORM, admin, auth, migrations). Best for full web apps, CMS, rapid CRUD, monoliths.
# FastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{id}")
async def get_user(id: int): ...
How does SQLAlchemy ORM work at a high level?
SQLAlchemy maps Python classes to database tables. Define a Model with columns; the Session tracks changes and commits transactions.
Two layers: Core (SQL expression language) and ORM (object mapping). Use session.query(User).filter_by(email=...) or 2.0-style select(User).where(...).
Migrations are typically handled by Alembic.
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, unique=True)
user = session.query(User).filter_by(email="a@b.com").first()
How do you write tests with pytest?
pytest discovers test functions named test_*. Use plain assert — pytest rewrites assertions for clear failure output.
Fixtures (@pytest.fixture) provide setup/teardown and dependency injection. conftest.py shares fixtures across a package. Use monkeypatch, tmp_path, and pytest.mark.parametrize for parametrized tests.
import pytest
@pytest.fixture
def client():
app.config["TESTING"] = True
return app.test_client()
def test_health(client):
r = client.get("/health")
assert r.status_code == 200
How does Python manage memory?
CPython uses reference counting — when an object's refcount hits zero, it's freed immediately. A generational garbage collector handles circular references (refcount alone can't).
Objects live in a private heap. Interning caches small ints and some strings. For large data, prefer generators and __slots__ to reduce per-instance dict overhead.
import sys
sys.getrefcount(obj) # inspect refcount (debug only)
What does @functools.lru_cache do?
Memoizes function results — caches up to maxsize recent calls (LRU eviction). Arguments must be hashable. Huge win for expensive pure functions called repeatedly with the same inputs.
@lru_cache(maxsize=None) caches everything (unbounded). Use .cache_info() and .cache_clear() for introspection.
from functools import lru_cache
@lru_cache(maxsize=128)
def fib(n):
if n < 2: return n
return fib(n-1) + fib(n-2)
What does if __name__ == '__main__' mean?
Every Python file is a module. When run directly, __name__ is '__main__'. When imported, __name__ is the module path (e.g. 'utils.helpers').
The guard runs code only when the file is executed, not when imported — standard pattern for CLI entry points and quick tests.
def main():
print("running")
if __name__ == "__main__":
main()
global vs nonlocal — when are they needed?
By default, assignment inside a function creates a local variable. global x declares assignment targets the module-level x. nonlocal x targets the enclosing (non-global) scope — used in nested functions/closures.
Prefer returning values or mutable containers over mutating globals. Closures capturing variables by reference are usually cleaner than nonlocal.
count = 0
def outer():
n = 0
def inner():
nonlocal n
n += 1
return n
return inner
What is the Method Resolution Order (MRO)?
When a class inherits from multiple parents, Python uses C3 linearization to determine the order it searches for methods. View with ClassName.mro() or ClassName.__mro__.
Prevents the 'diamond problem' ambiguity — each class appears once, parents before children, preserving local precedence order.
class A: def f(self): return "A"
class B(A): pass
class C(A): pass
class D(B, C): pass
D.mro() # D, B, C, A, object
What does __slots__ do?
__slots__ restricts instance attributes to a fixed set and replaces the per-instance __dict__ with compact slot storage. Saves memory when creating millions of small objects.
Tradeoffs: can't add arbitrary attributes at runtime; multiple inheritance gets tricky; no weakrefs unless '__weakref__' is in slots.
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
How do background task queues like Celery work?
The web request enqueues a task to a message broker (Redis/RabbitMQ). Worker processes pull tasks and execute them asynchronously. Results can be stored in a backend (Redis, DB).
Use for: sending emails, image processing, report generation — anything slow that shouldn't block the HTTP response.
# tasks.py
@celery.task
def send_email(user_id):
...
# view
send_email.delay(user_id) # returns immediately
What is Pydantic and why is it popular?
Pydantic defines data models with type annotations and validates/parses input at runtime. Invalid data raises clear errors. Serialises to/from JSON dicts.
Core to FastAPI request/response validation. Pydantic v2 is Rust-backed (pydantic-core) — much faster than v1.
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
age: int
user = UserCreate(email="a@b.com", age=25)
What do enumerate and zip do?
enumerate(iterable) yields (index, value) pairs — avoids manual counter variables.
zip(a, b) pairs elements from multiple iterables. Stops at the shortest. Use zip(a, b, strict=True) (3.10+) to error on length mismatch.
names = ["Ada", "Bob"]
for i, name in enumerate(names):
print(i, name)
for a, b in zip([1, 2], ["x", "y"]):
print(a, b)