Skip to the questions

Python Developer · Fresher to 3 years

Python interview questions

The 40 questions that come up most in Python interviews. Each one has a short answer you can say out loud in about 30 seconds, and the full explanation, with tested code, for when you need it.

Read the quick answers

4 Basics · 7 Data structures · 5 Functions · 6 OOP · 5 Generators · 5 Errors & files · 3 Memory · 3 Modules · 2 ConcurrencyUpdated 27 Oct 2026

When is your interview?

01Basics and data types

0/4 got it

Q01Not marked yetFresherAsked very oftenConcept

Is Python compiled or interpreted? What happens when you run a .py file?

Say this first~25 sec to say

Both, in two steps. CPython, the standard Python, first compiles your whole file to bytecode, a compact instruction set for a virtual machine. The Python Virtual Machine then interprets that bytecode. Imported modules have their bytecode cached as .pyc files in __pycache__, so unchanged files skip the compile step next time. Because you never run a separate build step, Python is usually called interpreted.

Could you say it?
Full answer3 min read

The two steps

report.py ──compile──▶ bytecode (.pyc, cached for imported modules) ──▶ Python Virtual Machine ──▶ output
  1. Compile. Python reads the whole file, checks the syntax and turns it into bytecode. A syntax error anywhere in the file is reported here, before a single line runs.
  2. Execute. The PVM, a loop inside CPython, runs the bytecode instruction by instruction.

You can see the compile step happen first. This file never prints first line, because the missing bracket on line 2 stops compilation:

