Vibe Engines
Visual Handbook · 2026
50 Questions · 8 Domains
Interview Preparation & Reference

50 Python
Interview
Questions

Detailed answers, visual diagrams & interview tips.
Everything you need to work confidently in Python.

Fundamentals Data Structures Functions OOP Generators Concurrency Testing Pythonic Patterns
Python Fundamentals
Q1–7
Data Structures
Q8–14
Functions & Scope
Q15–21
OOP in Python
Q22–28
Iterators & Generators
Q29–34
Concurrency & Perf
Q35–41
Errors & Testing
Q42–46
Pythonic Patterns
Q47–50
Saurabh Singh
AI Engineer & Builder.
Contents

What's
Inside

Python Fundamentals
Q1–7
Q1What is Python and what are its key features?
Q2Differences between Python 2 and Python 3?
Q3What are Python's built-in data types?
Q4Mutable vs immutable objects?
Q5How does Python manage memory?
Q6What is PEP 8 and why does it matter?
Q7Difference between is and ==?
Data Structures
Q8–14
Q8List vs Tuple vs Set vs Dict — when to use each?
Q9How does a Python dict work internally?
Q10What are list / dict / set comprehensions?
Q11Shallow vs deep copy — what's the difference?
Q12What's in the collections module?
Q13How does slicing work?
Q14Time complexities of common operations?
Functions & Scope
Q15–21
Q15What are *args and **kwargs?
Q16What are lambda functions?
Q17What are closures?
Q18What are decorators?
Q19What is the LEGB rule?
Q20Positional vs keyword vs default arguments?
Q21What does "first-class functions" mean?
OOP in Python
Q22–28
Q22What are classes and objects in Python?
Q23What is inheritance — types in Python?
Q24classmethod vs staticmethod vs instance method?
Q25What are dunder (magic) methods?
Q26What is MRO (Method Resolution Order)?
Q27Abstract classes & protocols?
Q28Difference between __new__ and __init__?
Iterators & Generators
Q29–34
Q29What is an iterable vs an iterator?
Q30Generators and the yield keyword?
Q31Generator vs list — memory implications?
Q32What is itertools?
Q33Context managers and the with statement?
Q34How does the for loop work internally?
Concurrency & Perf
Q35–41
Q35What is the GIL (Global Interpreter Lock)?
Q36Multithreading vs Multiprocessing vs Async?
Q37How does asyncio and the event loop work?
Q38What are coroutines?
Q39How do you profile and optimize Python?
Q40What is __slots__ and when to use it?
Q41What causes memory leaks in Python?
Errors & Testing
Q42–46
Q42How does try/except/else/finally work?
Q43How do you create custom exceptions?
Q44Assertions vs exceptions?
Q45unittest vs pytest — which and why?
Q46What is mocking and when to use it?
Pythonic Patterns
Q47–50
Q47Common Python gotchas?
Q48What does "Pythonic" mean?
Q49What is duck typing?
Q50What's new in modern Python (3.10+)?
Part I

Python Fundamentals

The language itself — how it's designed, how it handles memory, and the small rules that trip up beginners but define how fluent Python developers think.

Interpreter
Data Types
Memory
Mutability
PEP 8
Identity
Questions 1–7
Q1

What is Python and what are its key features?

Python is a high-level, interpreted, dynamically-typed, general-purpose programming language created by Guido van Rossum in 1991. Its design philosophy — codified in The Zen of Python — emphasizes readability, simplicity, and "one obvious way to do it."

The key features interviewers expect you to know:

WHAT MAKES PYTHON, PYTHON
01 · Interpreted
Source is compiled to bytecode (.pyc), executed by the CPython VM. No separate compile step.
02 · Dynamically Typed
Variables are names bound to objects; types are checked at runtime, not compile time.
03 · Multi-Paradigm
Supports procedural, object-oriented, and functional styles — mix and match freely.
04 · Batteries Included
Rich standard library (os, json, datetime, re, itertools, asyncio) plus PyPI ecosystem.

Python's trade-offs: slower runtime than compiled languages (C, Rust, Go) because of interpretation and dynamic dispatch, and the GIL limits CPU-bound multithreading. For most web, data, and ML workloads, developer productivity wins over raw speed — and hot paths can be dropped into C extensions (NumPy, PyTorch) when needed.

→ Interview Tip
Don't recite a Wikipedia list. Lead with dynamically typed, interpreted, multi-paradigm, then show you know the trade-off: the GIL and interpreter overhead are why Python partners with C/Rust extensions for performance.
Q2

What are the differences between Python 2 and Python 3?

Python 2 was sunset on January 1, 2020. Every new project uses Python 3, but interviewers still ask this to test historical awareness and understanding of why breaking changes happened.