Python
print("first line")
print("second line"
  File "demo.py", line 2
    print("second line"
         ^
SyntaxError: '(' was never closed

Looking at the bytecode

The dis module shows the instructions the PVM will run:

Python
import dis

def add(a, b):
    return a + b

dis.dis(add)
  3           0 RESUME                   0

  4           2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP                0 (+)
             10 RETURN_VALUE

That's the output on Python 3.12. The instruction names change between versions, which is fine: bytecode is an internal detail of CPython, not a stable format.

Where .pyc files come from

  • When you import a module, CPython saves its bytecode in __pycache__/ as something like utils.cpython-312.pyc, and reuses it while the source file is unchanged.
  • The script you run directly (python report.py) is compiled again each time and not cached.
  • .pyc files are only a cache. You never ship or commit them; __pycache__/ belongs in .gitignore.

How it compares

LanguageWhat happens before it runs
C, C++Compiled ahead of time to machine code for one platform
Javajavac compiles ahead of time to bytecode; the JVM runs it, compiling hot code to machine code (JIT)
Python (CPython)Compiled to bytecode automatically when you run it; the PVM interprets it

CPython 3.13 and later also include an experimental JIT compiler, switched off by default.

Follow-ups

  • Why is Python slower than C or Java? Each bytecode instruction is interpreted, and every operation checks types at runtime. Heavy number-crunching is handed to libraries written in C, such as NumPy.
  • What is PyPy? Another implementation of Python with a JIT compiler. It's often much faster for long-running pure-Python code. CPython is the reference implementation that most people use.
  • Is "interpreted" a property of the language? Strictly, no. It describes an implementation. "Python is interpreted" is shorthand for how CPython runs it.
Q02Not marked yetFresherAsked very oftenConcept

Python is dynamically typed. What does that mean, and is it strongly typed too?

Say this first~30 sec to say

Dynamically typed means types belong to objects, not to variables: a name can point to an int now and a str later, and type errors are found at runtime, when the line actually runs. Strongly typed means Python won't silently convert between unrelated types: "1" + 1 raises a TypeError instead of guessing what you meant. So Python is both dynamic and strong. JavaScript is dynamic but weak ("1" + 1 gives "11"), and Java is static and strong.

Could you say it?
Full answer2 min read

Dynamic: the name has no type, the object does

Python
x = 10           # x points to an int
x = "ten"        # now to a str: allowed
print(type(x))   # <class 'str'>

A variable is a label stuck on an object. Re-pointing the label to a different kind of object is always allowed.

Strong: no silent conversions between unrelated types

Python
print(int("1") + 1)    # 2: you convert explicitly
print("1" + str(1))    # 11
print("1" + 1)         # TypeError: can only concatenate str (not "int") to str

Python does convert within the number family, because there the meaning is clear: 1 + 2.5 is 3.5, and True + 1 is 2 because bool is a subclass of int.

The two ideas are independent

StrongWeak
Static (types checked before running)Java, Go, RustC (implicit casts between numbers and pointers)
Dynamic (types checked while running)Python, RubyJavaScript, PHP

Type hints don't change this

Python
def double(n: int) -> int:
    return n * 2

print(double(4))      # 8
print(double("ab"))   # abab: hints aren't enforced when the code runs

Type hints are notes for people and for checkers such as mypy or Pyright, which read the code before it runs. Python itself ignores them at runtime.

Follow-ups

  • What is duck typing? Python cares whether an object has the method you call, not what class it is. len() works on anything with a __len__ method: lists, strings, your own classes.
  • How do you check a type at runtime? Use isinstance(x, int), which also accepts subclasses, rather than type(x) == int.
  • Does dynamic typing make Python slower? Partly. Every a + b has to look up what + means for those two objects while the program runs.
Q03Not marked yetFresherAsked very oftenConcept

Which built-in types are mutable and which are immutable, and why does the difference matter?

Say this first~30 sec to say

Immutable: int, float, bool, complex, str, tuple, frozenset, bytes and None. Mutable: list, dict, set, bytearray, and objects of your own classes by default. A mutable object can change in place, so every name pointing to it sees the change. "Changing" an immutable object really creates a new object and points the name at it. It matters for aliasing bugs, for default arguments, and because only immutable (hashable) values can be dict keys or set members.

Could you say it?
Full answer3 min read
TypeMutable?Example
int, float, bool, complexNo42, 3.14, True
strNo"Pune"
tupleNo (but it can hold mutable items)(12.97, 77.59)
frozenset, bytesNofrozenset({1, 2}), b"hi"
listYes[1, 2]
dictYes{"city": "Pune"}
set, bytearrayYes{1, 2}, bytearray(b"hi")

What "changing" really does

Python
s = "pune"
old_id = id(s)
s += "!"                       # builds a new string
print(id(s) == old_id)         # False: s now points to a different object

nums = [1, 2]
old_id = id(nums)
nums.append(3)                 # changes the same list
print(id(nums) == old_id)      # True

Why it matters

  1. Aliasing. Two names can point to one list, and a change through either is visible through both:
Python
a = [1]
b = a
b.append(2)
print(a)   # [1, 2]
  1. Function arguments. A function can change a list you pass in, but not an int (question 34).
  2. Default arguments. A mutable default value is shared between calls (question 16).
  3. Dict keys and set members must be hashable, which in practice means immutable:
Python
locations = {(12.97, 77.59): "Bengaluru"}   # a tuple works as a key
locations[[12.97, 77.59]] = "Bengaluru"      # TypeError: unhashable type: 'list'

A tuple is only as immutable as its contents

Python
t = (1, [2])
t[1].append(3)    # the tuple still holds the same list; the list changed
print(t)          # (1, [2, 3])
hash(t)           # TypeError: unhashable type: 'list'

Follow-ups

  • Is += the same for a list and a tuple? No. On a list, += extends the same object in place. On a tuple or an int, it builds a new object:
Python
a = [1]
b = a
a += [2]
print(b)   # [1, 2]: same list

t = (1,)
u = t
t += (2,)
print(u)   # (1,): t now names a new tuple
  • Why are strings immutable? They can be shared safely, cached and used as dict keys, and their hash can be computed once and reused.
  • How do you build a long string efficiently? Collect the pieces in a list and call "".join(parts) once, instead of += in a loop, which creates a new string each time.
Q04Not marked yetFresherAsked very oftenPredict the output

What does this print? a = [1, 2]; b = a; c = [1, 2]; print(a == c, a is c, a is b)

Say this first~25 sec to say

It prints True False True. == compares values: a and c hold equal contents. is compares identity, whether two names point to the same object. c is a separate list that happens to be equal, while b = a makes b a second name for the same list. Use == to compare values, and keep is for identity checks such as x is None.

Could you say it?
Full answer3 min read
Python
a = [1, 2]
b = a          # a second name for the same list
c = [1, 2]     # a new list with equal contents
print(a == c, a is c, a is b)   # True False True

b.append(3)
print(a)       # [1, 2, 3]: a and b are one object
print(c)       # [1, 2]

Why is on numbers and strings is a trap

CPython keeps one shared object for each small integer (-5 to 256 in Python 3.12 and 3.13; 3.15 extends the range to 1024), and reuses some strings. So is can seem to work on values, until it doesn't:

Python
x = 256
y = int("256")
print(x is y)    # True: small ints are cached

x = 100_000
y = int("100000")
print(x is y)    # False: two separate int objects
print(x == y)    # True

Writing x = 100_000; y = 100_000 in one script can even print True for x is y, because the compiler reuses one constant for both. The answer depends on the Python version and on how the code was run, which is exactly why is must never compare numbers or strings. Python warns you when it spots this:

SyntaxWarning: "is" with 'int' literal. Did you mean "=="?

When is is the right choice

  • Checking for None: if result is None:. There is only ever one None object, and is can't be fooled by a class with an unusual __eq__.
  • Comparing against your own sentinel object, such as MISSING = object().
Python
def find(items, target):
    for index, item in enumerate(items):
        if item == target:
            return index
    return None

position = find(["chai", "dosa"], "idli")
if position is None:
    print("not found")   # not found

Follow-ups

  • What does id() return? A number that is unique to an object while it's alive. In CPython it's the object's memory address. a is b is the same as id(a) == id(b).
  • Can == be True across types? Yes: 1 == 1.0 and 1 == True are both True.
  • How does == work for your own classes? It calls __eq__. Without one, it falls back to identity, so two separate objects are never equal (question 21).

02Built-in data structures

0/7 got it

Q05Not marked yetFresherAsked very oftenConcept

How is a list different from a tuple, and when would you choose a tuple?

Say this first~30 sec to say

Both are ordered sequences that allow duplicates and any mix of types. A list is mutable: you can append, remove and change items. A tuple is immutable: once built, it can't change. Choose a tuple for a fixed record whose shape won't change, like (latitude, longitude) or a database row; for values that must be hashable, such as dict keys; and to signal "don't modify this". Tuples are also a little smaller and faster to create.

Could you say it?
Full answer3 min read
listtuple
Syntax[1, 2](1, 2) or just 1, 2
Can change after creationYesNo
MethodsMany: append, extend, insert, remove, pop, sort, …Only count and index
Usable as a dict keyNoYes, if everything inside is hashable
MemoryA little more (it keeps spare room to grow)Less
Typical useA collection of similar items that grows or shrinksA fixed record of fields
Python
import sys

point = (12.97, 77.59)         # Bengaluru: latitude, longitude
lat, lng = point               # unpacking a tuple into names

scores = [72, 85]
scores.append(90)              # lists grow
print(scores)                  # [72, 85, 90]

places = {point: "office"}     # a tuple works as a dict key
print(places[(12.97, 77.59)])  # office

print(sys.getsizeof([1, 2, 3]), sys.getsizeof((1, 2, 3)))   # 88 64 on 64-bit CPython 3.12
point[0] = 0                   # TypeError: 'tuple' object does not support item assignment

The one-item tuple

The comma makes a tuple, not the brackets:

Python
print(type((5)))    # <class 'int'>: just a number in brackets
print(type((5,)))   # <class 'tuple'>

Readable records: when a tuple has more than two or three fields, a named tuple (or a dataclass) lets you write row.city instead of row[2]:

Python
from typing import NamedTuple

class Employee(NamedTuple):
    name: str
    city: str

asha = Employee("Asha", "Pune")
print(asha.city)    # Pune
print(asha[0])      # Asha: still a tuple underneath

Follow-ups

  • Is a tuple faster than a list? Slightly faster to create and a little smaller, because its size is fixed. That's rarely the reason to choose one; meaning is.
  • Can a tuple contain a list? Yes, and that list can still change. The tuple then can't be hashed (question 3).
  • How do you "change" a tuple? Build a new one: t = t[:1] + (99,) + t[2:], or convert to a list, change it, and convert back.
Q06Not marked yetFresherAsked very oftenConcept

How does slicing work, and what do negative indices and [::-1] do?

Say this first~30 sec to say

seq[start:stop:step] returns a new sequence from start up to but not including stop, taking every step-th item. Any part can be left out: start defaults to the beginning, stop to the end and step to 1. Negative indices count from the end, so -1 is the last item. A negative step walks backwards, so [::-1] gives a reversed copy. Slicing never raises IndexError: out-of-range bounds are clipped to the sequence.

Could you say it?
Full answer2 min read
Python
nums = [10, 20, 30, 40, 50]
#        0   1   2   3   4     positive indices
#       -5  -4  -3  -2  -1     negative indices

print(nums[1:4])     # [20, 30, 40]: stop is excluded
print(nums[:2])      # [10, 20]: the first two
print(nums[-2:])     # [40, 50]: the last two
print(nums[::2])     # [10, 30, 50]: every second item
print(nums[::-1])    # [50, 40, 30, 20, 10]
print(nums[4:1:-1])  # [50, 40, 30]: backwards from index 4, stopping before 1
print(nums[3:100])   # [40, 50]: no error, the bound is clipped
print(nums[-1])      # 50
print("python"[::-1])   # nohtyp: works on any sequence

A slice of a list is a new list

Python
copy = nums[:]       # a shallow copy of the whole list
copy[0] = 99
print(nums[0])       # 10: the original is untouched

Assigning to a slice changes a list in place

Python
nums = [1, 2, 3, 4, 5]
nums[1:3] = ["a"]    # replace two items with one
print(nums)          # [1, 'a', 4, 5]
del nums[::2]        # delete every second item
print(nums)          # ['a', 5]

Index vs slice when you go out of range

Python
items = [1, 2, 3]
print(items[5:])     # []: a slice past the end is empty
print(items[5])      # IndexError: list index out of range

Follow-ups

  • [::-1] vs reversed() vs list.reverse()? [::-1] builds a reversed copy. reversed(items) returns a lazy iterator, with no copy. items.reverse() reverses the list in place and returns None.
  • How would you check for a palindrome? text == text[::-1], after normalising case and removing spaces if the question needs it.
  • What is a slice object? middle = slice(1, 4) stores the bounds, so items[middle] reuses them. Handy when the same slice appears in several places.
Q07Not marked yet1–3 yrsAsked oftenConcept

How does a dictionary find a key so quickly, and why must keys be hashable?

Say this first~30 sec to say

A dict is a hash table. Python calls hash(key) and uses the number to pick a slot in an internal array, so a lookup jumps straight to the right place: O(1) on average. When two keys land in the same slot, it probes further and uses == to find the exact key. Keys must be hashable, meaning their hash never changes; otherwise the dict would look in the wrong slot. That's why lists can't be keys but tuples can.

Could you say it?
Full answer3 min read
Python
prices = {"chai": 20, "samosa": 15}
print(hash("chai") == hash("chai"))   # True: same key, same hash, same slot
print(prices["chai"])                 # 20: one hash, one slot, no scan

prices[("veg", "large")] = 120        # a tuple of immutable values is hashable
prices[["veg", "large"]] = 120        # TypeError: unhashable type: 'list'

Newer versions of Python (3.15, for example) word that error as cannot use 'list' as a dict key (unhashable type: 'list').

What goes wrong if a key's hash changes

Here a class computes its hash from a field that can change, which breaks the rule:

Python
class Ticket:
    def __init__(self, seat):
        self.seat = seat

    def __eq__(self, other):
        return isinstance(other, Ticket) and self.seat == other.seat

    def __hash__(self):
        return hash(self.seat)     # depends on a field that can change

ticket = Ticket("A1")
booked = {ticket}
ticket.seat = "B7"                 # its hash changes after it went into the set
print(ticket in booked)            # False: Python looks in the slot for "B7"

The object is still in the set, but it's filed under its old hash, so it can't be found. Lists are unhashable precisely to prevent this.

The rules a hashable object follows

  • Objects that compare equal must have the same hash.
  • The hash must not change while the object is alive.
  • Your own classes are hashable by identity by default. If you define __eq__ without __hash__, Python makes instances unhashable, so you can't break the first rule by accident.

Costs

OperationAverageWorst case (many collisions)
d[key], key in d, d[key] = value, del d[key]O(1)O(n)
Looping over all itemsO(n)O(n)
x in some_list, for comparisonO(n)O(n)

Insertion order is guaranteed

Python
d = {}
d["b"] = 1
d["a"] = 2
print(list(d))   # ['b', 'a']: the order the keys were added

Follow-ups

  • Why is x in my_list slow but x in my_set fast? A list is checked item by item. A set is a hash table like a dict, so it jumps to one slot.
  • Can 1, 1.0 and True be separate keys? No. They compare equal and hash the same, so they're one key: {1: "a", 1.0: "b", True: "c"} is {1: 'c'}.
  • What stops an attacker from sending keys that all collide? Python randomises string hashes per process (hash randomisation), so collisions can't be planned in advance.
Q08Not marked yetFresherAsked very oftenCoding

Write one list comprehension that keeps only the even numbers in a list and squares them

Say this first~25 sec to say

squares = [n * n for n in nums if n % 2 == 0]. Read it as expression, loop, filter: for each n in nums, keep it if it's even, and put n * n in the new list. It replaces a loop plus append(), is usually faster, and leaves the original list alone. The same syntax builds dicts and sets, and with round brackets, a lazy generator.

Could you say it?
Full answer2 min read
Python
nums = [1, 2, 3, 4, 5, 6]

squares = [n * n for n in nums if n % 2 == 0]
print(squares)   # [4, 16, 36]

# The same thing as a loop
squares = []
for n in nums:
    if n % 2 == 0:
        squares.append(n * n)
print(squares)   # [4, 16, 36]

Filtering vs choosing a value

An if at the end filters items out. An if … else before the for chooses a value for every item:

Python
labels = ["even" if n % 2 == 0 else "odd" for n in nums]
print(labels)    # ['odd', 'even', 'odd', 'even', 'odd', 'even']

Dict, set and generator comprehensions

Python
words = ["chai", "dosa", "chai", "idli"]

lengths = {w: len(w) for w in words}      # dict: later duplicates overwrite earlier ones
first_letters = {w[0] for w in words}     # set: duplicates disappear
total = sum(len(w) for w in words)        # generator: no list is built in memory

print(lengths)                 # {'chai': 4, 'dosa': 4, 'idli': 4}
print(sorted(first_letters))   # ['c', 'd', 'i']
print(total)                   # 16

Nested loops read left to right, in the same order as nested for statements:

Python
pairs = [(x, y) for x in range(2) for y in "ab"]
print(pairs)     # [(0, 'a'), (0, 'b'), (1, 'a'), (1, 'b')]

matrix = [[1, 2], [3, 4]]
flat = [value for row in matrix for value in row]
print(flat)      # [1, 2, 3, 4]

When not to use one: if the logic needs several statements, has side effects such as printing or saving, or nests more than two loops, a normal loop is easier to read.

Follow-ups

  • Does the loop variable leak out? No. In Python 3 a comprehension has its own scope, so n doesn't exist after the line runs.
  • Comprehension or map() and filter()? list(map(lambda n: n * n, filter(lambda n: n % 2 == 0, nums))) gives the same result, but the comprehension is easier to read.
  • Why is it faster than append() in a loop? It avoids looking up and calling squares.append on every pass.
Q09Not marked yetFresherAsked very oftenCoding

Remove duplicates from a list while keeping the remaining items in their original order

Say this first~20 sec to say

list(dict.fromkeys(items)). Dict keys are unique and, since Python 3.7, keep insertion order, so the first time each item appears wins. list(set(items)) also removes duplicates but loses the order, because a set doesn't remember it. The hand-written version loops once with a seen set, which is O(n). All of these need the items to be hashable.

Could you say it?
Full answer2 min read
Python
cities = ["Pune", "Delhi", "Pune", "Chennai", "Delhi"]

print(list(dict.fromkeys(cities)))   # ['Pune', 'Delhi', 'Chennai']
print(list(set(cities)))             # the same three, in an unpredictable order

Writing it yourself

Python
def dedupe(items):
    seen = set()
    result = []
    for item in items:
        if item not in seen:     # an O(1) check, because seen is a set
            seen.add(item)
            result.append(item)
    return result

print(dedupe([3, 1, 3, 2, 1]))   # [3, 1, 2]

Checking if item not in result instead would scan the result list on every pass, making the whole thing O(n²).

Unhashable items: dedupe by a key

Dicts can't go into a set, so pick the field that identifies them:

Python
users = [
    {"id": 1, "name": "Asha"},
    {"id": 2, "name": "Ravi"},
    {"id": 1, "name": "Asha"},
]
unique = list({user["id"]: user for user in users}.values())
print([user["name"] for user in unique])   # ['Asha', 'Ravi']

That keeps the position of each id's first appearance, with the value from its last one.

ApproachKeeps order?TimeNeeds hashable items?
list(dict.fromkeys(items))YesO(n)Yes
list(set(items))NoO(n)Yes
Loop with a seen setYesO(n)Yes
Loop checking in resultYesO(n²)No

Follow-ups

  • How would you dedupe ignoring case? Keep item.lower() in the seen set, but append the original item.
  • How do you count the duplicates instead? collections.Counter(items) gives each item's count.
  • Why does a set lose order? It places items by hash, not by arrival, and doesn't record the arrival order.
Q10Not marked yetFresherAsked oftenCoding

Given two lists, use sets to find the items they share and the items found only in the first

Say this first~25 sec to say

Convert both lists to sets, then use intersection, a & b, for the shared items and difference, a - b, for the items only in the first. Each is one expression and runs in about O(len(a) + len(b)) time, instead of a loop inside a loop. The catch: sets drop duplicates and don't keep order, so sort the result, or filter the original list, when order matters.

Could you say it?
Full answer2 min read
Python
skills_needed = ["python", "sql", "excel", "power bi"]
my_skills = ["sql", "python", "git"]

needed, have = set(skills_needed), set(my_skills)

print(sorted(needed & have))   # ['python', 'sql']: in both
print(sorted(needed - have))   # ['excel', 'power bi']: only in the first
print(sorted(needed | have))   # ['excel', 'git', 'power bi', 'python', 'sql']: in either
print(sorted(needed ^ have))   # ['excel', 'git', 'power bi']: in exactly one

The method versions accept any iterable, so you don't need to convert the second list: needed.intersection(my_skills).

Keeping the original order

Python
missing = [skill for skill in skills_needed if skill not in have]
print(missing)   # ['excel', 'power bi']

The set operators

OperatorMethodResult
a & ba.intersection(b)Items in both
a | ba.union(b)Items in either
a - ba.difference(b)Items in a but not in b
a ^ ba.symmetric_difference(b)Items in exactly one
a <= ba.issubset(b)Is every item of a in b?
a.isdisjoint(b)Do they share nothing?

What is a set? An unordered collection of unique, hashable items, built on a hash table like a dict's keys. Membership tests are O(1) on average. set() makes an empty set; {} makes an empty dict. A frozenset is the immutable version, so it can be a dict key.

Follow-ups

  • When would you use a set instead of a list? For fast in checks, for removing duplicates, and for comparing two groups of items.
  • Can a set hold lists? No, lists aren't hashable. Use tuples instead.
  • What's the time complexity of a & b? About O(min(len(a), len(b))): Python loops over the smaller set and checks each item in the larger one.
Q11Not marked yetFresherAsked oftenPredict the output

What does this print? nums = [3, 1, 2]; result = nums.sort(); print(result, nums)

Say this first~20 sec to say

It prints None [1, 2, 3]. list.sort() sorts the list in place and returns None, following Python's convention that methods which change an object don't also return it. So result is None, while nums itself is now sorted. To get a new sorted list and keep the original, use sorted(nums), which works on any iterable.

Could you say it?
Full answer2 min read
Python
nums = [3, 1, 2]
result = nums.sort()
print(result, nums)     # None [1, 2, 3]

nums = [3, 1, 2]
ordered = sorted(nums)
print(ordered, nums)    # [1, 2, 3] [3, 1, 2]
list.sort()sorted()
Works onLists onlyAny iterable: tuples, strings, dict keys, generators
ReturnsNoneA new list
OriginalChangedUnchanged
Extra memoryNoneA copy

A classic bug follows from this: nums = nums.sort() leaves nums set to None. append, reverse and random.shuffle behave the same way.

Both take key and reverse

Python
names = ["ravi", "Asha", "arjun"]
print(sorted(names))                         # ['Asha', 'arjun', 'ravi']: capitals sort first
print(sorted(names, key=str.lower))          # ['arjun', 'Asha', 'ravi']
print(sorted(names, key=len, reverse=True))  # ['arjun', 'ravi', 'Asha']

Sorting is stable

Items with equal keys keep their original order, so you can sort by several fields:

Python
students = [("Asha", "B"), ("Ravi", "A"), ("Priya", "B"), ("Arjun", "A")]
by_grade = sorted(students, key=lambda student: student[1])
print(by_grade)   # [('Ravi', 'A'), ('Arjun', 'A'), ('Asha', 'B'), ('Priya', 'B')]

Ravi stays ahead of Arjun, and Asha ahead of Priya, because that's the order they started in.

Follow-ups

  • Which algorithm does Python use? Timsort, a mix of merge sort and insertion sort: O(n log n) in the worst case, close to O(n) on data that's already mostly sorted, and stable.
  • How do you sort a dict by its values? sorted(scores.items(), key=lambda item: item[1]) returns a list of (key, value) pairs.
  • Why does sort() return None? So nobody mistakes it for a function that returns a sorted copy and leaves the original alone.

03Functions and scope

0/5 got it

Q12Not marked yetFresherAsked very oftenConcept

What are *args and **kwargs, and when would you use them?

Say this first~30 sec to say

In a function definition, *args collects any extra positional arguments into a tuple, and **kwargs collects any extra keyword arguments into a dict. The names are only a convention; the * and ** do the work. Use them for functions that accept a variable number of arguments, and for wrappers such as decorators that must pass every argument through unchanged. At a call site, the same stars do the opposite: they unpack a list or dict into separate arguments.

Could you say it?
Full answer2 min read
Python
def total(*args):
    print(args)               # (10, 20, 30): a tuple
    return sum(args)

print(total(10, 20, 30))      # 60

def make_profile(name, **kwargs):
    print(kwargs)             # {'city': 'Pune', 'role': 'SDE-1'}: a dict
    return {"name": name, **kwargs}

print(make_profile("Asha", city="Pune", role="SDE-1"))
# {'name': 'Asha', 'city': 'Pune', 'role': 'SDE-1'}

The order of parameters

Regular parameters come first, then *args, then keyword-only parameters, then **kwargs:

Python
def describe(a, b=2, *args, c, **kwargs):
    print(a, b, args, c, kwargs)

describe(1, 5, 6, 7, c=3, x=9)   # 1 5 (6, 7) 3 {'x': 9}

c comes after *args, so it can only be passed by keyword.

Unpacking at the call site

Python
point = [3, 4]
options = {"sep": " | "}
print(*point, **options)   # 3 | 4: same as print(3, 4, sep=" | ")

The main real-world use: passing everything through

A wrapper that doesn't know what its target function takes can still forward every argument:

Python
def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__} with {args} {kwargs}")
        return func(*args, **kwargs)
    return wrapper

def greet(name, greeting="Hello"):
    return f"{greeting}, {name}"

print(log_call(greet)("Ravi", greeting="Hi"))
# calling greet with ('Ravi',) {'greeting': 'Hi'}
# Hi, Ravi

Follow-ups

  • Is args a list? No, it's a tuple, so the function can't accidentally change it.
  • What does a bare * in a signature mean? Everything after it must be passed by keyword: def connect(host, *, timeout=5). A / does the reverse: parameters before it can only be passed by position.
  • Can you rename them? Yes. *numbers and **options work exactly the same; only the stars matter.
Q13Not marked yetFresherAsked very oftenConcept

What is a lambda, and where would you use one with map(), filter() or sorted()?

Say this first~25 sec to say

A lambda is a small anonymous function written as a single expression: lambda x: x * 2. Its value is returned automatically; there's no return and no statements. Use it for short throwaway functions passed to other functions: most often the key for sorted(), min() and max(), or with map() and filter(). If you'd want to give it a name or a second line, write a def instead.

Could you say it?
Full answer2 min read
Python
nums = [1, 2, 3, 4, 5]
print(list(map(lambda n: n * 10, nums)))          # [10, 20, 30, 40, 50]
print(list(filter(lambda n: n % 2 == 1, nums)))   # [1, 3, 5]

employees = [("Asha", 72000), ("Ravi", 55000), ("Priya", 91000)]
print(sorted(employees, key=lambda e: e[1]))
# [('Ravi', 55000), ('Asha', 72000), ('Priya', 91000)]
print(max(employees, key=lambda e: e[1])[0])      # Priya

map() and filter() return lazy iterators in Python 3, which is why they're wrapped in list() to print.

A lambda is just a shorter def

Python
square = lambda x: x * x      # works, but style guides discourage naming a lambda

def square(x):                # say this instead
    return x * x

Both create the same kind of function object. The def version has a real name, which shows up in error messages.

What a lambda can't do: contain statements such as if blocks, loops, try or assignments. A conditional expression is fine: lambda n: "even" if n % 2 == 0 else "odd".

A comprehension is often clearer than map or filter

Python
print([n * 10 for n in nums if n % 2 == 1])   # [10, 30, 50]

Follow-ups

  • Can a lambda have default arguments? Yes: lambda x, y=2: x + y.
  • What's operator.itemgetter? A ready-made key function: sorted(employees, key=itemgetter(1)) does the same as the lambda above.
  • Is a lambda faster than a def? No. They compile to the same kind of function.
Q14Not marked yetFresherAsked oftenConcept

What is the LEGB rule, and when do you need the global or nonlocal keyword?

Say this first~30 sec to say

LEGB is the order Python searches for a name: Local (inside the current function), Enclosing (any outer function it's nested in), Global (the module's top level), then Built-in (names like len and print). Reading an outer variable just works. But assigning to a name inside a function makes it local to that whole function, so to rebind a module-level variable you declare it global, and to rebind a variable of an enclosing function you declare it nonlocal.

Could you say it?
Full answer4 min read
Python
x = "global"

def outer():
    x = "enclosing"
    def inner():
        x = "local"
        print(x)      # local
    inner()
    print(x)          # enclosing

outer()
print(x)              # global
print(len("abc"))     # 3: len is found in the built-in scope

The UnboundLocalError trap

Python
count = 0

def increment():
    count += 1        # an assignment, so count is local to this function

increment()
# UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

Because of the assignment, Python treats count as local for the whole function, so the read in count + 1 fails before anything is assigned.

global rebinds a module-level name

Python
count = 0

def increment():
    global count
    count += 1

increment()
increment()
print(count)   # 2

nonlocal rebinds a name in the enclosing function

Python
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

counter = make_counter()
counter()
print(counter())   # 2

Changing an object is not rebinding a name

Python
cart = []

def add(item):
    cart.append(item)   # no global needed: the name cart isn't reassigned

add("pen")
print(cart)   # ['pen']
VariableDefinedVisibleTo reassign it from inside a function
LocalInside the functionThat function onlyNothing needed
EnclosingIn an outer functionNested functionsnonlocal
GlobalAt module levelThe whole moduleglobal
Built-inBy PythonEverywhereDon't: shadowing built-ins causes bugs

Follow-ups

  • Do if and for blocks create a new scope? No. Only modules, functions and classes do (plus comprehensions). A variable set in a for loop is still there after the loop.
  • What happens if you name a variable list? It shadows the built-in, so a later list("abc") fails with TypeError: 'list' object is not callable.
  • Should you use global often? No. Shared mutable state makes code hard to test. Pass values in and return results instead.
Q15Not marked yet1–3 yrsAsked sometimesConcept

What is a closure, and what does a simple one look like?

Say this first~25 sec to say

A closure is an inner function that remembers variables from its enclosing function, even after that outer function has returned. Python keeps those variables alive in cells attached to the inner function. Closures let you build functions with preset, private state, such as a multiplier factory or a counter, without writing a class. They're also how decorators keep hold of the function they wrap.

Could you say it?
Full answer2 min read
Python
def make_multiplier(factor):
    def multiply(n):
        return n * factor          # factor comes from the enclosing function
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5), triple(5))        # 10 15

print(double.__code__.co_freevars)            # ('factor',)
print(double.__closure__[0].cell_contents)    # 2: the remembered value

make_multiplier has returned, but each returned function still carries its own factor.

Closures capture variables, not values

That's the source of a classic trap with loops:

Python
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])   # [2, 2, 2]: every lambda reads i when it's called

funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])   # [0, 1, 2]: a default argument captures the value now

All three lambdas share one variable i, which is 2 by the time they run. A default argument is evaluated when each lambda is created, so it freezes the current value.

Closure or class?

Use a closure whenUse a class when
The state is one or two valuesThere's a lot of state
There's one operationYou need several methods
You want a quick function factoryOther code needs to inspect or change the state

Follow-ups

  • How is a closure different from any nested function? A nested function that uses nothing from its outer function isn't a closure. It becomes one when it uses outer variables and outlives the outer call.
  • How does a closure change an outer variable? With nonlocal (question 14).
  • Where are closures used in practice? Decorators (question 25), callbacks, and factories that return configured functions.
Q16Not marked yetFresherAsked very oftenPredict the output

Given def add(item, items=[]): items.append(item); return items, what do print(add(1)) and then print(add(2)) show?

Say this first~25 sec to say

[1] and then [1, 2]. A default value is evaluated once, when the function is defined, not on each call. Every call that relies on the default therefore gets the same list object, and append keeps adding to it. The fix is to use None as the default and create a new list inside the function: if items is None: items = [].