FeaturePython 2Python 3
printStatement: print "hi"Function: print("hi")
Integer division5/2 = 25/2 = 2.5 (use // for floor)
Stringsstr = bytes, unicode separatestr = unicode, bytes separate
range()Returns a listReturns a lazy iterator
input()raw_input() for stringsinput() returns str
Exceptionsexcept E, e:except E as e:
Type hintsNot supportedPEP 484 annotations supported

The driving motivation for the split was Unicode. Python 2 mixing bytes and text caused endless encoding bugs — Python 3 forces them to be different types, so you can't concatenate them by accident.

→ Mental Model
The real story isn't "they changed print" — it's "they fixed strings." Every other breaking change is small; the bytes/unicode split is the one that made the migration worth the pain.
Q3

What are Python's built-in data types?

Python's built-in types fall into a handful of families. Knowing which are mutable, ordered, and hashable is what separates confident from approximate answers.

CategoryTypesMutable?Hashable?
Numericint, float, complex, boolNoYes
TextstrNoYes
Binarybytes, bytearray, memoryviewbytes: No / others: Yesbytes only
Sequencelist, tuple, rangelist onlytuple (if contents are)
MappingdictYesNo
Setset, frozensetset onlyfrozenset only
NoneNoneTypeN/A (singleton)Yes

Hashable means usable as a dict key or set member. Any object is hashable if its hash never changes over its lifetime — which is why mutable containers are not. A tuple of lists, for example, is not hashable because its contents can change.

bool is a subclass of int (True == 1, False == 0). None is a singleton — always compare with is None, never == None.

→ Interview Tip
If asked "can I use X as a dict key?" the answer is only if it's hashable — and hashable requires immutability. That single idea handles 90% of follow-up questions in this area.
Q4

What is the difference between mutable and immutable objects?

Mutable objects can change their internal state after creation. Immutable objects cannot — any "change" produces a new object at a new memory address.

MUTABLE VS IMMUTABLE
Immutable
int, float, bool
str, bytes
tuple, frozenset
None
Safe as dict keys · thread-safe · "assignment" = new object
Mutable
list
dict
set, bytearray
custom objects (default)
Cannot be dict keys · aliasing risks · modify in place

Consider this subtle behavior:

x = 5; y = x; x += 1x=6, y=5 (int is immutable, += rebinds x to a new object)

a = [1]; b = a; a += [2]a=[1,2], b=[1,2] (list is mutable, += modifies in place — a and b are the same list)

This aliasing behavior is the root cause of most "why did my other variable change?" bugs.

→ Mental Model
Variables are name tags, not boxes. Assignment sticks the tag on an object. With immutable objects, operations produce new objects — tags get moved. With mutable objects, operations change the object itself — all tags still point at it.
Q5

How does Python manage memory?

CPython manages memory automatically through three cooperating mechanisms: a private heap, reference counting, and a cyclic garbage collector.

PYTHON MEMORY LIFECYCLE
📦
Step 01
Allocate
Object placed in private heap; pymalloc manages small blocks
🔢
Step 02
Refcount++
Each reference (name, container, arg) increments ob_refcnt
📉
Step 03
Refcount--
When a reference goes out of scope or is rebound
🗑️
Step 04
Free
At refcount 0 the object is immediately deallocated
🔄
Step 05
GC Sweep
Cyclic collector catches reference cycles refcount can't

Reference counting is the primary mechanism: every object has a counter; when it reaches zero, the memory is reclaimed immediately. This makes deallocation deterministic — unlike Java or Go, a __del__ tends to run predictably at end-of-scope.

Refcounting's blind spot is cycles: a.ref = b; b.ref = a — neither ever reaches zero even after all external names are gone. The gc module runs periodically to detect unreachable cycles and break them.

Small integers (-5 to 256) and short strings are interned (cached and reused), which is why a = 5; b = 5; a is b is True but a = 1000; b = 1000; a is b may be False.

→ Real-World Use
If you see suspicious memory growth, check for reference cycles involving __del__ methods or large closures. gc.get_objects() and tracemalloc are your friends in production debugging.
Q6

What is PEP 8 and why does it matter?

PEP 8 is Python's official style guide, written in 2001 by Guido van Rossum and Barry Warsaw. It's not a language spec — it's a convention document that makes Python code look like Python across projects and teams.

The highlights worth memorizing:

RuleConventionExample
Indentation4 spaces, no tabs return x
Line length79 chars (72 for docstrings)Modern teams use 88 or 100
Variables / functionssnake_caseuser_count, get_user()
ClassesPascalCaseUserAccount
ConstantsUPPER_SNAKEMAX_RETRIES = 3
PrivateLeading underscore_internal_helper()
ImportsStdlib → third-party → local; one per lineGrouped with blank lines
Blank lines2 around top-level defs, 1 around methods

PEP 8 matters because Python doesn't have braces — readability is its whole point. Inconsistent style in Python looks broken in a way it wouldn't in Java. Modern teams enforce PEP 8 with ruff, black, or flake8 in CI, so style arguments never happen.

→ Interview Tip
PEP 8's own first rule — "A Foolish Consistency is the Hobgoblin of Little Minds" — says breaking style is fine when readability wins. Quote that if asked whether you always follow PEP 8; it shows you've actually read it.
Q7

What is the difference between is and ==?

== compares values (calls __eq__). is compares identity — whether two names point to the same object in memory (same id()).

IDENTITY VS EQUALITY
== (equality)
Calls __eq__
Can be overridden
"Same value?"
Use for data comparison
is (identity)
Compares id()
Cannot be overridden
"Same object?"
Use for singletons

Common traps:

[1,2,3] == [1,2,3]True (same value)
[1,2,3] is [1,2,3]False (different list objects)

a = 5; b = 5; a is bTrue (small int interning)
a = 1000; b = 1000; a is bFalse in most contexts (no interning)

Always use is for None, True, and False. These are singletons — there's only ever one of each, so is None is both faster and safer than == None, which can be broken by a misbehaving __eq__.

→ Interview Tip
If you ever write == None in a code review, expect it flagged. is None is idiomatic, atomic under threading, and cannot be fooled by overridden equality. Same for checking singletons like sentinel objects.
Part II

Data Structures

Every non-trivial Python program is a choreography of lists, dicts, sets, and tuples. Picking the right one — and knowing its cost — is the single biggest leverage point for clean, fast code.

Lists & Tuples
Dict Internals
Sets
Comprehensions
Copy
Complexity
Questions 8–14
Q8

List vs Tuple vs Set vs Dict — when to use each?

The four core containers each optimize for a different shape of problem. Choosing correctly is half the craft of Python.

TypeOrdered?Mutable?Duplicates?Use When
list [1,2,3]YesYesYesOrdered, growable sequence
tuple (1,2,3)YesNoYesFixed record, dict key, return value
set {1,2,3}No*YesNoMembership tests, deduping, set math
dict {k:v}Insertion order (3.7+)YesKeys: NoKey→value lookup

Lists are your default sequence. They allow mutation, duplicates, and are cheap to append (amortized O(1)). Tuples signal "this is a fixed record — don't reassign fields." They're hashable so they can be dict keys, and pack/unpack makes them the natural return type for multiple values.

Sets trade ordering for O(1) membership and natural set operations (|, &, -). If you ever write if x in some_list on a list of 10k+ elements, you almost certainly want a set. Dicts are hash tables — by far the most used container in Python, the backbone of objects, modules, and JSON.

→ Mental Model
Ask: "Do I need order? Can I have duplicates? Am I doing membership checks a lot?" Order + duplicates = list. No duplicates + fast in = set. Key-to-value = dict. Fixed-size record = tuple.
Q9

How does a Python dict work under the hood?

A dict is an open-addressing hash table with perturbation probing. In Python 3.6+ it uses a compact + ordered layout that stores keys and values in a dense array and the hash table holds indices into that array.

DICT LOOKUP FLOW
🔑
Step 01
hash(key)
Compute hash via __hash__
🎯
Step 02
Index
hash % table_size
📍
Step 03
Probe
On collision, perturb & retry
Step 04
Compare
Check __eq__ on candidate
📦
Step 05
Return
Fetch value from entries[]

Each slot stores (hash, key, value). Lookup computes the hash, indexes into the table, and compares. If the slot is occupied by a different key (collision), Python follows a deterministic probe sequence using a perturbation value derived from the hash, guaranteeing every slot can be visited.

The table resizes when it's 2/3 full — doubling (or shrinking) and rehashing all entries. This is why average dict insert/lookup is O(1), with O(n) worst-case on pathological collisions.

Since Python 3.7, dict insertion order is guaranteed by language spec (was CPython implementation detail in 3.6). This is made possible by the compact layout: iteration walks the dense entries array in order.

→ Key Insight
Two keys are equal in a dict iff hash(k1) == hash(k2) AND k1 == k2. That's why if you override __eq__ in a class, you must also override __hash__ — otherwise instances become un-findable in dicts/sets.
Q10

What are list / dict / set comprehensions?

Comprehensions are compact, readable expressions that build lists, dicts, or sets from iterables — a single Pythonic idiom that replaces three lines of for + append.

TypeSyntaxExample
List[expr for x in it if cond][x*x for x in range(10) if x%2]
Dict{k:v for x in it}{u.id: u for u in users}
Set{expr for x in it}{w.lower() for w in words}
Generator(expr for x in it)sum(x*x for x in nums)

Key benefits: they are faster than equivalent for loops (bytecode is specialized), they're one expression (no intermediate variable), and they evaluate in a nested scope so the loop variable doesn't leak into the surrounding namespace.

Rule of thumb: if the comprehension has more than two filters or two loops, switch to a normal for-loop. Clever nesting makes you feel smart and your teammates cry.

Generator expressions use parentheses and are lazy — no list is built; values are produced on demand. Use them inside sum(), max(), any(), etc. for O(1) memory over huge inputs.

→ Interview Tip
Show you know the generator-expression trick: sum(x*x for x in range(10**7)) uses constant memory while sum([x*x for x in range(10**7)]) allocates 10 million ints first. That one paren-vs-bracket is a measurable win.
Q11

What is the difference between shallow and deep copy?

Copying matters because assignment creates an alias, not a copy. b = a makes both names point to the same object. For real copies you need copy.copy() (shallow) or copy.deepcopy() (recursive).

SHALLOW VS DEEP COPY
Assignment (b = a)
Two names, one object. Change via either is visible to both.
Shallow copy (copy.copy)
New outer container; inner objects still shared by reference.
Deep copy (copy.deepcopy)
New outer + recursively new inner objects. Fully independent tree.

The textbook demo:

a = [[1,2],[3,4]]

b = a.copy()b[0] is a[0]True (inner lists shared)
b[0].append(99) changes a too.

c = copy.deepcopy(a)c[0] is a[0]False
c[0].append(99) does NOT change a.

Deep copy has costs: it's slower (traverses the whole object graph) and it handles cycles with a memo dict. For configs and pure-data trees you usually want it; for large caches you usually don't.

→ Real-World Use
If you're passing a default dict/list into a function and worried it'll be mutated, deep-copy at the boundary — or better, pass an immutable type (tuple / frozenset) if the contents allow it.
Q12

What's in the collections module?

The collections module is a toolbox of specialized containers that solve common problems more elegantly than built-ins. These show up constantly in real code — knowing them signals experience.

COLLECTIONS TOOLBOX
defaultdict
Auto-creates missing keys via a factory. defaultdict(list) — no more if k not in d checks.
Counter
Multiset for counting hashables. Counter(words).most_common(5) — top-k in one line.
deque
Double-ended queue with O(1) appends/pops at both ends. Use for BFS, sliding windows.
namedtuple
Tuple with named fields. Immutable mini-record; today often superseded by @dataclass.
OrderedDict
Legacy ordered dict. Since 3.7 regular dicts preserve order; still useful for move_to_end() (LRU caches).
ChainMap
Layered view across multiple dicts — great for config overlays (defaults → env → CLI).

The one you'll reach for daily is defaultdict. Grouping records by key in plain dict takes 3 lines (setdefault or if); with defaultdict(list) it's one.

→ Interview Tip
"Find the 3 most common words in a list" — Counter(words).most_common(3). One-liner answers to word-count problems tell the interviewer you've shipped real code, not just practiced LeetCode.
Q13

How does slicing work in Python?

Slicing lets you extract a subsequence from any sequence type (list, tuple, str, bytes) using seq[start:stop:step]. All three parts are optional; the operation returns a new object of the same type.

SLICING IN ONE PICTURE
s[2:5] — indices 2 to 4 (stop is exclusive)
s[:3] — first 3 elements
s[-3:] — last 3 elements
s[::2] — every other element
s[::-1] — reversed copy
s[:] — shallow copy of whole sequence

Slices clip instead of raising: [1,2,3][:100] returns [1,2,3], not an error. Single-index access with an out-of-range index would raise IndexError — slicing is the forgiving cousin.

Slice assignment on a list can do clever things: a[1:3] = [10,20,30] replaces the slice with the new elements (list grows or shrinks). a[::2] = [0,0,0] replaces every other element (lengths must match for extended slices).

Under the hood, s[a:b:c] creates a slice(a,b,c) object and calls s.__getitem__(slice(a,b,c)). You can build slices explicitly and reuse them — last_three = slice(-3, None); lst[last_three].

→ Key Insight
s[:] is the shortest idiom for "give me a shallow copy." For lists this is fine; for dicts use d.copy() since dicts don't support slicing.
Q14

What are the time complexities of common operations?

Pick the right container and most hot-path bugs disappear. These are the numbers every Python developer should have memorized.

Operationlistdequedict / set
x in cO(n)O(n)O(1) avg
c[i] / c[k]O(1)O(n)O(1) avg
append / addO(1) amortizedO(1)O(1) avg
appendleft / popleftO(n)O(1)
insert middleO(n)O(n)
sortO(n log n)
min / maxO(n)O(n)O(n)
MEMBERSHIP TEST COST
list n=10
O(n)
list n=10k
O(n)
list n=1M
O(n) — slow
set n=1M
O(1) — flat

The biggest practical win: replace x in big_list with x in big_set. If you're doing many membership checks, build the set once, reuse it — you go from quadratic to linear.

For FIFO queues, use collections.deque, not list.pop(0), which is O(n) per call.

→ Real-World Use
Profile before optimizing, but know these numbers cold. Most "slow Python" bugs reduce to in on a list, pop(0) on a list, or += on a string inside a loop — all O(n²) patterns.
Part III

Functions & Scope

Functions in Python are first-class objects — they can be passed around, returned, decorated, and closed over. Mastering these is what unlocks decorators, middleware, and clean functional patterns.

*args & **kwargs
Lambdas
Closures
Decorators
LEGB
First-Class
Questions 15–21
Q15

What are *args and **kwargs?

*args collects extra positional arguments into a tuple. **kwargs collects extra keyword arguments into a dict. Together they let a function accept anything.

ARGS VS KWARGS
*args
Extra positional → tuple
def f(*args): args == (1, 2, 3)
Unknown number of positional
**kwargs
Extra keyword → dict
def f(**kw): kw == {'a':1}
Unknown keyword arguments

The names args and kwargs are conventions — the * and ** prefixes are what matter. You can name them anything, but don't.

They work in both directions:

Definition: def f(a, *args, **kwargs) — function accepts any extras

Call site (unpacking): f(*[1,2,3], **{'k':'v'}) — spread a list/dict into arguments

The killer use case is wrapper functions: a decorator that accepts any signature just writes def wrapper(*args, **kwargs): return func(*args, **kwargs) and forwards everything transparently.

Ordering in a signature: positional-only → positional-or-keyword → *args → keyword-only → **kwargs.

→ Interview Tip
Avoid **kwargs in public APIs when you can list real parameters — it hides the interface and breaks autocomplete. Reserve it for wrappers, middleware, and super().__init__ plumbing.
Q16

What are lambda functions?

A lambda is an anonymous, single-expression function. lambda x, y: x + y is the callable equivalent of def add(x, y): return x + y.

Lambdas exist to avoid naming a function you use once — typically as a key or callback:

Use CaseExample
Sort keysorted(users, key=lambda u: u.age)
Filter predicatefilter(lambda x: x > 0, nums)
Map transformmap(lambda s: s.strip().lower(), lines)
defaultdict factorydefaultdict(lambda: {"count": 0})

Limitations: lambdas are a single expression only — no statements, no assignments, no type annotations. If your lambda spans more than one line or does anything complex, a named def is almost always clearer.

PEP 8 even discourages assigning a lambda to a name: square = lambda x: x*x is worse than def square(x): return x*x — the def shows up better in tracebacks (<lambda> is unhelpful) and gives you a __name__.

→ Mental Model
Lambdas are expressions that make functions, not a replacement for def. If you need a name, you need a def. If you need it inline and anonymous, lambda earns its keep.
Q17

What are closures in Python?

A closure is a function that remembers variables from its enclosing scope even after that scope has finished executing. It "closes over" those variables.

CLOSURE MECHANICS
def make_counter():
  count = 0 # captured
  def inc():
    nonlocal count
    count += 1
    return count
  return inc # still holds count

c = make_counter(); c(), c(), c() # 1, 2, 3

Three conditions must hold for a closure to exist:

1. A nested function. 2. That function references a variable from the outer (enclosing) function. 3. The outer function returns the nested function.

To rebind (not just read) an enclosing variable, you need the nonlocal keyword. Without it, count += 1 would be treated as a local assignment and raise UnboundLocalError. Reading is fine without nonlocal.

Closures are the foundation of decorators, callbacks, and factory functions. They let you create specialized functions with pre-baked configuration without classes.

You can inspect a closure via func.__closure__ — a tuple of cell objects holding the captured values.

→ Key Insight
Closures capture variables, not values. A loop that creates lambdas with [lambda: i for i in range(3)] all capture the same i — by the time they run, i == 2 for all of them. Fix with a default arg: lambda i=i: i.
Q18

What are decorators?

A decorator is a function that takes a function and returns a (usually wrapped) function. The @decorator syntax is just sugar: @log \n def f(): ... is identical to f = log(f).

DECORATOR LIFECYCLE
📝
Step 01
Define f
Original function exists
🎁
Step 02
Wrap
@log replaces f with log(f)
📞
Step 03
Call
User calls f(x) — actually calls wrapper
⚙️
Step 04
Augment
Wrapper runs before/after original
🎯
Step 05
Return
Result flows back to caller

A minimal timing decorator:

def timer(f):
  @functools.wraps(f)
  def wrapper(*a, **kw):
    t = time.perf_counter()
    r = f(*a, **kw)
    print(time.perf_counter() - t)
    return r
  return wrapper

functools.wraps is not optional. Without it, the wrapped function loses __name__, __doc__, and signature — which breaks logging, docs, and introspection.

Common built-in decorators: @staticmethod, @classmethod, @property, @functools.cache, @dataclass. In frameworks: @app.route (Flask), @pytest.fixture, @retry.

→ Real-World Use
Decorators are ideal for cross-cutting concerns: logging, timing, caching, auth, retries. If your wrapper starts growing state, promote it to a class with __call__ — classes are decorators too.
Q19

What is the LEGB rule?

LEGB is the order Python follows to resolve a name: Local → Enclosing → Global → Built-in. When you reference x, Python searches these scopes in that exact sequence and uses the first match.

NAME RESOLUTION ORDER
L · Local
Current function body — parameters and variables assigned inside.
E · Enclosing
Outer function(s) if nested. Read-only without nonlocal.
G · Global
Module top level. Read-only without global.
B · Built-in
Names pre-loaded by Python: len, print, range, Exception, etc.

Two twists everyone gets bitten by:

1. Assignment creates a local. If you write x = 5 anywhere in a function, x is local for the whole function — even on lines before the assignment, reading it raises UnboundLocalError.

2. Classes are not in LEGB. Methods don't automatically see their class's attributes — you must go through self or cls. A class body is a separate namespace that exists only during class creation.

To write to an outer scope: global x for module-level, nonlocal x for an enclosing function. Reading works without either.

→ Interview Tip
The classic trap: def f(): print(x); x = 1 — looks like it should print the global x, but raises UnboundLocalError because Python decided at compile time that x is local.
Q20

What's the difference between positional, keyword, and default arguments?

Python gives you more argument-passing flexibility than most languages. Understanding the categories lets you design APIs that are both ergonomic and safe.

KindAt the call siteAt definition
Positionalf(1, 2)Order matters
Keywordf(x=1, y=2)Order doesn't — name does
Defaultf(1)y=10def f(x, y=10)
Positional-onlyMust be positionaldef f(x, /) (PEP 570)
Keyword-onlyMust use namedef f(*, x) or after *args

Full signature order: def f(pos_only, /, pos_or_kw, *args, kw_only, **kwargs). Everything after / can be keyword; everything after * or *args must be.

Keyword-only arguments are the cleanest way to prevent misuse. def connect(host, *, timeout=30, retries=3) forces callers to write connect("x.com", timeout=10), which is self-documenting and safe to extend — you can reorder or add new options later without breaking callers.

The infamous mutable-default trap: def append_item(x, lst=[]). That list is created once at function-definition time and shared across every call with no explicit lst. Use lst=None and allocate inside.

→ Interview Tip
Prefer keyword-only arguments for booleans and flags. user.save(True) tells the reader nothing; user.save(commit=True) tells them everything. The keyword-only * forces this discipline at the API boundary.
Q21

What does "first-class functions" mean?

Functions in Python are first-class objects: they can be assigned to variables, stored in data structures, passed as arguments, and returned from other functions — exactly like any other value.

FUNCTIONS AS VALUES
01 · Assign
g = len — now g([1,2]) works. Functions are just names bound to callables.
02 · Store
dispatch = {'add': add, 'sub': sub} — dispatch tables instead of long if-chains.
03 · Pass
sorted(items, key=get_price) — higher-order functions take callables.
04 · Return
def multiplier(n): return lambda x: x*n — factories that return functions.

Any object with a __call__ method is callable — which means classes, lambdas, methods, generators, and instances with __call__ all participate in this system. The duck-typed "callable" concept is broader than just def.

First-class functions enable Python's most loved patterns: decorators (wrap a function), higher-order functions (map, filter, reduce, sorted(key=)), strategy pattern (pass behavior as an argument), and event handlers / callbacks.

→ Mental Model
In Python, "callable" is a trait — not a type. callable(x) returns True for any object where x() makes sense. That's why a function, a class, and a custom object with __call__ all look the same to sorted.
Part IV

OOP in Python

Python's object model is duck-typed, permissive, and powerful. Everything is an object — including classes themselves. Understanding the mechanics reveals why Python can be both simple and meta-programmable.

Classes
Inheritance
Methods
Dunders
MRO
ABC
Questions 22–28
Q22

What are classes and objects in Python?

A class is a blueprint describing state (attributes) and behavior (methods). An object is an instance created from that blueprint. In Python, even the classes themselves are objects — instances of a metaclass (by default, type).

CLASS ANATOMY
class Account:
  interest_rate = 0.05 # class attribute

  def __init__(self, owner, balance=0): # constructor
    self.owner = owner # instance attribute
    self.balance = balance

  def deposit(self, amount): # instance method
    self.balance += amount

a = Account("Sau", 100) # instance

Key pieces to name clearly in an interview:

__init__ — the initializer, run after the object is created to set up its state. (Not strictly the constructor — that's __new__.)

self — the instance, passed implicitly by the interpreter. It's a convention, not a keyword, but never rename it.

Class vs instance attributesinterest_rate lives on the class and is shared by all instances; self.balance lives on the instance. Reading an attribute looks on the instance first, then the class — which is why class attributes act as "defaults."

For pure data containers, modern Python prefers @dataclass: it auto-generates __init__, __repr__, and __eq__, cutting boilerplate dramatically.

→ Mental Model
Remember "everything is an object" is literal in Python. Classes are instances of type; modules are instances of module; functions are instances of function. That's why metaprogramming (decorators, metaclasses) feels natural here.
Q23

What is inheritance and what types exist in Python?

Inheritance lets a class reuse and extend another class's attributes and methods. Python supports multiple inheritance — a class can have any number of parents.

INHERITANCE PATTERNS
01 · Single
One parent. class Dog(Animal). Most common, easiest to reason about.
02 · Multiple
class C(A, B). Powerful but risks the diamond problem — Python solves with C3 MRO.
03 · Multilevel
Chain: A → B → C. C inherits from B inherits from A — natural for specialization hierarchies.
04 · Hierarchical
Multiple children share one parent. Circle(Shape), Square(Shape), etc.

super() is how you delegate to the next class in the MRO (Method Resolution Order). Inside __init__, always call super().__init__(...) so parent setup runs.

Every class ultimately inherits from object — that's where __repr__, __eq__, __hash__ and the baseline dunders come from.

Mixins are a disciplined use of multiple inheritance: small classes that add one capability (e.g. LoggingMixin, SerializableMixin) and are combined into concrete classes. They work because of Python's cooperative super().

→ Interview Tip
Prefer composition over inheritance as your default. Inheritance is for "is-a" relationships; if you find yourself inheriting for code reuse, a helper object or mixin is usually cleaner and easier to test.
Q24

Difference between @classmethod, @staticmethod, and instance method?

All three live on a class; they differ in what implicit first argument they receive — and therefore what they can touch.

TypeFirst argCan accessTypical use
Instance methodselfInstance & class stateNormal behavior
@classmethodclsClass state onlyAlternative constructors, factories
@staticmethod(none)NeitherUtility helpers logically grouped with class

A canonical factory pattern:

@classmethod
def from_json(cls, s):
  data = json.loads(s)
  return cls(**data)

The brilliance: cls is the actual class being called. If a subclass PaidUser inherits this, PaidUser.from_json(...) returns a PaidUser, not a User. That's why alternate constructors use @classmethod, not @staticmethod.

@staticmethod is really just a regular function living inside a class namespace — no special access, no self, no cls. Use it when the function is related to the class conceptually but doesn't need its state (e.g. a validator).

→ Key Insight
If you find yourself writing @staticmethod a lot, consider whether those helpers really belong as module-level functions. Python isn't Java — you don't need a class to host a function.
Q25

What are dunder (magic) methods?

Dunder ("double underscore") methods are Python's protocol hooks. They let your objects integrate with language syntax — operators, iteration, context managers, printing, comparisons. You don't call them; Python calls them for you in response to syntax.

CategoryDundersTriggered by
Construction__new__, __init__, __del__Object creation / destruction
Representation__repr__, __str__, __format__repr(), print(), f-string
Arithmetic__add__, __sub__, __mul__, __truediv__+, -, *, /
Comparison__eq__, __lt__, __hash__==, <, set/dict usage
Containers__len__, __getitem__, __contains__len(), [], in
Iteration__iter__, __next__for, iter(), next()
Callable__call__obj()
Context__enter__, __exit__with statement

Implementing the right dunders makes your objects feel native:

Add __len__len(x) works. Add __iter__for i in x works. Add __eq__ and __hash__ → usable in sets/dicts.

Two repr rules to internalize: __repr__ is for developers (unambiguous, ideally copy-pasteable), __str__ is for users (friendly). If you only implement one, implement __repr__ — Python falls back to it.

→ Interview Tip
If you override __eq__, you must also override __hash__ (or set it to None). Python enforces: a == b implies hash(a) == hash(b). Forget this and your objects silently break dicts and sets.
Q26

What is MRO (Method Resolution Order)?

MRO is the order in which Python searches base classes when looking up an attribute or method. For multiple inheritance, it's the algorithm that makes "which parent wins?" deterministic.

Python uses the C3 linearization algorithm. Its three rules:

1. A class always comes before its parents. 2. Order of base classes in the class declaration is preserved. 3. The result is monotonic — no class appears before another that was before it in a parent's MRO.

THE DIAMOND PROBLEM — SOLVED
class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass

D.__mro__ → D, B, C, A, object
D is visited first, then B (first base), then C, then A, then object.

You can inspect MRO two ways: ClassName.__mro__ (tuple) or ClassName.mro() (method).

super() doesn't mean "call the parent" — it means "call the next class in the MRO." In single inheritance these are the same; in multiple inheritance they differ, and that's what makes cooperative mixins work. Every super().method() call follows the MRO chain.

If Python can't compute a consistent MRO (say, two classes conflict), it raises TypeError at class definition. You'll never hit this if your inheritance tree is sane.

→ Key Insight
The mantra "super() calls the next in MRO, not the parent" is the key to understanding cooperative multiple inheritance. Mixins rely on every level calling super() to chain behavior correctly.
Q27

What are abstract classes and protocols?

Both are ways to declare "objects of this shape should behave like X." They differ in how strictly they enforce it.

ABCs VS PROTOCOLS
Abstract Base Class
from abc import ABC, abstractmethod
Must inherit explicitly
Nominal subtyping
Instantiating fails if methods unimplemented
Protocol (PEP 544)
from typing import Protocol
No inheritance required
Structural subtyping (duck typing)
Static type-checked at compile time

ABC: inherit from ABC, decorate methods with @abstractmethod, and Python refuses to instantiate any subclass that doesn't implement them.

class Shape(ABC):
  @abstractmethod
  def area(self): ...

Protocol: defines a shape; any class with matching methods is considered a subtype by static type checkers — no inheritance needed. This is how Python's built-ins define "iterable," "sized," etc.

class Drawable(Protocol):
  def draw(self) -> None: ...

The standard library defines ABCs in collections.abc (Iterable, Sized, Mapping) — useful for isinstance checks and as mixins that fill in methods once you implement the required ones.

→ Mental Model
ABCs enforce at runtime; Protocols enforce at type-check time. ABCs say "you must inherit from me"; Protocols say "if you quack like me, you're me." Modern Python leans toward Protocols for library code.
Q28

Difference between __new__ and __init__?

__new__ creates the object. __init__ initializes the object after it exists. Both are called when you invoke MyClass(), but in that order.

OBJECT CREATION PIPELINE
📞
Step 01
Cls(args)
User writes MyClass(x)
🏗️
Step 02
__new__(cls)
Returns new raw instance
⚙️
Step 03
__init__(self)
Sets up attributes on it
Step 04
Return
Fully built instance handed back
__new____init__
First argcls (class)self (instance)
ReturnsA new instanceNone
RoleAllocateConfigure
Called byThe type machineryAfter __new__, if instance is of cls

You rarely override __new__. The standard use cases are: (1) subclassing immutable types like int, str, tuple — where you can't modify the object in __init__ because it already exists; (2) singletons — return the same instance on every call; (3) metaclass magic.

For ordinary classes, just override __init__ and let Python handle __new__.

→ Key Insight
If __new__ returns an instance of a different class, __init__ is skipped. That's what makes it useful for singletons — return the cached one, skip reinit.
Part V

Iterators & Generators

Python's iteration protocol is elegant and everywhere — from for loops to file handling to streaming pipelines. Generators turn it into a lazy, memory-efficient way to produce values on demand.

Iterables
Iterators
yield
itertools
Context Managers
for-loops
Questions 29–34
Q29

What is an iterable vs an iterator?

An iterable is anything you can loop over — it implements __iter__, which returns an iterator. An iterator is a single-pass cursor — it implements both __iter__ (returning itself) and __next__ (returning the next value, or raising StopIteration when done).

ITERABLE VS ITERATOR
Iterable
list, tuple, str, dict, set, file
Has __iter__
Can be iterated many times
A source of iterators
Iterator
Generators, iter(list), map, zip
Has __iter__ AND __next__
Single-pass — exhausts itself
A cursor over values

The canonical usage: iter(my_list) turns a list (iterable) into a list-iterator (iterator). next(it) advances it. When exhausted, next() raises StopIteration.

Critical distinction for interview traps:

lst = [1,2,3] — iterate twice, get the same elements. Iterables are replayable.

it = iter(lst) — consume it once, the second pass yields nothing. Iterators are burned after use.

This is why a generator function passed to sum() then list() gives weird results — the second call sees an empty generator.

→ Key Insight
Iterables are containers of potential; iterators are consumed sequences. If you need to iterate multiple times, keep the iterable and call iter() each time — or materialize to a list.
Q30

What are generators and the yield keyword?

A generator is a function that produces values lazily using yield instead of return. Calling it doesn't run the body — it returns a generator object (an iterator). Each next() call runs the function until the next yield, pausing its state.

YIELD MECHANICS
def count_up_to(n):
  i = 0
  while i < n:
    yield i # pauses here, resumes on next()
    i += 1

g = count_up_to(3)
next(g) # 0
next(g) # 1
next(g) # 2
next(g) # StopIteration

Every generator is automatically an iterator — you get __iter__ and __next__ for free. You can loop, list(), sum(), unpack (a, b, c = gen), or pipeline them.

Generator expressions are the compact form: (x*x for x in range(10)). Same lazy semantics, no def needed.

yield from delegates to a sub-generator, piping values through transparently — useful for composing pipelines and writing recursive generators (e.g. tree traversal).

Generators are also coroutines: value = yield lets you send values back in with gen.send(x). This is the foundation asyncio was originally built on, before the dedicated async/await syntax.

→ Real-World Use
Use generators for streaming: reading large files line by line, paginated API results, or producing data that's expensive to compute upfront. One yield line replaces an entire iterator class.
Q31

Generator vs list — memory implications?

Lists are eager: every element exists in memory at once. Generators are lazy: one value at a time, computed on demand. The difference is trivial for 100 elements, transformative for 10 million.

MEMORY FOOTPRINT — 10M ELEMENTS
list
~400 MB
generator
~200 B
ListGenerator
MemoryO(n)O(1)
EvaluationEager (all at once)Lazy (on demand)
Reusable?Yes — indexable, re-iterableNo — single-pass
Indexinglst[5] in O(1)Must iterate forward
len()YesNo (unknown in advance)
Best forSmall, reusable, random accessStreaming, infinite, large data

The canonical benchmark:

sum([x*x for x in range(10**7)]) — allocates 10M ints as a list, then sums. Slow, memory-heavy.

sum(x*x for x in range(10**7)) — streams values. Fast, ~200 bytes.

The paren-vs-bracket switch is a real perf win. But watch out: generators can't be rewound or measured. If you need len(), indexing, or multiple passes, you need a list.

→ Interview Tip
Default to generators for pipelines where each stage consumes the last. Materialize to a list only when you truly need random access, length, or re-iteration — and then do it once, at the end.
Q32

What is itertools and what are common functions?

itertools is a standard-library module of fast, memory-efficient iterator building blocks, inspired by functional languages. Every function returns an iterator, so chains stay lazy.

ITERTOOLS TOOLBOX
01 · Infinite
count(start, step), cycle(iter), repeat(x, n) — endless sequences you'd truncate with islice.
02 · Slicing & chaining
chain, islice, takewhile, dropwhile — compose and carve iterators without lists.
03 · Combinatorics
product, permutations, combinations, combinations_with_replacement.
04 · Grouping
groupby(iter, key) — groups adjacent equal items. Pair with sorted for full grouping.
05 · Pairing
zip_longest, pairwise (3.10+) — window over an iterable as overlapping pairs.
06 · Accumulation
accumulate — running totals or any binary-op fold, streaming.

Common idioms:

Flatten a list of lists: list(chain.from_iterable(lol))

Top-N from an iterator: list(islice(gen, 10))

Cartesian product for grid search: product(lrs, batch_sizes)

Everything in itertools is C-implemented — it's almost always faster than a Python for-loop equivalent.

→ Real-World Use
Before writing nested loops, check whether itertools already has the shape you need. chain, groupby, and product alone replace thousands of ad-hoc loops across every codebase.
Q33

What are context managers and the with statement?

A context manager is an object that defines setup and teardown behavior around a block of code. The with statement guarantees teardown runs — even if the block raises an exception.

WITH-STATEMENT LIFECYCLE
🚪
Step 01
__enter__
Acquire resource; return it
⚙️
Step 02
Body
User's code runs inside block
🚪
Step 03
__exit__
Always runs — even on exception

The canonical file example:

with open('f.txt') as f:
  data = f.read()
# f is auto-closed, even on exception

Two ways to write your own:

Class-based: implement __enter__(self) (returns the as-value) and __exit__(self, exc_type, exc_val, tb) (returns True to suppress exception, else let it propagate).

Generator-based (cleaner): @contextlib.contextmanager on a generator with one yield. Code before yield is setup, code after is teardown.

@contextmanager
def timer():
  t = time.perf_counter()
  yield
  print(time.perf_counter() - t)

Real uses: file / socket / DB connection management, lock acquisition (with lock:), transactions (commit on success, rollback on exception), temporarily changing state (cwd, logging level, env vars).

Python 3.10+ supports parenthesized multi-line with: with (open(a) as x, open(b) as y): ...

→ Mental Model
Think of with as RAII for Python — resource acquisition is initialization, cleanup is guaranteed. Any time you pair acquire + release or open + close, a context manager is the right answer.
Q34

How does the for loop work internally?

A for loop in Python is syntactic sugar for the iterator protocol. Understanding what it expands to demystifies iteration.

FOR LOOP — UNDER THE HOOD
# What you write:
for x in iterable:
  body(x)

# What Python effectively runs:
it = iter(iterable) # calls __iter__
while True:
  try:
    x = next(it) # calls __next__
  except StopIteration:
    break
  body(x)

Steps Python takes:

1. Call iter(iterable) → this invokes __iter__ to get an iterator. 2. Repeatedly call next(it) → this invokes __next__. 3. When __next__ raises StopIteration, the loop terminates silently (the exception is caught by the loop machinery).

That's it. Any object that provides __iter__ returning something with __next__ can be used in a for loop — no inheritance, no base class required. This is pure duck typing.

Two extras worth knowing:

Python supports for/else: the else block runs if the loop completes without break. Rare but useful for search patterns.

For dict, default iteration gives keys. Use .items() for key-value pairs or .values() for values.

→ Interview Tip
If asked to make a custom class work with for, you have two choices: implement __iter__/__next__, or just make __iter__ a generator function with yield — the latter is almost always cleaner.
Part VI

Concurrency & Performance

The GIL, three concurrency models, and the tricks that make Python fast when it needs to be. This section separates the Python developers who build toy scripts from those who ship production systems.

GIL
Threads
Processes
asyncio
Profiling
__slots__
Questions 35–41
Q35

What is the GIL (Global Interpreter Lock)?

The GIL is a mutex in CPython that allows only one thread to execute Python bytecode at a time, regardless of how many CPU cores you have. It's the single most famous design choice (and complaint) in Python.

GIL IMPACT BY WORKLOAD
CPU-bound
Pure-Python math
Threads don't help
Threads serialize via GIL
Use multiprocessing or C extensions (NumPy)
I/O-bound
HTTP / DB / disk / sockets
Threads help — GIL releases on I/O
asyncio even better — no OS threads
Concurrency works here

Why it exists: the GIL makes CPython's reference counting thread-safe without fine-grained locks. Removing it is hard — every INCREF/DECREF would need atomic operations, which slows down single-threaded code. Early experiments showed 2× regressions for single-threaded programs.

When it releases: on I/O operations (read, write, socket), during sleeps, around C extension calls (NumPy, PyTorch, image libraries intentionally release it), and every ~15 ms otherwise so threads can rotate.

The future: PEP 703 (accepted) introduces a "no-GIL" mode in Python 3.13 as an opt-in build, becoming default over several releases. This is the biggest change to CPython's concurrency story in 30 years.

→ Interview Tip
Don't parrot "GIL is bad." The right answer is: it makes single-threaded code fast and C interop easy; it hurts pure-Python CPU-bound threading. For I/O, use threads or asyncio. For CPU, use multiprocessing or native-code libraries.
Q36

Multithreading vs Multiprocessing vs Async — when do I use each?

Python gives you three concurrency models. Picking correctly depends almost entirely on whether your bottleneck is CPU or I/O, and whether tasks are many and fine-grained or few and heavy.

ThreadingMultiprocessingasyncio
Parallelism?No (GIL)Yes (separate interpreters)No — single thread
Shares memory?Yes — same processNo — IPC neededYes
OverheadLow (OS threads)High (fork / spawn)Very low (coroutines)
Good forI/O with blocking libsCPU-bound workloadsI/O at scale — 1000s of conns
Modulethreading / concurrent.futuresmultiprocessing / concurrent.futuresasyncio
DECISION TREE
Q: Is work CPU-bound?
↳ Yes → multiprocessing (or NumPy/C extension)
↳ No → Is it I/O-bound?
↳ Few concurrent ops, blocking libs → threads
↳ Thousands of concurrent I/Os → asyncio

Threading: lightweight. Ideal when you're using blocking libraries (requests, legacy DB drivers) and want parallel I/O. The GIL releases during I/O so threads actually progress.

Multiprocessing: each process has its own interpreter, memory, and GIL. True parallel CPU use, but sharing data means pickling — expensive for large objects.

asyncio: single thread, many coroutines cooperatively scheduled. Extreme efficiency for many-connection scenarios (web servers, WebSockets, crawlers). Requires async-aware libraries (aiohttp, asyncpg).

→ Real-World Use
For a scraper fetching 10k URLs: asyncio with aiohttp wins. For an image-processing batch job: multiprocessing. For mixing CPU + I/O: ProcessPoolExecutor for CPU parts, async for I/O — hybrids are common.
Q37

How does asyncio and the event loop work?

asyncio is single-threaded cooperative concurrency. An event loop manages a queue of tasks (coroutines); each task runs until it hits an await on something slow (I/O), at which point it yields control. The loop picks another ready task and runs it. When the awaited operation completes, the original task is scheduled to resume.

EVENT LOOP CYCLE
📝
Step 01
Schedule
Coroutines added as tasks
▶️
Step 02
Run
Execute until await
⏸️
Step 03
Suspend
I/O registered with OS
🔄
Step 04
Switch
Loop picks another task
Step 05
Resume
On I/O complete, reschedule

The key primitives:

async def — defines a coroutine function. Calling it returns a coroutine object; you must await it or schedule it as a task for it to actually run.

await x — pauses the current coroutine until x completes, releasing the loop for other work.

asyncio.gather(...) — runs coroutines concurrently and waits for all of them. The common idiom for "fetch these 100 URLs at once."

asyncio.run(main()) — the top-level entry point. Creates a loop, runs your coroutine, closes the loop.

Golden rule: one blocking call in an async function freezes the whole loop. Never time.sleep in async code — use asyncio.sleep. Never use requests — use aiohttp. If you must call blocking code, wrap it in asyncio.to_thread(fn, ...).

→ Interview Tip
Async code that mixes in a synchronous blocking call is the #1 production bug. Always ask "does this library support async?" before using it in a coroutine, and reach for asyncio.to_thread when it doesn't.
Q38

What are coroutines?

A coroutine is a function that can be paused and resumed. In Python, modern coroutines are defined with async def. Calling one doesn't execute the body — it returns a coroutine object, which you schedule with await or asyncio.run.

FUNCTION VS COROUTINE
Regular function
Called → runs → returns
Single linear execution
Blocks caller until done
"Do this now, give me the answer"
Coroutine
Called → returns coroutine obj
Run only via await / loop
Can suspend on await
"Here's the plan — run it when ready"

A minimal example:

async def fetch(url):
  async with aiohttp.ClientSession() as s:
    async with s.get(url) as r:
      return await r.text()

Under the hood: coroutines evolved from generators. A generator with value = yield could already pause, receive a value, and resume. async/await is a cleaner syntax with dedicated type and rules (you can't accidentally next() a coroutine, for instance).

Key properties: coroutines are cheap (no OS thread), they cooperatively yield (only at await points), and they preserve state between suspensions (locals, call stack, exception handlers).

→ Mental Model
A coroutine is a promise of work, not the work itself. Creating one is essentially free — it only runs when something drives it (await or event loop). This is why awaiting a list of coroutines via gather is so efficient.
Q39

How do you profile and optimize Python code?

The optimizer's mantra: measure first, guess never. Python has excellent tools — use them before rewriting anything.

ToolMeasuresUse when
timeitMicrobench small snippetsComparing two expressions
cProfileFunction-level CPU timeFinding hot functions
line_profilerLine-by-line timeDrilling into a hot function
py-spySampling profiler, prod-safeLive process, no code changes
tracemallocMemory allocationsMemory leaks / growth
memory_profilerLine-by-line memoryPeaks in specific functions

Standard optimization ladder, ordered by ROI:

OPTIMIZATION HIERARCHY
01 · Algorithm — O(n²) → O(n log n) beats any micro-optimization
02 · Data structure — set for membership, deque for queue, dict for lookup
03 · Built-ins & librariessum, any, sorted, NumPy — all C-implemented
04 · Caching@functools.cache / lru_cache for repeated calls
05 · Parallelism — async for I/O, multiprocessing for CPU
06 · Native code — Cython, Rust (PyO3), or C extension for hot loops

Quick wins that rarely hurt readability:

Avoid repeated attribute lookup in hot loops (append = lst.append once). Use generator expressions to avoid list materialization. Replace s += item with "".join(parts). Cache pure functions. Vectorize numeric code with NumPy.

→ Real-World Use
In production, reach for py-spy — it attaches to a running process, needs no code changes, and produces beautiful flamegraphs. Most slow Python in the wild reveals itself as one hot function doing O(n²) work.
Q40

What is __slots__ and when should you use it?

By default every Python instance carries a __dict__ — a hash table of its attributes. Declaring __slots__ tells Python to use a fixed-size array of named attributes instead, skipping the dict entirely.

WITH VS WITHOUT SLOTS
Without __slots__
Per-instance __dict__
~296 B minimum
Any attribute can be added
Default — flexible, heavy
With __slots__
No __dict__
~72 B (4× smaller)
Only listed attrs allowed
Fixed shape — tight, fast

class Point:
  __slots__ = ('x', 'y')

p = Point()
p.z = 5AttributeError

Benefits: 40–60% memory reduction per instance, slightly faster attribute access (array index vs dict lookup), prevents typo-bugs like obj.naem = "x" silently creating a new attribute.

Caveats: can't add attributes not in the list; subclasses lose the optimization unless they also define __slots__; doesn't compose well with multiple inheritance (slot clashes); can't have class-level defaults for slotted attributes (the name would live in both the slot and the class dict).

When to use: data classes instantiated millions of times — records, nodes, events, points, particles. The memory savings scale linearly.

Modern bonus: @dataclass(slots=True) (Python 3.10+) handles this automatically.

→ Key Insight
Don't slot everything. For ordinary classes with a handful of instances, the gain is negligible and the rigidity can bite during refactors. Reach for it only when memory or instantiation throughput is measured and matters.
Q41

What causes memory leaks in Python?

Python has automatic memory management, but leaks absolutely happen — just not the C-style "forgot to free" kind. Python leaks are usually references that outlive their usefulness.

COMMON LEAK PATTERNS
01 · Global caches
Module-level dicts that grow forever. lru_cache(maxsize=None) on functions with many unique args.
02 · Reference cycles
a.child = b; b.parent = a. Cyclic GC eventually clears them — but slowly, and never if __del__ is involved.
03 · Closures & callbacks
Event handlers hold references to large outer objects. Unregister on teardown.
04 · Unclosed resources
Files, sockets, DB connections not explicitly closed. Use with statements.
05 · Growing log lists
In-memory buffers (history, audit lists) that never get trimmed in long-lived processes.
06 · C extension bugs
Native libraries can leak outside Python's GC. These are hardest to find — watch RSS, not Python memory.

Diagnosis toolkit:

tracemalloc — track where allocations come from. Take snapshots at two points and diff them to find the growing site.

gc.get_objects() — get every tracked object. Combined with sys.getsizeof and Counter of type(o) you can spot "oh, 2 million Session objects."

weakref — break cycles by using weak references for back-pointers (parent→child strong, child→parent weak).

→ Real-World Use
In a long-running server, the #1 leak source is unbounded caches. Always give lru_cache a maxsize. For per-request state, use weakref.WeakValueDictionary or explicitly clear at the end of the request.
Part VII

Errors & Testing

Error handling shapes how resilient your code is; testing shapes how confidently you can change it. Together they separate prototypes from production.

try/except
Custom Exceptions
Assertions
pytest
unittest
Mocking
Questions 42–46
Q42

How does try/except/else/finally work?

Python's exception handling has four clauses, each with a specific role. Understanding all four lets you write tight, correct error code without over-catching.

FOUR CLAUSES — WHEN EACH RUNS
try
Code that may raise. Keep this block as small as possible.
except
Runs only if a matching exception is raised. Be specific about which ones.
else
Runs if try succeeded without raising. Use for "success path" code.
finally
Always runs — on success, exception, or even return. Use for cleanup.

Canonical shape:

try: risky
except ValueError as e: handle
except (KeyError, IndexError): handle others
else: ran only if no exception
finally: always runs

Best practices:

Catch specific exceptions, not bare except: (which also catches SystemExit and KeyboardInterrupt — not what you want). Use except Exception as the broadest reasonable catch-all.

Keep try blocks small — only the line(s) that can raise. Put post-success logic in else so it doesn't silently get caught by the except.

Use raise (no args) to re-raise the current exception unchanged. Use raise NewError(...) from original to chain — the from preserves the original traceback.

EAFP vs LBYL: "Easier to Ask Forgiveness than Permission" — try it, catch the error — is idiomatic Python, often clearer and faster than "Look Before You Leap" existence checks.

→ Interview Tip
Avoid except Exception: pass — it's the "swallow all errors and hope" pattern. At minimum log the exception. Better, re-raise after logging, or let it propagate up to a single boundary handler.
Q43

How do you create custom exceptions?

Make a class that inherits from Exception (or a more specific built-in). That's it.

class PaymentError(Exception):
  """Raised when a payment fails to process."""

You can add structured data for richer errors:

class ValidationError(Exception):
  def __init__(self, field, value, reason):
    super().__init__(f"{field}={value!r}: {reason}")
    self.field, self.value, self.reason = field, value, reason

Design guidelines:

CUSTOM EXCEPTION CHECKLIST
01 · One base per library
Users can except YourLibError to catch anything from your package.
02 · Inherit meaningfully
Timeout-like? Inherit TimeoutError. Value issue? Inherit ValueError. Match the built-in semantics.
03 · Name ends in "Error"
FileNotFoundError, KeyError — match the stdlib pattern. Warning is the other suffix.
04 · Carry diagnostic data
Store the offending field, response code, or record ID as attributes. Log.info that to debug in prod.

Inherit never from BaseException directly — that class is reserved for system-level signals like KeyboardInterrupt and SystemExit, which you almost never want users to catch by mistake.

→ Mental Model
Exceptions are part of your API. A well-designed library exposes a small hierarchy of domain errors. Callers should be able to distinguish "my input was bad" from "the service is down" just by the exception class.
Q44

Assertions vs exceptions — what's the difference?

assert condition is a debugging aid that raises AssertionError if the condition is false. Exceptions are the general-purpose mechanism for reporting error conditions at runtime. They look similar but serve different purposes.

assertraise Exception
PurposeCatch programmer bugsHandle user/environment errors
Stripped in prod?Yes (with python -O)No
Who reads itThe developerThe caller / user
Exampleassert balance >= 0raise ValueError("Negative balance")

The critical rule: never use assert for security or input validation. If someone runs your code with python -O, every assert disappears. Assertions that must always hold — like "user is authenticated" — silently vanish, leaving a gaping hole.

# WRONG
assert user.is_authenticated, "Must be logged in"

# RIGHT
if not user.is_authenticated:
  raise PermissionError("Must be logged in")

Where assert shines: sanity checks that verify invariants — things you know must be true if your code is correct. "This queue shouldn't be empty here," "the index must be in bounds," "no two IDs overlap." These pin down assumptions in development and add almost zero runtime cost.

→ Interview Tip
"I'd never use assert to validate external input" is a near-guaranteed high-signal answer — it shows you know -O mode strips them and have thought about the production implications.
Q45

unittest vs pytest — which and why?

Both are Python testing frameworks. unittest is the stdlib module, modeled on JUnit. pytest is the de facto modern standard — richer, shorter, and widely adopted across open source and industry.

UNITTEST VS PYTEST
unittest
Stdlib — no install
Class-based, inherit TestCase
self.assertEqual(a, b)
setUp / tearDown
Verbose, xUnit-style
pytest
pip install pytest
Plain functions
plain assert a == b
Fixtures (DI-style)
Minimal, powerful, plugin ecosystem

Same test, both styles:

unittest:

class TestMath(unittest.TestCase):
  def test_add(self):
    self.assertEqual(add(2, 3), 5)

pytest:

def test_add():
  assert add(2, 3) == 5

Why pytest wins:

1. Plain assert — no memorizing assertEqual, assertIn, etc. Failure messages are still detailed via assertion rewriting.
2. Fixtures are composable and injected by name — much cleaner than setUp.
3. Parametrization: @pytest.mark.parametrize("a,b", [(1,2), (3,4)]) runs the same test with many inputs.
4. Plugins: pytest-cov (coverage), pytest-mock, pytest-xdist (parallel), pytest-asyncio.

pytest also runs unittest-style tests out of the box — you can migrate incrementally.

→ Real-World Use
If you inherit a unittest codebase, don't rewrite — just run it with pytest and write new tests in the pytest style. Years later you'll have converted naturally. New projects: go straight to pytest.
Q46

What is mocking and when should you use it?

Mocking replaces real objects with fake ones whose behavior you control, so you can test code without its real dependencies. The standard tool is unittest.mock (stdlib).

WHY MOCK?
01 · Speed
No real HTTP / DB / file calls. Tests stay in milliseconds.
02 · Determinism
Freeze time, force errors, control randomness — repeatable results.
03 · Isolation
Test your logic without depending on a third-party service being up.
04 · Verifiability
Assert that your code called an API with the right arguments.

Core primitives:

Mock — a generic fake. Any attribute or method access returns another Mock.

MagicMock — like Mock but also supports dunder methods (__iter__, __len__, etc.). Default for patch.

patch(...) — replaces an object where it's looked up for the duration of a test.

with patch('mymodule.requests.get') as mock_get:
  mock_get.return_value.json.return_value = {'ok': True}
  result = my_func()
  mock_get.assert_called_once_with('https://api/x')

Critical subtlety: patch the name where it's used, not where it's defined. If mymodule.py does from requests import get, you must patch mymodule.get, not requests.get. The lookup is done via the local binding.

When NOT to mock: if you mock everything, your tests check your mocks, not your code. Integration tests with real (or test-container) databases catch integration bugs that mocks miss. Mock at the system boundary; test internal logic for real.

→ Interview Tip
If your answer mentions "patch where it's used, not where it's defined," you've demonstrated real experience. That one rule saves more mocking hours than any other insight.
Part VIII

Pythonic Patterns

What separates a "Python-literate" engineer from a "Pythonic" one: idiom awareness, gotcha sense, and comfort with modern language features.

Gotchas
Pythonic Idioms
Duck Typing
match/case
Type Hints
PEP 695
Questions 47–50
Q47

What are common Python gotchas?

A handful of traps catch every Python developer at least once. Knowing the top few is a strong interview signal.

GotchaWhat happensFix
Mutable default argsdef f(x=[]) — list is created once, shared across callsUse None and initialize in body
Late binding in closures[lambda: i for i in range(3)] all return 2Default arg: lambda i=i: i
== vs is on small ints1000 is 1000 may be False (no interning)Use == unless checking identity
Modifying while iteratingfor x in lst: lst.remove(x) skips elementsIterate over a copy: lst[:]
Integer / string cachinga is b True for small ints, False for bigNever rely on interning
Shallow copy surprise[[0]*3]*3 — all rows are the same list[[0]*3 for _ in range(3)]
Truthy trapif items: hides None vs empty listUse if items is not None:
Circular importsModules import each other at import timeDefer import, or restructure

The single most famous gotcha deserves its own example:

def append(item, lst=[]): # BUG
  lst.append(item)
  return lst

append(1) # [1]
append(2) # [1, 2] — same list!

The default [] is evaluated once, at function definition time, and becomes a single mutable object shared across every call that doesn't override it. The fix:

def append(item, lst=None):
  if lst is None: lst = []
  lst.append(item); return lst

→ Interview Tip
If you can explain the mutable-default-argument trap clearly — why it happens (function defs are expressions, defaults are evaluated once) — that's a real-Python-experience signal, not textbook knowledge.
Q48

What does "Pythonic" mean?

Pythonic means writing code in the style and idioms the language was designed for — leveraging built-ins, protocols, and expressive syntax rather than translating idioms from C, Java, or JavaScript. import this in a REPL shows the guiding principles ("The Zen of Python").

UNPYTHONIC → PYTHONIC
UNPYTHONIC
for i in range(len(items)): print(items[i])
PYTHONIC
for item in items: print(item)
UNPYTHONIC
result = []
for x in xs: result.append(x*2)
PYTHONIC
result = [x*2 for x in xs]
UNPYTHONIC
if len(lst) == 0: ...
PYTHONIC
if not lst: ...
UNPYTHONIC
f = open('x'); try: ... finally: f.close()
PYTHONIC
with open('x') as f: ...

The Zen's tenets guide daily decisions: readability counts, there should be one obvious way to do it, explicit is better than implicit, flat is better than nested, errors should never pass silently.

Pythonic hallmarks in practice: EAFP over LBYL, comprehensions over manual loops, unpacking over indexing, with for resources, enumerate / zip over index juggling, generators over building big lists, named tuples / dataclasses over primitive dicts for records.

→ Mental Model
"Pythonic" isn't about being clever — it's about being aligned with the language. If your Python looks like translated Java, you're leaving productivity on the table. The built-ins already know these patterns.
Q49

What is duck typing?

Duck typing: "If it walks like a duck and quacks like a duck, it's a duck." Python cares about what an object can do, not what class it is. You don't declare interfaces — you rely on objects having the right methods.

NOMINAL VS DUCK TYPING
Nominal (Java, C#)
Must implement interface
Compiler enforces types
class X implements Y
"Shape by declaration"
Duck (Python)
Any object with the right methods
Runtime discovery
No explicit interface
"Shape by behavior"

A function that calls obj.quack() accepts any object that has a quack method — a Duck, a MockDuck, a RobotDuck, or some unrelated class that just happens to quack. Python doesn't ask "is this a Duck?"; it asks "can it do what I need?"

This is why Python's built-ins are so composable:

for x in obj works on any object implementing __iter__. len(obj) works on anything with __len__. json.dump(obj, f) works on any file-like object with write. Custom classes slot into Python's built-in protocols just by implementing the right dunders.

Modern twist — Protocol (PEP 544): duck typing meets static analysis. Define a Protocol with expected methods, and type checkers validate at edit-time whether passed objects satisfy it. No inheritance required — still structural.

class Sized(Protocol):
  def __len__(self) -> int: ...

Trade-off: flexibility vs. discoverability. You can plug anything in — and you won't know something's missing until it fails at runtime. Unit tests and type hints mitigate this.

→ Key Insight
Duck typing is why writing isinstance(x, list) is usually the wrong check. Prefer isinstance(x, collections.abc.Sequence) — or better, just try to use it and handle TypeError. Code that accepts "anything with this shape" is more reusable.
Q50

What's new in modern Python (3.10+)?

Python has shipped substantial upgrades every release since 3.9. Mentioning modern features tells interviewers you've kept current.

VersionHeadline FeatureWhat it adds
3.10Structural pattern matchingmatch/case — destructure and dispatch on shape
3.10Better error messages"did you forget a colon?" hints with line pointers
3.10Parenthesized withMulti-line context managers
3.11Exception groupsexcept* for concurrent errors
3.11Faster CPython10–60% speedup on many workloads
3.11Self typedef clone(self) -> Self
3.12F-string improvementsMultiline, quotes-in-quotes, backslashes allowed
3.12Type parameter syntax (PEP 695)class Stack[T], def first[T](x: list[T]) -> T
3.13Optional no-GIL buildExperimental free-threaded mode (PEP 703)

Pattern matching example:

match event:
  case {"type": "click", "x": x, "y": y}:
    handle_click(x, y)
  case Point(x, y) if x > 0:
    ...
  case _:
    default()

This is not a switch-case — it destructures data and binds variables, like pattern matching in Rust / Scala. Lifesaver for nested JSON, AST walks, and state machines.

Type hints have also grown up. list[int] instead of List[int] (3.9+); X | Y instead of Union[X, Y] (3.10+); TypedDict, Protocol, Literal, Self, and now the generic syntax class Stack[T]: from PEP 695. Mypy, pyright, and ty (Astral) give you compile-time safety without giving up Python's flexibility.

Dataclasses + type hints + match together form a modern, almost ML-like Python style — the language is simultaneously dynamic and statically analyzable.

→ Interview Tip
Modern hiring managers expect you to know match/case, | union types, and @dataclass. Mentioning PEP 695 generics or the no-GIL work (PEP 703) signals you actively follow where the language is going.
Complete

All 50 Questions.
Covered.

From fundamentals and data structures to concurrency, testing, and modern language features — a complete reference for Python interviews and day-to-day engineering.

50
Questions
8
Topic Areas
40+
Visual Diagrams
Saurabh Singh
AI Engineer & Builder
linkedin.com/in/iamsausi medium.com/@sausi github.com/sausi-7