Could you say it?
Full answer2 min read
Python
def add(item, items=[]):
    items.append(item)
    return items

print(add(1))             # [1]
print(add(2))             # [1, 2]: the same default list as last time
print(add.__defaults__)   # ([1, 2],): the default lives on the function object

The fix

Python
def add(item, items=None):
    if items is None:
        items = []        # a fresh list on every call
    items.append(item)
    return items

print(add(1))   # [1]
print(add(2))   # [2]

It isn't only lists

Dicts, sets and any other mutable default behave the same way. So does any default that's computed, because it's computed once:

Python
import time

def stamp(at=time.time()):
    return at

print(stamp() == stamp())   # True: time.time() ran once, when def ran

Immutable defaults such as 0, "", None and tuples are safe, because they can't be changed in place.

Follow-ups

  • Why does Python work this way? def is a statement that runs once. It builds the function object and evaluates the defaults at that moment, storing them on the function.
  • Is the shared default ever useful? It's occasionally used as a cache, but functools.lru_cache does that job more clearly.
  • Will tools catch it? Yes. Linters such as Pylint and Ruff flag mutable default arguments.

04Python OOP specifics

0/6 got it

Q17Not marked yetFresherAsked very oftenConcept

What do self and __init__ do in a Python class?

Say this first~30 sec to say

self is the object a method is called on. When you write asha.raise_salary(10), Python calls Employee.raise_salary(asha, 10), passing the object in as the first argument, which is named self by convention. __init__ is the initialiser: Python calls it automatically right after creating a new object, to give it its starting attributes, like self.name = name. Strictly, __new__ creates the object and __init__ fills it in, which is why __init__ must return None.

Could you say it?
Full answer2 min read
Python
class Employee:
    def __init__(self, name, salary):
        self.name = name              # instance attributes are stored on self
        self.salary = salary

    def raise_salary(self, percent):
        self.salary = round(self.salary * (1 + percent / 100))
        return self.salary

asha = Employee("Asha", 60000)        # Python calls __init__(asha, "Asha", 60000)
print(asha.raise_salary(10))           # 66000
print(Employee.raise_salary(asha, 10)) # 72600: what asha.raise_salary(10) really does
print(asha.__dict__)                   # {'name': 'Asha', 'salary': 72600}

Forgetting self

Python
class Broken:
    def hello():                # no self parameter
        return "hi"

Broken().hello()
# TypeError: Broken.hello() takes 0 positional arguments but 1 was given

The "1 was given" is the object itself, which Python always passes as the first argument.

What happens on Employee("Asha", 60000)

  1. Employee.__new__ creates an empty object.
  2. Employee.__init__ runs with that object as self and sets its attributes.
  3. The finished object is returned to you.

You almost never write __new__ yourself; it's for special cases such as subclassing immutable types.

Follow-ups

  • Is self a keyword? No, just a strong convention. Any name works, but using another one confuses every reader.
  • Can a class have two __init__ methods? No. Python has no overloading, so a second definition replaces the first. Use default arguments, or alternative constructors with @classmethod (question 18).
  • Does a subclass run its parent's __init__? Only if it doesn't define its own, or if it calls super().__init__(...) explicitly.
Q18Not marked yetFresherAsked very oftenConcept

How do instance methods, @classmethod and @staticmethod differ?

Say this first~30 sec to say

An instance method receives the object as self, so it can read and change that object's data. A class method (@classmethod) receives the class as cls, so it works with class-level data and is the usual way to write alternative constructors such as Employee.from_csv(...); because it gets cls, it also works correctly for subclasses. A static method (@staticmethod) receives neither: it's a plain function that lives in the class because it belongs there logically, such as a validation helper.

Could you say it?
Full answer3 min read
Python
class Employee:
    raise_percent = 10                    # class attribute, shared

    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def apply_raise(self):                # instance method: needs self
        self.salary = round(self.salary * (1 + self.raise_percent / 100))

    @classmethod
    def from_csv(cls, row):               # class method: builds an object
        name, salary = row.split(",")
        return cls(name, int(salary))

    @staticmethod
    def is_valid_salary(amount):          # static method: no self, no cls
        return amount > 0

ravi = Employee.from_csv("Ravi,50000")
ravi.apply_raise()
print(ravi.name, ravi.salary)             # Ravi 55000
print(Employee.is_valid_salary(-5))       # False

Why cls matters for subclasses

Python
class Manager(Employee):
    raise_percent = 15

priya = Manager.from_csv("Priya,80000")
print(type(priya).__name__)   # Manager: cls was Manager, not Employee
priya.apply_raise()
print(priya.salary)           # 92000

If from_csv had hard-coded Employee(...), Manager.from_csv would return the wrong type.

Instance method@classmethod@staticmethod
First argumentself, the objectcls, the classNone
Can use the object's dataYesNoNo
Can use class dataYes, through selfYesOnly by naming the class
Typical useBehaviour of one objectAlternative constructors, class-wide settingsHelpers that belong with the class

Follow-ups

  • Can you call a class method on an object? Yes. ravi.from_csv(...) works, and Python passes type(ravi) as cls.
  • Why not make the static method a normal function? You can. A static method is about organisation: it keeps a helper next to the class it serves.
  • Where have you seen class methods in the standard library? dict.fromkeys(...) and datetime.fromisoformat(...) are both alternative constructors.
Q19Not marked yetFresherAsked oftenConcept

Does Python have private attributes? What do _name and __name mean?

Say this first~30 sec to say

No. Python has no truly private attributes; privacy is a convention. One leading underscore, _name, means "internal, please don't use this from outside", but nothing stops you. Two leading underscores, __name, trigger name mangling: inside class Account, self.__balance is stored as _Account__balance. That prevents accidental clashes with attributes of subclasses, but the value is still reachable under the mangled name. Names with two underscores on both sides, like __init__, are special methods and aren't mangled.

Could you say it?
Full answer3 min read
Python
class Account:
    def __init__(self, owner, balance):
        self.owner = owner
        self._branch = "Pune"        # internal by convention
        self.__balance = balance     # name-mangled

    def balance(self):
        return self.__balance        # inside the class, the short name works

acct = Account("Asha", 5000)
print(acct._branch)                  # Pune: allowed, just impolite
print(acct._Account__balance)        # 5000: the mangled name still works
print(list(vars(acct)))              # ['owner', '_branch', '_Account__balance']
print(acct.__balance)                # AttributeError: 'Account' object has no attribute '__balance'

What mangling is actually for

It stops a subclass from overwriting a parent's attribute by accident:

Python
class Base:
    def __init__(self):
        self.__id = "base"

class Child(Base):
    def __init__(self):
        super().__init__()
        self.__id = "child"          # a different attribute, not a clash

print(vars(Child()))   # {'_Base__id': 'base', '_Child__id': 'child'}

Controlled access the Pythonic way: @property

Python
class Account:
    def __init__(self, balance):
        self._balance = balance

    @property
    def balance(self):               # read like an attribute: acct.balance
        return self._balance

acct = Account(5000)
print(acct.balance)                  # 5000
acct.balance = 0                     # AttributeError: property 'balance' of 'Account' object has no setter
NameMeaningEnforced?
namePublicNot applicable
_nameInternal; skipped by from module import *No
__nameMangled to _ClassName__nameOnly by renaming
__name__A special method or attribute defined by PythonNot applicable

Follow-ups

  • Is name mangling a security feature? No. Anyone can read _Account__balance. It only prevents accidents.
  • How do you validate a value when it's set? Add a setter with @balance.setter and check the value there.
  • How does Java compare? Java's private is enforced by the compiler. Python trusts developers instead: "we're all consenting adults here".
Q20Not marked yet1–3 yrsAsked oftenConcept

With multiple inheritance, how does Python decide which method to call, and what does super() do?

Say this first~25 sec to say

Python follows the class's method resolution order (MRO), one ordered list of classes built by C3 linearisation: every class comes before its parents, and bases keep the left-to-right order you wrote. Python uses the first class in that list that has the method; ClassName.__mro__ shows it. super() means "the next class in the MRO", not "my parent", which is how each class in a diamond runs exactly once.

Could you say it?
Full answer3 min read
Python
class A:
    def hello(self):
        return "A"

class B(A):
    def hello(self):
        return "B -> " + super().hello()

class C(A):
    def hello(self):
        return "C -> " + super().hello()

class D(B, C):
    def hello(self):
        return "D -> " + super().hello()

print(D().hello())                           # D -> B -> C -> A
print([cls.__name__ for cls in D.__mro__])   # ['D', 'B', 'C', 'A', 'object']

B inherits from A, yet inside a D object, B's super() goes to C, because C is next in D's MRO. A runs once, at the end.

Cooperative __init__

The same idea makes initialisers chain correctly. Each class takes what it needs and passes the rest along:

Python
class Base:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)

class Named(Base):
    def __init__(self, name, **kwargs):
        self.name = name
        super().__init__(**kwargs)

class Located(Base):
    def __init__(self, city, **kwargs):
        self.city = city
        super().__init__(**kwargs)

class Office(Named, Located):
    pass

office = Office(name="HQ", city="Pune")
print(office.name, office.city)   # HQ Pune

When no consistent order exists

Python
class X: pass
class Y(X): pass
class Z(X, Y): pass
# TypeError: Cannot create a consistent method resolution order (MRO) for bases X, Y

Z(X, Y) asks for X before Y, but Y is a subclass of X and must come before it. C3 can't satisfy both, so Python refuses to create the class.

Follow-ups

  • What's the MRO with single inheritance? Just the chain: the class, its parent, the grandparent, and finally object.
  • What does super(D, self) mean? The Python 2 spelling: "the class after D in self's MRO". In Python 3, plain super() works it out for you.
  • What is a mixin? A small class that adds one ability, such as JsonMixin with a to_json() method, listed before the main base class.
Q21Not marked yet1–3 yrsAsked sometimesCoding

Write a Point class where Point(1, 2) + Point(3, 4) gives Point(4, 6), == compares coordinates, and printing a point shows Point(4, 6)

Say this first~25 sec to say

Define dunder methods, which Python calls behind operators and built-ins: a + b calls a.__add__(b), a == b calls a.__eq__(b), and print(a) uses __str__, falling back to __repr__. So __add__ returns a new Point, __eq__ compares x and y, and __repr__ returns "Point(4, 6)". For a type you don't support, return NotImplemented, so Python can try the other operand or raise a clean TypeError.

Could you say it?
Full answer4 min read
Python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return Point(self.x + other.x, self.y + other.y)

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

p = Point(1, 2) + Point(3, 4)
print(p)                   # Point(4, 6)
print(p == Point(4, 6))    # True
print(p == (4, 6))         # False: a tuple isn't a Point
print([p])                 # [Point(4, 6)]: lists show their items' repr
Point(1, 2) + 5            # TypeError: unsupported operand type(s) for +: 'Point' and 'int'

Defining __eq__ also makes Point objects unhashable: Python sets __hash__ to None, because the default identity hash would break the rule that equal objects hash equally. That's right for a mutable class like this one.

__str__ vs __repr__

  • __repr__ is for developers: unambiguous, and ideally valid Python that would recreate the object. The REPL, debuggers and containers use it.
  • __str__ is for end users: readable. print() and str() use it, and fall back to __repr__ when it's missing.
Python
class Money:
    def __init__(self, amount):
        self.amount = amount

    def __repr__(self):
        return f"Money({self.amount!r})"

    def __str__(self):
        return f"₹{self.amount:,}"

fee = Money(250000)
print(fee)       # ₹250,000
print([fee])     # [Money(250000)]

The shortcut: a frozen dataclass

@dataclass writes __init__, __repr__ and __eq__ for you, and frozen=True makes instances immutable and hashable:

Python
from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: int
    y: int

    def __add__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return Point(self.x + other.x, self.y + other.y)

p = Point(1, 2) + Point(3, 4)
print(p)                     # Point(x=4, y=6): the generated repr names the fields
print({p, Point(4, 6)})      # {Point(x=4, y=6)}: equal points hash the same

Other dunders worth naming

MethodCalled by
__len__len(obj)
__getitem__obj[key]
__iter__for x in obj
__contains__x in obj
__lt__<, and therefore sorted()
__call__obj()
__enter__, __exit__with obj: (question 31)

Follow-ups

  • Why does sum([Point(1, 2), Point(3, 4)]) fail? sum starts from 0, and 0 + Point isn't defined. Add __radd__ to handle it, or call sum(points, start=Point(0, 0)).
  • NotImplemented vs NotImplementedError? The first is a value that operator methods return to say "I can't handle this type". The second is an exception, usually raised by methods a subclass must override.
Q22Not marked yetFresherAsked oftenPredict the output

Given class Cart: items = [], what is the output of a, b = Cart(), Cart(); a.items.append("pen"); print(b.items)?

Say this first~25 sec to say

It prints ['pen']. items is a class attribute: one list stored on the class and shared by every instance. a.items finds no attribute on a itself, so it falls back to Cart.items, and append changes that shared list, which b sees too. Data that belongs to each object goes on self, created in __init__, so every cart gets its own list.

Could you say it?
Full answer2 min read
Python
class Cart:
    items = []                  # one list, shared by every cart

a, b = Cart(), Cart()
a.items.append("pen")
print(b.items)                  # ['pen']
print(a.items is Cart.items)    # True

class Cart:
    def __init__(self):
        self.items = []         # a new list for each cart

a, b = Cart(), Cart()
a.items.append("pen")
print(b.items)                  # []

How Python finds a.items: first the object's own attributes (a.__dict__), then its class, then the parent classes in MRO order (question 20).

Assigning is different from mutating

Python
class Counter:
    count = 0

c1, c2 = Counter(), Counter()
c1.count += 1        # reads Counter.count, then creates c1's own count
print(c1.count, c2.count, Counter.count)   # 1 0 0

c1.count += 1 is c1.count = c1.count + 1: the read finds the class attribute, but the assignment creates a new attribute on c1. Only append and other in-place changes reach the shared object.

When a class attribute is the right choice: constants and settings that really are shared, such as GST_RATE = 0.18, or a counter you update deliberately through the class, as in Cart.created += 1.

Class attributeInstance attribute
DefinedIn the class bodyOn self, usually in __init__
StoredOnce, on the classSeparately on each object
Good forConstants, shared settingsEach object's own data

Follow-ups

  • How would you count how many carts were created? Add created = 0 to the class and Cart.created += 1 in __init__, naming the class explicitly.
  • Is this related to mutable default arguments? Yes. Both are one mutable object created once and shared by everyone who uses it (question 16).

05Iterators, generators and decorators

0/5 got it

Q23Not marked yet1–3 yrsAsked oftenConcept

What's the difference between an iterable and an iterator?

Say this first~25 sec to say

An iterable is anything you can loop over: it has __iter__, which hands out an iterator. Lists, strings, dicts and files are iterables. An iterator does the walking: __next__ returns the next item or raises StopIteration, and it remembers its position. A for loop calls iter() once, then next() until StopIteration. You can loop over an iterable many times; an iterator is used up after one pass.

Could you say it?
Full answer3 min read
Python
nums = [10, 20, 30]          # an iterable
it = iter(nums)              # an iterator over it
print(next(it))              # 10
print(next(it))              # 20
print(next(it))              # 30
print(next(it, "done"))      # done: a default instead of StopIteration
print(iter(it) is it)        # True: an iterator is also iterable; it returns itself

What a for loop really does

Python
it = iter(nums)
while True:
    try:
        n = next(it)
    except StopIteration:
        break
    print(n)                 # 10, 20, 30 on separate lines

An iterator runs out

Python
squares = map(lambda n: n * n, [1, 2, 3])   # map returns an iterator
print(list(squares))   # [1, 4, 9]
print(list(squares))   # []: already used up

The same happens with generators, zip, filter and open files. If you need the values twice, store them in a list.

Writing an iterator class

Python
class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1

print(list(Countdown(3)))   # [3, 2, 1]

A generator (question 24) does the same job in four lines, which is why hand-written iterator classes are rare.

IterableIterator
Has__iter____iter__ and __next__
Exampleslist, str, dict, rangeiter(list), generators, map, files
Remembers a positionNoYes
Can be looped over twiceYesNo

Follow-ups

  • Is a list an iterator? No. next([1, 2]) raises TypeError: 'list' object is not an iterator.
  • Why are iterators useful? They produce items one at a time, so a 10 GB file can be read line by line, and a sequence can even be endless.
  • Is a range an iterator? No, it's an iterable. You can loop over the same range many times.
Q24Not marked yetFresherAsked very oftenConcept

What is a generator, and how is yield different from return?

Say this first~30 sec to say

A generator is a function containing yield. Calling it returns a generator object, an iterator, without running the body. Each next() runs the body to the next yield, hands out that value and pauses, keeping its local variables. return ends a function for good with one value; yield produces a value and resumes from the same spot next time. Values are made one at a time, so memory use stays tiny.

Could you say it?
Full answer3 min read
Python
def count_up_to(limit):
    print("starting")
    n = 1
    while n <= limit:
        yield n           # pause here and hand n to the caller
        n += 1
    print("finished")

gen = count_up_to(3)      # nothing is printed: the body hasn't started
print(next(gen))          # starting, then 1
print(next(gen))          # 2: carried on from the yield
print(list(gen))          # finished, then [3]

Lazy means small

Python
import sys

squares_list = [n * n for n in range(1_000_000)]
squares_gen = (n * n for n in range(1_000_000))

print(sys.getsizeof(squares_list))   # 8448728: about 8 MB for the list itself
print(sys.getsizeof(squares_gen))    # 200: tiny, whatever the range
print(sum(squares_gen))              # 333332833333500000

Those sizes are from CPython 3.12. The generator expression, in round brackets, computes each square only when sum asks for it.

A practical use: filtering a large log file

Python
def error_lines(path):
    with open(path) as f:
        for line in f:                # reads one line at a time
            if "ERROR" in line:
                yield line.rstrip()

with open("app.log", "w") as f:
    f.write("INFO start\nERROR db timeout\nINFO retry\nERROR db timeout\n")

for line in error_lines("app.log"):
    print(line)                       # ERROR db timeout, twice

Only one line is in memory at a time, however big the file is.

returnyield
Function call gives youThe return valueA generator object
After it runsThe function is finishedThe function pauses, keeping its state
How many valuesOneAs many as it yields
MemoryWhatever the value needsOne value at a time

Follow-ups

  • Can you restart a generator? No. Once it's exhausted, call the generator function again to get a new one.
  • What does yield from do? It hands out every value from another iterable: yield from range(3) yields 0, 1 and 2.
  • What does return do inside a generator? It ends the generator. The returned value is attached to the StopIteration exception, which a for loop ignores.
Q25Not marked yetFresherAsked very oftenConcept

What is a decorator, and what does the @ line above a function actually do?

Say this first~30 sec to say

A decorator is a function that takes a function and returns a new one, usually a wrapper that adds behaviour before or after calling the original: logging, timing, caching, checking permissions. The @ line is only shorthand: @log above def add means add = log(add), and it runs once, when the function is defined. Decorators work because Python functions are objects: you can pass them in, return them, and keep them in a closure.

Could you say it?
Full answer3 min read
Python
def log(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}{args}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log                  # the same as writing add = log(add) after the def
def add(a, b):
    return a + b

print(add(2, 3))
calling add(2, 3)
add returned 5
5

Without the @

Python
def multiply(a, b):
    return a * b

multiply = log(multiply)   # exactly what @log does
print(multiply(4, 5))      # the same three lines of output, ending in 20

Stacking decorators

Python
def first(func):
    print("applying first")
    return func

def second(func):
    print("applying second")
    return func

@first
@second
def task():
    pass
# applying second
# applying first

Stacking means task = first(second(task)): the decorator nearest the function is applied first.

Decorators you already use

Python
from functools import lru_cache

@lru_cache(maxsize=None)          # remembers results for each argument
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(80))                    # 23416728348467685, instantly

Without the cache, fib(80) would make billions of recursive calls. Others you'll meet: @property, @classmethod, @staticmethod, @dataclass, and route decorators like Flask's @app.route("/").

Follow-ups

  • When does a decorator run? The decorator itself runs once, when the module defines the function. The wrapper it returns runs on every call.
  • Why does add.__name__ say wrapper? The wrapper replaced the function. Adding @functools.wraps(func) to the wrapper copies the name and docstring across (question 27).
  • How do you write a decorator that takes arguments, like @retry(3)? Add one more layer: retry(3) returns the actual decorator, which returns the wrapper.
Q26Not marked yetFresherAsked oftenCoding

Write a generator that yields Fibonacci numbers up to a given limit

Say this first~25 sec to say

Keep two variables, a and b, starting at 0 and 1. In a loop, yield a, then move both forward with a, b = b, a + b, and stop once a passes the limit. Because it's a generator, it only ever holds two numbers, however long the sequence gets, and the caller can loop over it, turn it into a list, or stop early.

Could you say it?
Full answer2 min read
Python
def fibonacci(limit):
    a, b = 0, 1
    while a <= limit:
        yield a
        a, b = b, a + b       # the right side is worked out before either name changes

print(list(fibonacci(50)))    # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

for n in fibonacci(10):
    print(n, end=" ")         # 0 1 1 2 3 5 8
print()

Why the tuple assignment matters

Writing a = b and then b = a + b on separate lines would be wrong: by the second line, a already holds the new value. a, b = b, a + b evaluates b and a + b first, then assigns both.

An endless version

A generator doesn't need to stop on its own; the caller decides how many values to take:

Python
from itertools import islice

def fibonacci_forever():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

print(list(islice(fibonacci_forever(), 10)))   # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

"The first n numbers" instead of "up to a limit"

Python
def first_n_fibonacci(n):
    a, b = 0, 1
    for _ in range(n):
        yield a
        a, b = b, a + b

print(list(first_n_fibonacci(7)))   # [0, 1, 1, 2, 3, 5, 8]

Edge cases to mention: a negative limit yields nothing, and a limit of 0 yields [0].

Follow-ups

  • What's the time and space complexity? O(k) time for k numbers, and O(1) space: two variables, whatever k is.
  • Why not just return a list? A list holds every number at once. The generator holds two, and lets the caller stop whenever it likes.
  • What about the recursive and memoised versions? They're in the DSA kit, question 38. Here the point is yield.
Q27Not marked yet1–3 yrsAsked oftenCoding

Write a decorator that prints how long any function takes to run

Say this first~20 sec to say

Write a function that takes func and returns wrapper(*args, **kwargs). Inside the wrapper, read time.perf_counter() before and after calling func(*args, **kwargs), print the difference, and return the original result. Put @functools.wraps(func) on the wrapper so the decorated function keeps its name and docstring. Accepting *args, **kwargs means it can wrap any function, whatever arguments it takes.

Could you say it?
Full answer3 min read
Python
import functools
import time

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:                          # report even if func raises
            elapsed = time.perf_counter() - start
            print(f"{func.__name__} took {elapsed * 1000:.0f} ms")
    return wrapper

@timed
def slow_square(n):
    """Square n, slowly."""
    time.sleep(0.2)
    return n * n

print(slow_square(9))         # slow_square took 200 ms (or a little more), then 81
print(slow_square.__name__)   # slow_square: without wraps, it would say wrapper
print(slow_square.__doc__)    # Square n, slowly.

Why each piece is there

  • *args, **kwargs forward any arguments, so one decorator fits every function.
  • return func(...): without it, every decorated function would return None.
  • try / finally prints the time even when the function raises.
  • time.perf_counter() is a high-resolution clock that only moves forward. time.time() follows the system clock, which can jump when it's adjusted.
  • functools.wraps copies __name__, __doc__ and a few other attributes, so tracebacks, logs and documentation tools see the real function.

With a label: a decorator that takes arguments

Python
def timed_as(label):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            start = time.perf_counter()
            result = func(*args, **kwargs)
            print(f"[{label}] {func.__name__}: {time.perf_counter() - start:.2f}s")
            return result
        return wrapper
    return decorator

@timed_as("reports")
def build_report():
    time.sleep(0.1)
    return "done"

print(build_report())   # [reports] build_report: 0.10s, then done

timed_as("reports") runs first and returns the real decorator, which then wraps build_report.

Follow-ups

  • How would you time an async function? The wrapper must be async def too, and must await func(*args, **kwargs); otherwise it only times the creation of the coroutine.
  • How would you send the time to a logger instead of printing? Call logging.getLogger(func.__module__).info(...) in place of print.
  • Is there a built-in way to time code? timeit for small snippets, and profilers such as cProfile for whole programs.

06Error handling and files

0/5 got it

Q28Not marked yetFresherAsked very oftenConcept

How do try, except, else and finally work together, and when does else run?

Say this first~30 sec to say

Code that might fail goes in try. If it raises, Python jumps to the first except block that matches the exception. else runs only if the try block raised nothing: it's the place for code that depends on the try succeeding but whose own errors you don't want that except to catch. finally runs every time, whether there was an exception, a return, or neither, so it's where cleanup goes, such as closing a connection.

Could you say it?
Full answer3 min read
Python
def parse_age(text):
    try:
        age = int(text)
    except ValueError:
        print("not a number")
        return None
    else:
        print("parsed fine")          # only when int() succeeded
        return age
    finally:
        print("finally runs every time")

print(parse_age("21"))
print(parse_age("abc"))
parsed fine
finally runs every time
21
not a number
finally runs every time
None

finally prints before each result: it runs on the way out of the function, after the return value is decided but before the caller receives it.

What runs in each case

What happens in tryexceptelsefinally
No exceptionSkippedRunsRuns
An exception that an except matchesRunsSkippedRuns
An exception nothing matchesSkippedSkippedRuns, then the exception carries on upwards
A returnSkippedSkippedRuns before the function returns

Why else exists

You could put the else code at the end of try. But then an error in that code could be caught by an except written for the risky line, and hide a different bug. else keeps the try block down to the one operation you expect to fail.

Catching well

Python
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None
    except (TypeError, ValueError) as error:   # several types, one handler
        print(f"bad input: {error}")
        raise                                  # re-raise the same exception

print(safe_divide(10, 0))    # None
safe_divide(10, "2")         # bad input: unsupported operand type(s) for /: 'int' and 'str', then the TypeError
  • Catch the specific exceptions you expect and can handle.
  • Avoid a bare except:. It also catches KeyboardInterrupt and SystemExit, so Ctrl+C stops working. If you must catch everything, use except Exception, and log the error.
  • A bare raise inside except re-raises the current exception with its original traceback.

Follow-ups

  • Can you have try with only finally? Yes. That's for cleanup without handling the error: the exception still propagates after finally runs.
  • What does raise NewError(...) from error do? It records the original exception as the cause, so the traceback shows both. Use it when you convert a low-level error into one of your own.
  • What is except*? Since Python 3.11, it handles exception groups, which code running several tasks at once can raise.
Q29Not marked yetFresherAsked oftenConcept

What does with open("data.txt") as f: do, and why is it better than calling open() and close() yourself?

Say this first~30 sec to say

with uses the file as a context manager: Python opens the file, binds it to f, runs the block, and always closes the file when the block ends, even if an exception is raised or the function returns early. With manual open() and close(), an error before close() leaves the file open, leaking a file handle and possibly losing buffered writes. Under the hood, with calls the object's __enter__ method at the start and __exit__ at the end.

Could you say it?
Full answer3 min read
Python
with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("line one\nline two\n")

print(f.closed)                  # True: closed as soon as the block ended

with open("notes.txt", encoding="utf-8") as f:   # "r" (read text) is the default mode
    for line in f:               # one line at a time, not the whole file
        print(line.rstrip())     # line one, then line two

What with saves you from writing

Python
f = open("notes.txt", encoding="utf-8")
try:
    data = f.read()
finally:
    f.close()                    # runs even if read() fails

File modes

ModeMeaning
"r"Read (the default). Fails if the file doesn't exist
"w"Write. Creates the file, or empties it if it exists
"a"Append to the end. Creates the file if needed
"x"Create a new file. Fails if it already exists
"b"Binary: read and write bytes, e.g. "rb" for an image
"+"Read and write, e.g. "r+"

Ways to read

Python
with open("notes.txt", encoding="utf-8") as f:
    print(f.read())          # the whole file as one string

with open("notes.txt", encoding="utf-8") as f:
    print(f.readline())      # just the first line, newline included

with open("notes.txt", encoding="utf-8") as f:
    print(f.readlines())     # ['line one\n', 'line two\n']

For big files, loop over the file object as in the first example: it holds only one line in memory at a time.

Always pass encoding="utf-8" for text. Before Python 3.15, the default encoding depended on the operating system, so a file written on Linux could be misread on Windows. From 3.15, UTF-8 is the default everywhere, but stating it keeps the code correct on older versions.

Two files in one with

Python
with open("notes.txt", encoding="utf-8") as src, open("copy.txt", "w", encoding="utf-8") as dst:
    dst.write(src.read().upper())

Follow-ups

  • What happens to an exception raised inside the block? __exit__ runs and closes the file, then the exception carries on to the caller.
  • What else works with with? Locks (threading.Lock), database connections and transactions, temporary directories, and your own context managers (question 31).
  • Is there a shorter way to read a whole file? pathlib.Path("notes.txt").read_text(encoding="utf-8") opens, reads and closes in one call.
Q30Not marked yetFresherAsked oftenCoding

Define a custom exception for an invalid age, raise it from a function, and catch it where the function is called

Say this first~25 sec to say

Subclass Exception, or a more specific built-in such as ValueError: class InvalidAgeError(ValueError): pass. In the function, check the input and raise InvalidAgeError(...) with a clear message. At the call site, wrap the call in try and catch that specific type with except InvalidAgeError as error:, so unrelated bugs aren't swallowed. Custom exceptions make errors self-describing and let callers handle yours separately from Python's own.

Could you say it?
Full answer3 min read
Python
class InvalidAgeError(ValueError):
    """Raised when an age is outside the accepted range."""

    def __init__(self, age, minimum=18, maximum=60):
        self.age = age                       # extra data the caller can use
        super().__init__(f"age {age} is outside {minimum}-{maximum}")

def register(name, age):
    if not 18 <= age <= 60:
        raise InvalidAgeError(age)
    return f"{name} registered"

for name, age in [("Asha", 24), ("Ravi", 15)]:
    try:
        print(register(name, age))
    except InvalidAgeError as error:
        print(f"Could not register {name}: {error} (got {error.age})")
Asha registered
Could not register Ravi: age 15 is outside 18-60 (got 15)

Choosing the base class

  • Subclass ValueError when the problem is a bad value, as here. Code that already catches ValueError will catch yours too.
  • Subclass Exception for errors that don't fit a built-in category.
  • Never subclass BaseException. That's reserved for exits such as KeyboardInterrupt and SystemExit.

One base class per application

Python
class AppError(Exception):
    """Base class for this app's errors."""

class PaymentError(AppError):
    pass

class InventoryError(AppError):
    pass

try:
    raise PaymentError("UPI timeout")
except AppError as error:                  # catches every app error
    print(type(error).__name__, error)     # PaymentError UPI timeout

Callers can catch one specific error, or all of the application's errors at once.

Follow-ups

  • When should you create a custom exception? When a caller needs to tell your error apart from others and react differently, for example retrying a PaymentError but not an InventoryError.
  • What does raise InvalidAgeError(age) from error add? It keeps the original exception as __cause__, and the traceback shows both, joined by "The above exception was the direct cause of the following exception".
  • Should the message be in the exception or in the except block? In the exception. It travels with the error into logs and tracebacks.
Q31Not marked yet1–3 yrsAsked sometimesCoding

Write a context manager that switches to another folder and always switches back, even if the block raises; once as a class and once with @contextmanager

Say this first~30 sec to say

As a class: in __enter__, save os.getcwd() and call os.chdir(path); in __exit__, change back to the saved folder. __exit__ runs however the block ends, including on an exception, and returning False lets that exception carry on. With @contextmanager: write a generator that saves the current folder, changes to the new one, and yields inside a try whose finally changes back. Python 3.11 added contextlib.chdir, but the point here is writing it yourself.

Could you say it?
Full answer4 min read

As a class

Python
import os
import tempfile

class ChangeDir:
    def __init__(self, path):
        self.path = path
        self.previous = None

    def __enter__(self):
        self.previous = os.getcwd()
        os.chdir(self.path)
        return self.path              # this is what "as" binds to

    def __exit__(self, exc_type, exc_value, traceback):
        os.chdir(self.previous)
        return False                  # don't hide the exception

start = os.getcwd()
target = tempfile.mkdtemp()
try:
    with ChangeDir(target):
        print(os.getcwd() == os.path.realpath(target))   # True
        raise RuntimeError("something failed")
except RuntimeError:
    pass
print(os.getcwd() == start)           # True: switched back despite the error

__exit__ receives the exception's type, value and traceback, or three Nones if the block finished normally. Returning True would swallow the exception, which is almost never what you want.

With @contextmanager

Python
import os
import tempfile
from contextlib import contextmanager

@contextmanager
def change_dir(path):
    previous = os.getcwd()
    os.chdir(path)
    try:
        yield path                    # the with block runs here
    finally:
        os.chdir(previous)            # runs even if the block raised

start = os.getcwd()
try:
    with change_dir(tempfile.mkdtemp()):
        raise RuntimeError("something failed")
except RuntimeError:
    pass
print(os.getcwd() == start)           # True

Everything before yield is the setup, and everything after it is the cleanup. The try / finally is essential: if the block raises, the exception is thrown into the generator at the yield, and without finally the line that changes back would never run.

Which to choose

Class@contextmanager
More code, but explicitShort and readable
Can hold state and extra methodsBest for simple setup and teardown
Works as a decorator only if it subclasses ContextDecoratorWorks as a decorator out of the box: @change_dir("/tmp")

Follow-ups

  • Is changing directory safe with threads? No. The current directory belongs to the whole process, so other threads see the change too. That's why contextlib.chdir's documentation warns against using it in threaded code.
  • How would you ignore one specific error? with contextlib.suppress(FileNotFoundError): os.remove(path).
  • Where else would you write a context manager? Timing a block, opening and committing a database transaction, temporarily changing a setting and restoring it.
Q32Not marked yet1–3 yrsAsked sometimesPredict the output

What does f() return if its body is try: return "try" followed by finally: return "finally"?

Say this first~30 sec to say

It returns "finally". When try reaches return "try", Python works out that value but runs the finally block before the function actually exits. A return inside finally then replaces the pending value, so "try" is thrown away. A return in finally also silently swallows any exception raised in try, which is why it's treated as a bug, and why Python 3.14 and later print a SyntaxWarning for it (PEP 765).

Could you say it?
Full answer2 min read
Python
def f():
    try:
        return "try"
    finally:
        return "finally"

print(f())   # finally

On Python 3.14 and later, compiling that function prints:

SyntaxWarning: 'return' in a 'finally' block

It swallows exceptions too

Python
def risky():
    try:
        raise ValueError("this error is lost")
    finally:
        return "no error?"

print(risky())   # no error?: the ValueError has vanished

finally without a return can't change the result

Python
def g():
    value = "try"
    try:
        return value
    finally:
        value = "changed"    # too late: the return value was already worked out

print(g())   # try

But it can change a mutable object being returned

Python
def h():
    items = ["try"]
    try:
        return items
    finally:
        items.append("finally")   # the same list the caller receives

print(h())   # ['try', 'finally']

The rule: never put return, break or continue in a finally block. PEP 765's warning covers all three.

Follow-ups

  • When does finally not run? If the process is killed, if os._exit() is called, or if the try block never finishes. sys.exit() raises SystemExit, so finally does run.
  • Does Java behave the same way? Yes. A return in Java's finally also overrides the try block's return (Java kit, question 22).

07Memory and copying

0/3 got it

Q33Not marked yetFresherAsked very oftenConcept

What's the difference between a shallow copy and a deep copy?

Say this first~30 sec to say

A shallow copy creates a new outer object but fills it with references to the same inner objects: list(a), a[:], a.copy() and copy.copy(a) all do this. So changing a nested list through the copy changes the original too. A deep copy, copy.deepcopy(a), copies everything recursively, so the two are fully independent. Plain assignment, b = a, makes no copy at all, only a second name for the same object.

Could you say it?
Full answer2 min read
Python
import copy

original = [[1, 2], [3, 4]]
alias = original                  # no copy: the same object
shallow = copy.copy(original)     # a new outer list holding the same inner lists
deep = copy.deepcopy(original)    # everything copied

original[0].append(99)
print(alias)      # [[1, 2, 99], [3, 4]]
print(shallow)    # [[1, 2, 99], [3, 4]]: it shares the inner lists
print(deep)       # [[1, 2], [3, 4]]: fully independent

shallow.append([5])               # changing the outer list itself
print(original)   # [[1, 2, 99], [3, 4]]: the outer lists are separate

The [[0] * 3] * 3 trap

Multiplying a list copies references, not the lists inside it:

Python
grid = [[0] * 3] * 3          # three references to ONE row
grid[0][0] = 1
print(grid)                   # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]

grid = [[0] * 3 for _ in range(3)]   # three separate rows
grid[0][0] = 1
print(grid)                   # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]

Ways to copy

HowWhat you get
b = aNo copy: a second name for the same object
a[:], list(a), a.copy(), dict(d), copy.copy(a)Shallow copy
copy.deepcopy(a)Deep copy

When a shallow copy is enough: when the items are immutable, such as a flat list of numbers or strings. There's nothing inside that could change, so sharing is harmless.

What a deep copy costs: it's slower and uses more memory, because it visits every nested object. It also handles shared and circular references correctly, by keeping a record of what it has already copied.

Follow-ups

  • Is copy.deepcopy always safe to use? It copies everything, including things you may not want copied, such as cached data or open connections. A class can control how it's copied by defining __deepcopy__.
  • How do you copy a dict of lists? {key: value[:] for key, value in d.items()} copies one level down, or use copy.deepcopy(d).
  • How does this compare with JavaScript? The spread operator is a shallow copy there too, and structuredClone is the deep copy (Frontend kit, question 13).
Q34Not marked yetFresherAsked oftenConcept

Does Python pass arguments by value or by reference?

Say this first~30 sec to say

Neither, exactly. Python passes references to objects, by value, often called "pass by assignment". The parameter becomes a new name for the same object. If the function changes that object, say by appending to a list, the caller sees it. If it rebinds the parameter with =, only the local name moves and the caller is unaffected. Ints and strings can't change in place, so they behave as if passed by value.

Could you say it?
Full answer2 min read
Python
def add_item(cart):
    cart.append("pen")       # changes the caller's list

def replace_cart(cart):
    cart = ["book"]          # moves the local name only

def bump(n):
    n += 1                   # ints are immutable: n now names a new int
    return n

my_cart = []
add_item(my_cart)
replace_cart(my_cart)
print(my_cart)               # ['pen']

count = 5
bump(count)
print(count)                 # 5
count = bump(count)          # return the new value and assign it
print(count)                 # 6

No copy is made when you pass an argument

Python
def same_object(items, original):
    return items is original

data = list(range(1_000_000))
print(same_object(data, data))   # True: passing a big list is cheap

If a function shouldn't change its input, copy it first (cart = list(cart)) or build and return a new object.

LanguageHow arguments are passed
CBy value; you pass a pointer to let a function change the caller's data
C++By value, or by reference with &
JavaBy value; for objects, the value is a reference (Java kit, question 4)
PythonReferences to objects, by value: like Java's objects, for every type

Follow-ups

  • How would you let a function change an int for the caller? Return the new value and assign it, as with count = bump(count) above.
  • Why did replace_cart do nothing? cart = ["book"] only pointed the local name at a new list. The caller's my_cart still points to the old one.
Q35Not marked yetFresherAsked oftenConcept

How does Python manage memory, and what does the garbage collector do?

Say this first~25 sec to say

CPython frees most objects by reference counting: each object counts the references to it and is freed the moment that count hits zero. Counting can't free reference cycles, like two objects pointing at each other, so a cyclic garbage collector (the gc module) runs from time to time to find and free groups of objects that only reference each other. You never free memory yourself.

Could you say it?
Full answer3 min read

Reference counting

Python
import sys

data = []
print(sys.getrefcount(data))   # 2: the name data, plus getrefcount's own argument
alias = data
print(sys.getrefcount(data))   # 3
del alias
print(sys.getrefcount(data))   # 2

del removes a name, not an object. The object is freed only when its last reference is gone.

Cycles need the garbage collector

Python
import gc

freed = []

class Node:
    def __init__(self, name):
        self.name = name
        self.partner = None

    def __del__(self):             # called when the object is freed
        freed.append(self.name)

a, b = Node("a"), Node("b")
a.partner, b.partner = b, a        # a cycle: each keeps the other alive
del a, b                           # no names left, but the counts are still 1
print(freed)                       # []: reference counting can't free them

gc.collect()                       # run the cycle collector now
print(sorted(freed))               # ['a', 'b']

The collector runs on its own, triggered by how many objects have been created. It checks new objects most often, because most objects are short-lived.

What this means in practice

  • Memory is usually freed the moment you stop using an object, which is why files left unclosed by mistake still tend to get closed quickly in CPython. Don't rely on it: use with (question 29).
  • Python programs can still "leak" by keeping references they don't need: a global list or cache that only grows, or objects stored in a long-lived dict. The tracemalloc module shows where memory is being allocated.

Follow-ups

  • Does del x free memory? It removes the name x. The object is freed only if that was its last reference.
  • Can you turn the garbage collector off? gc.disable() stops the cycle collector, and some programs do it for speed. Reference counting can't be turned off.
  • When is __del__ called? When the object is freed, but the timing isn't guaranteed for objects in cycles. Use with and context managers for cleanup instead.

08Modules and environment

0/3 got it

Q36Not marked yetFresherAsked oftenConcept

What's the difference between a module and a package?

Say this first~25 sec to say

A module is one .py file; you import it with import utils. A package is a folder of modules, imported with dotted names like from shop.payments import upi. A regular package has an __init__.py, which runs on first import and can re-export names for a tidier API. Since Python 3.3, a folder without one can still be imported as a namespace package, but most projects include it.

Could you say it?
Full answer2 min read
shop/
├── __init__.py
├── cart.py
└── payments/
    ├── __init__.py
    └── upi.py
Python
import shop.cart                    # a module inside a package
from shop.payments import upi       # a module inside a subpackage
from shop.payments.upi import pay   # one function from it
from shop import Cart               # works if shop/__init__.py re-exports Cart

What __init__.py is for

Python
# shop/__init__.py
from .cart import Cart          # relative import from inside the package

__all__ = ["Cart"]              # what "from shop import *" brings in

Users can now write from shop import Cart without knowing which file Cart lives in.

How importing works

  1. Python looks for the module in the folders listed in sys.path: the script's own folder, the standard library, then installed packages.
  2. It runs the module's code once, top to bottom, and caches the module object in sys.modules.
  3. Every later import, from any file, gets the cached module.
Python
import sys
import json

print("json" in sys.modules)   # True: imported once, cached from now on
TermMeaning
ModuleOne .py file
PackageA folder of modules, usually with __init__.py
LibraryAn informal name for any reusable collection of packages and modules
DistributionWhat pip install downloads from PyPI; confusingly, also called a "package"

Follow-ups

  • What happens if two files import each other at the top? A circular import: one of them sees the other half-initialised and gets an ImportError or AttributeError. Fix it by moving the shared code into a third module, or moving an import inside the function that needs it.
  • Absolute or relative imports? Absolute (from shop.cart import Cart) is clearer and recommended. Relative (from .cart import Cart) is handy inside a package.
  • How do you run code again after editing a module in a live session? importlib.reload(module) re-runs it; a normal import would return the cached version.
Q37Not marked yetFresherAsked very oftenConcept

What does if __name__ == "__main__": do?

Say this first~25 sec to say

Every module has a __name__. When you run a file directly, Python sets it to "__main__"; when the file is imported, it's the module's name, like "greetings". So code under if __name__ == "__main__": runs only when the file is executed as a script. One file can then be both an importable module and a runnable program, with no side effects, such as starting a server, on import.

Could you say it?
Full answer2 min read
Python
# greetings.py
def greet(name):
    return f"Hello, {name}"

print("greetings loaded, __name__ =", __name__)

if __name__ == "__main__":
    print(greet("Asha"))
Python
# app.py
import greetings

print(greetings.greet("Ravi"))
$ python greetings.py
greetings loaded, __name__ = __main__
Hello, Asha

$ python app.py
greetings loaded, __name__ = greetings
Hello, Ravi

The module-level print runs in both cases, because importing a module runs its top-level code. The guarded print(greet("Asha")) runs only in the first.

The usual pattern

Python
def main():
    print("running the report")

if __name__ == "__main__":
    main()

Keeping the work in main() means tests and other modules can import the file, and call main() themselves if they need to.

Where it's required: multiprocessing

On Windows and macOS, multiprocessing starts each worker by importing your main file. Without the guard, every worker would run the code that starts workers again, and Python stops with a RuntimeError. Question 40 has an example.

Follow-ups

  • What does python -m package.module do? It runs that module as the main program, so its __name__ is "__main__", while imports still resolve as if it were part of the package.
  • What is __main__.py? The file Python runs for python -m package, which lets a whole package act as a command.
Q38Not marked yetFresherAsked oftenConcept

What is a virtual environment, and how does it work with pip?

Say this first~25 sec to say

A virtual environment is an isolated folder with its own Python and its own installed packages, so each project gets exactly the versions it needs without clashing with others. Create it with python -m venv .venv, activate it, and pip install puts packages only there. Pin versions with pip freeze > requirements.txt so anyone can rebuild it with pip install -r requirements.txt. Keep .venv itself out of Git.

Could you say it?
Full answer2 min read
Terminal
python3 -m venv .venv               # create
source .venv/bin/activate           # activate (Windows: .venv\Scripts\activate)
pip install requests==2.32.3        # installs into .venv only
pip freeze > requirements.txt       # pin the exact versions
deactivate                          # back to the system Python

pip install -r requirements.txt     # later, in a fresh venv on another machine

How it works

  • venv creates a folder with a link to a Python interpreter, an empty site-packages, and a pyvenv.cfg file.
  • Activating it puts .venv/bin first on your PATH, so python and pip now mean the environment's copies.
  • Python inside the environment looks for packages in the environment's site-packages instead of the global one.
Python
import sys

print(sys.prefix != sys.base_prefix)   # True inside a virtual environment

The problems it solves

  • Project A needs Django 4.2 and project B needs Django 5.2: each gets its own copy.
  • You can install packages without admin rights, and without breaking tools that depend on the system Python. Many Linux distributions and Homebrew now refuse a global pip install with an "externally-managed-environment" error for exactly this reason.
  • requirements.txt makes the setup repeatable on a teammate's laptop, in CI and on the server.

Other tools you may hear about

ToolWhat it adds
venv + pipBuilt in. The baseline every Python developer should know
Poetry, PipenvDependency management with a lock file, in one tool
uvA very fast replacement for pip and venv, with lock files
condaEnvironments that can also install non-Python libraries; common in data science

Follow-ups

  • requirements.txt or pyproject.toml? pyproject.toml describes the project and the version ranges it supports. A lock file or pinned requirements.txt records the exact versions that were tested.
  • Can you copy a virtual environment to another machine? Not reliably: it contains absolute paths. Recreate it from the requirements file instead.
  • Why not install everything globally? Different projects need different versions, and upgrading for one project silently breaks another.

09Concurrency basics

0/2 got it

Q39Not marked yet1–3 yrsAsked oftenConcept

What is the GIL, and how does it affect multithreaded Python code?

Say this first~25 sec to say

The Global Interpreter Lock lets only one thread run Python bytecode at a time in CPython, which keeps its internals safe. So threads don't speed up CPU-bound work. They do help with I/O-bound work, because a waiting thread releases the GIL, and so do C libraries like NumPy while they compute. For parallel CPU work, use multiprocessing. Since 3.13 there's also an optional free-threaded build with no GIL.

Could you say it?
Full answer4 min read

CPU-bound: threads don't help

Python
import time
from concurrent.futures import ThreadPoolExecutor

def count_down(n):
    while n > 0:
        n -= 1

N = 20_000_000

start = time.perf_counter()
count_down(N)
count_down(N)
print(f"one after another: {time.perf_counter() - start:.2f}s")

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=2) as pool:
    pool.submit(count_down, N)
    pool.submit(count_down, N)
print(f"two threads:       {time.perf_counter() - start:.2f}s")   # about the same

I/O-bound: threads help a lot

Python
import time
from concurrent.futures import ThreadPoolExecutor

def fake_download(_):
    time.sleep(1)            # waiting releases the GIL

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
    list(pool.map(fake_download, range(5)))
print(f"5 downloads: {time.perf_counter() - start:.1f}s")   # 1.0s, not 5

The GIL doesn't make your code thread-safe

The GIL protects Python's internals, not your logic. counter += 1 is several bytecode steps, and a thread can be switched out between them, so two threads updating a shared counter can lose updates. Protect shared data with a threading.Lock:

Python
import threading

counter = 0
lock = threading.Lock()

def add_many():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

threads = [threading.Thread(target=add_many) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(counter)   # 400000, every time

The free-threaded build

PEP 703 added a separate CPython build without the GIL: experimental in 3.13 and officially supported, though still optional, from 3.14. It's installed as python3.13t or python3.14t, and there threads can run Python code on several cores at once. Single-threaded code runs a little slower on it, and some C extensions still need updating, so the standard build keeps the GIL.

Follow-ups

  • Why did CPython have a GIL in the first place? It made the interpreter and C extensions simple, and kept single-threaded code fast.
  • How do you check whether the GIL is on? sys._is_gil_enabled(), available from Python 3.13.
  • Does every Python have a GIL? No. It's a CPython detail. PyPy has one too; Jython and IronPython never did.
Q40Not marked yet1–3 yrsAsked oftenConcept

When would you choose threading, multiprocessing or asyncio?

Say this first~25 sec to say

Match the tool to the bottleneck. I/O-bound work, like API calls or database queries: threads for a handful of tasks or blocking libraries, and asyncio for thousands of connections with async libraries, all on one thread that switches at each await. CPU-bound work, like image processing or heavy pure-Python maths: multiprocessing, because each process has its own interpreter and GIL and runs on its own core.

Could you say it?
Full answer3 min read
threadingmultiprocessingasyncio
Runs asSeveral threads in one processSeveral processesOne thread with an event loop
Best forI/O-bound work, blocking librariesCPU-bound workVery many I/O-bound tasks
Uses several cores for Python codeNo (yes on free-threaded builds)YesNo
MemorySharedSeparate: data is copied between processesShared
Who switches tasksThe operating system, at any momentNot needed: processes run side by sideYour code, at each await
Main pitfallRace conditions; you need locksStart-up time and copying dataOne blocking call stalls everything

asyncio: wait for many things at once

Python
import asyncio
import time

async def fetch(name, seconds):
    await asyncio.sleep(seconds)        # stands in for a network call
    return f"{name} done"

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(
        fetch("orders", 1), fetch("users", 1), fetch("stock", 1)
    )
    print(results)                                 # ['orders done', 'users done', 'stock done']
    print(f"{time.perf_counter() - start:.1f}s")   # 1.0s: they waited together

asyncio.run(main())

multiprocessing: use every core for CPU work

Python
from concurrent.futures import ProcessPoolExecutor

def sum_of_squares(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":                     # required: workers import this file
    with ProcessPoolExecutor() as pool:
        print(list(pool.map(sum_of_squares, [10, 100, 1000])))   # [285, 328350, 332833500]

concurrent.futures gives threads and processes the same interface: switching between ThreadPoolExecutor and ProcessPoolExecutor is a one-word change.

The asyncio trap: blocking calls

Inside async code, a normal blocking call such as time.sleep(5) or requests.get(...) freezes the event loop, and every other task with it. Use async libraries (asyncio.sleep, httpx, aiohttp), or push the blocking call to a thread with await asyncio.to_thread(func, ...).

How to answer: first ask whether the work is I/O-bound or CPU-bound, then pick the tool. That question is what the interviewer is listening for.

Follow-ups

  • Can you combine them? Yes. asyncio can hand blocking work to a thread pool or CPU work to a process pool with loop.run_in_executor.
  • Why must the pool code sit under if __name__ == "__main__":? Worker processes import your main file. Without the guard, each one would try to start a pool of its own (question 37).
  • Is asyncio faster than threads? Not per task. Its strength is handling thousands of waiting tasks cheaply, because an idle task costs far less memory than an idle thread.

Last step

Write these from memory

Before a Python interview, make sure you can write each of these on a blank page without help:

  • Q8
  • Q9
  • Q16
  • Q14
  • Q21
  • Q26
  • Q27
  • Q30
  • Q31
  • Q33
  • Q40

0 of 11 done