Python Interview Questions
60 common questions, each with a short answer you can give in an interview and code you can run.
Beginner questions
Types, copying and the basic built-ins.
1.What is the difference between a list and a tuple?
A list is mutable: you can add, remove and replace items after creating it. A tuple is immutable: once it exists, its items cannot be added, removed or reassigned. Because it cannot change, a tuple of hashable items can be a dictionary key or a set member, and a list cannot.
Use a list for a collection that grows or changes, like a shopping cart, and a tuple for a fixed record, like a pair of coordinates.
list_vs_tuple.pycities = ["Paris", "Rome"]cities.append("Oslo")print(cities)point = (3, 4)try:point[0] = 10except TypeError as error:print("TypeError:", error)labels = {(0, 0): "origin", point: "target"} # tuples work as keysprint(labels[(3, 4)])Output['Paris', 'Rome', 'Oslo'] TypeError: 'tuple' object does not support item assignment target
Follow-up: Can a tuple ever change? Its slots cannot be reassigned, but a mutable object inside it, such as a list, can still be modified, and such a tuple can no longer be hashed.
Go deeper: Python Tuples: When and Why to Use Immutable Sequences
2.What is the difference between
==andis?==checks whether two objects have equal values, whileischecks whether two names refer to the very same object. Two separate lists with the same items are equal but not identical. Useisonly for singletons such asNone(if result is None), and==for every comparison of values.equality_vs_identity.pya = [1, 2, 3]b = [1, 2, 3]c = aprint(a == b) # same valuesprint(a is b) # two different objectsprint(a is c) # c is just another name for aresult = Noneprint(result is None)OutputTrue False True True
Follow-up: Why can
isbe True for two equal small integers? CPython caches the integers from -5 to 256 and reuses them, an implementation detail you should never rely on.Go deeper: 15 Python Pitfalls That Trip Up Even Experienced Developers
3.What are mutable and immutable types?
A mutable object can be changed in place after it is created; an immutable object cannot, so every "change" builds a new object. Lists, dicts, sets and most class instances are mutable, while ints, floats, strings, tuples, frozensets and bools are immutable.
It matters when two names share one object: a change made to a mutable object through one name shows up through the other.
mutable_vs_immutable.pyscores = [70, 85]backup = scoresscores.append(90)print(backup) # the same list, so it changed tooname = "sam"original = namename = name.upper() # builds a new stringprint(original, name)word = "hello"try:word[0] = "J"except TypeError as error:print("TypeError:", error)Output[70, 85, 90] sam SAM TypeError: 'str' object does not support item assignment
Follow-up: If ints are immutable, how does
count += 1work? It does not modify the int: it creates a new int object and rebinds the namecountto it.Go deeper: Python Copy vs Deepcopy: Why Your "Copy" Isn't Really a Copy
4.How do you reverse a string or a list?
Slicing with a step of -1,
[::-1], returns a reversed copy and works on strings, lists and tuples. Lists also have.reverse(), which reverses in place and returnsNone, and the built-inreversed()returns a lazy iterator, so wrap it inlist()or"".join()to get a result. Strings are immutable, so they have no.reverse()method. All of these are O(n).reverse_string_or_list.pyword = "python"print(word[::-1])print("".join(reversed(word)))numbers = [1, 2, 3, 4]print(numbers[::-1]) # a new listprint(numbers) # unchangednumbers.reverse() # in place, returns Noneprint(numbers)Outputnohtyp nohtyp [4, 3, 2, 1] [1, 2, 3, 4] [4, 3, 2, 1]
Follow-up: How do you check whether a word is a palindrome? Compare it with its reverse,
word == word[::-1], after lowercasing it and removing spaces if the input can contain them.Go deeper: Python Slicing: Master Indexing for Strings, Lists, and Tuples
5.What is the difference between
append()andextend()?append(x)addsxas one single item at the end of the list, even ifxis itself a list.extend(iterable)loops over its argument and adds each item separately. So appending a two-item list grows the list by one (a nested list), while extending grows it by two.items += otherdoes the same asextend().append_vs_extend.pyfruits = ["apple", "banana"]fruits.append(["cherry", "kiwi"])print(fruits, len(fruits))fruits = ["apple", "banana"]fruits.extend(["cherry", "kiwi"])print(fruits, len(fruits))letters = ["a"]letters.extend("bc") # a string is an iterable of charactersprint(letters)Output['apple', 'banana', ['cherry', 'kiwi']] 3 ['apple', 'banana', 'cherry', 'kiwi'] 4 ['a', 'b', 'c']
Follow-up: What does
insert()do?items.insert(i, x)puts one item at indexi, which is O(n) because every later item has to shift right, whileappend()is O(1) on average.Go deeper: Python Lists: Create, Access, Modify, and Loop Through Lists
6.What are
*argsand**kwargs?*argscollects any extra positional arguments into a tuple, and**kwargscollects any extra keyword arguments into a dict. The names are only a convention: the*and**do the work. They let one function accept any number of arguments, which is why wrappers and decorators use them.At a call site the same symbols unpack:
f(*values)spreads a list into positional arguments andf(**options)spreads a dict into keyword arguments.args_and_kwargs.pydef describe(*args, **kwargs):print("args:", args)print("kwargs:", kwargs)describe("Asha", 21, city="Pune", course="Python")def total(*prices):return sum(prices)print(total(10, 20, 30))values = [5, 15]print(total(*values)) # unpack the list into argumentsOutputargs: ('Asha', 21) kwargs: {'city': 'Pune', 'course': 'Python'} 60 20Follow-up: In what order do parameters go? Normal parameters, then
*args, then keyword-only parameters, then**kwargs, as indef f(a, *args, flag=False, **kwargs).Go deeper: Python *args, **kwargs, Default Values, and Keyword Arguments
7.What is a list comprehension?
A list comprehension builds a new list from an iterable in a single expression:
[expression for item in iterable if condition]. It replaces the pattern of creating an empty list, looping and callingappend(), and it is usually shorter and a little faster. Theifpart is optional and filters items. If it needs several loops and conditions, a plainforloop is easier to read.list_comprehension.pyprices = [120, 45, 300, 80]in_cents = [p * 100 for p in prices]cheap = [p for p in prices if p < 100]labels = ["high" if p > 100 else "low" for p in prices]print(in_cents)print(cheap)print(labels)squares = {n: n * n for n in range(4)} # a dict comprehensionprint(squares)Output[12000, 4500, 30000, 8000] [45, 80] ['high', 'low', 'high', 'low'] {0: 0, 1: 1, 2: 4, 3: 9}Follow-up: What changes if you use parentheses instead of square brackets?
(p * 2 for p in prices)is a generator expression: it produces items lazily, one at a time, instead of building the whole list in memory.Go deeper: Python Comprehensions: List, Dict, Set, and Generator in One Guide
8.What do
break,continueandpassdo?breakexits the nearest enclosing loop at once.continueskips the rest of the current iteration and jumps to the next one.passdoes nothing: it is a placeholder for places where Python requires a statement, such as an empty function or class you will fill in later.break_continue_pass.pyfor n in [1, 2, 3, 4, 5, 6]:if n == 2:continue # skip 2, go on with the next numberif n == 5:break # leave the loop completelyprint(n)def send_report():pass # a body is required, nothing to do yetprint(send_report())Output1 3 4 None
Follow-up: What does
elseon a loop do? It runs only if the loop finished without hittingbreak, which makes search loops tidy: theelseis the "not found" case.Go deeper: Python break, continue, pass: Controlling Loop Flow
9.How do you remove duplicates from a list and keep the order?
Use
list(dict.fromkeys(items)): dict keys are unique and keep insertion order (guaranteed since Python 3.7), so you get the first occurrence of each item in its original position.list(set(items))also removes duplicates but scrambles the order. Both are O(n) and need hashable items.A loop with a
seenset is also O(n) and lets you add rules, such as ignoring case.remove_duplicates_keep_order.pynames = ["Ana", "Ben", "Ana", "Cara", "Ben"]print(list(dict.fromkeys(names)))seen = set()unique = []for name in names:if name not in seen:seen.add(name)unique.append(name)print(unique)print(sorted(set(names))) # unique, but sorted, not in the original orderOutput['Ana', 'Ben', 'Cara'] ['Ana', 'Ben', 'Cara'] ['Ana', 'Ben', 'Cara']
Follow-up: What if the items are unhashable, like lists? Keep an item only if it is
not inthe result list so far, which works for any items but is O(n^2).Go deeper: Python Sets: Unique Collections, Operations, and Use Cases
10.What is the difference between
/,//and%?/is true division and always returns a float, even for10 / 2.//is floor division: it rounds the result down toward negative infinity, so-7 // 2is-4, not-3.%returns the remainder, which takes the sign of the divisor, so-7 % 2is1. The three always agree:a == (a // b) * b + a % b.division_operators.pyprint(7 / 2, 10 / 2)print(7 // 2, -7 // 2)print(7 % 2, -7 % 2)minutes = 135print(minutes // 60, "h", minutes % 60, "min")print(divmod(minutes, 60)) # both at onceOutput3.5 5.0 3 -4 1 1 2 h 15 min (2, 15)
Follow-up: How do you test whether a number is even?
n % 2 == 0, which also works for negative numbers because%with a positive divisor always returns 0 or 1.Go deeper: Python Numbers: Integers, Floats, and Math You'll Actually Use
11.What does
if __name__ == "__main__":do?Every module has a
__name__variable. Python sets it to"__main__"when the file is run directly, and to the module name (such as"greet") when the file is imported. So the code underif __name__ == "__main__":runs only when the file is executed as a script, never on import. It is the standard home for a script entry point, demos and quick tests.if_name_main.pyimport runpywith open("greet.py", "w") as f:f.write('print("__name__ is", repr(__name__))\n')f.write('if __name__ == "__main__":\n')f.write(' print("greet.py was run directly")\n')runpy.run_path("greet.py", run_name="__main__") # like: python greet.pyprint("---")runpy.run_path("greet.py", run_name="greet") # like: import greetOutput__name__ is '__main__' greet.py was run directly --- __name__ is 'greet'
Follow-up: What goes wrong without it? Any top-level code, like a demo or a test call, runs every time another file imports the module, printing output or doing work nobody asked for.
Go deeper: Python Imports: How import Really Works (Modules, Packages, Paths)
12.What is the difference between a shallow copy and a deep copy?
A shallow copy creates a new outer container but fills it with references to the same inner objects. A deep copy,
copy.deepcopy(), recursively copies everything, so it shares no mutable parts with the original. With nested lists or dicts, changing an inner item through a shallow copy also changes the original.list.copy(),list(x),x[:],dict.copy()andcopy.copy()all make shallow copies.shallow_vs_deep_copy.pyimport copyoriginal = [["Ana", 90], ["Ben", 75]]shallow = copy.copy(original)deep = copy.deepcopy(original)original[0][1] = 100 # change an inner listprint(shallow[0]) # sees the changeprint(deep[0]) # fully independentprint(shallow is original, shallow[0] is original[0])Output['Ana', 100] ['Ana', 90] False True
Follow-up: Is
b = aa copy? No, assignment copies nothing: both names point to the same object, so a change through either one is seen by both.Go deeper: Python Copy vs Deepcopy: Why Your "Copy" Isn't Really a Copy
13.Which values are falsy in Python?
A value is falsy when
bool(value)isFalse. The falsy built-ins areNone,False, zero of any numeric type (0,0.0,0j) and every empty collection:"",[],(),{},set()andrange(0). Everything else is truthy, including"0"," "and[0]. Your own classes can decide by defining__bool__or__len__.falsy_values.pyfalsy = [None, False, 0, 0.0, "", [], (), {}, set(), range(0)]print([bool(v) for v in falsy])tricky = ["0", " ", [0], -1, "False"]print([bool(v) for v in tricky])cart = []if not cart:print("Your cart is empty")Output[False, False, False, False, False, False, False, False, False, False] [True, True, True, True, True] Your cart is empty
Follow-up: Why write
if value is Noneinstead ofif not value? Becausenot valueis also True for0and"", which may be valid data you do not want to treat as missing.Go deeper: Python Booleans, Comparisons, and All Operators Explained
14.How do
try,except,elseandfinallywork together?The
tryblock holds code that might raise an exception. Anexceptblock runs only if a matching exception is raised there.elseruns only if thetryblock raised nothing, which keeps the success path out of the guarded code.finallyalways runs last, error or not, even after areturn, so it is the place for cleanup such as closing files or connections.try_except_else_finally.pydef divide(a, b):try:result = a / bexcept ZeroDivisionError:print("cannot divide by zero")return Noneelse:print("worked:", result)return resultfinally:print("finished", a, "/", b)divide(10, 4)divide(1, 0)Outputworked: 2.5 finished 10 / 4 cannot divide by zero finished 1 / 0
Follow-up: Why not put the success code inside
trytoo? A largertrycan catch errors from lines you never meant to guard and hide real bugs, soelsekeeps the guarded part small.Go deeper: Python Try-Except: Handle Errors Without Crashing
15.What is the difference between
remove(),pop()anddel?remove(x)deletes the first item equal toxand raisesValueErrorif there is none.pop(i)deletes the item at indexi, the last one by default, and returns it.del items[i]deletes by index or slice and returns nothing; it is a statement, not a method, and can also delete a whole variable.pop()from the end is O(1), while removing from the front or middle is O(n) because later items shift left.remove_pop_del.pyitems = ["pen", "book", "pen", "lamp", "cup"]items.remove("pen") # the first match onlyprint(items)last = items.pop() # returns what it removedprint(last, items)del items[0] # by index, returns nothingprint(items)try:items.remove("phone")except ValueError as error:print("ValueError:", error)Output['book', 'pen', 'lamp', 'cup'] cup ['book', 'pen', 'lamp'] ['pen', 'lamp'] ValueError: list.remove(x): x not in list
Follow-up: How do you remove every occurrence of a value? Build a new list,
[x for x in items if x != value], instead of callingremove()again and again.Go deeper: Python Lists: Create, Access, Modify, and Loop Through Lists
Intermediate questions
Functions as values, generators, decorators, scope and the data model.
16.Why is a mutable default argument dangerous?
Default values are created once, when the function is defined, not on every call. A default list or dict is therefore shared by every call that does not pass its own, so changes leak from one call into the next.
Use
Noneas the default and create the list inside the function.mutable_default_arguments.pydef add_item(item, basket=[]):basket.append(item)return basketprint(add_item("apple"))print(add_item("bread")) # the same list as beforedef add_item_safe(item, basket=None):if basket is None:basket = []basket.append(item)return basketprint(add_item_safe("apple"))print(add_item_safe("bread"))Output['apple'] ['apple', 'bread'] ['apple'] ['bread']
Follow-up: Where is the default stored? In the function object,
add_item.__defaults__, which is why it survives between calls.Go deeper: Python *args, **kwargs, Default Values, and Keyword Arguments
17.What is a lambda, and when should you use one?
A lambda is a small anonymous function written as a single expression,
lambda args: expression, whose value it returns automatically. It cannot contain statements such as assignments, loops orreturn. Use it for short throwaway functions passed to another function, most often as thekeyforsorted(),min()ormax(). If you would assign it to a name, PEP 8 says to write adefinstead, and the same goes for anything longer than one simple expression.lambda_functions.pystudents = [("Ana", 88), ("Ben", 72), ("Cara", 95)]by_score = sorted(students, key=lambda s: s[1], reverse=True)print(by_score)print(max(students, key=lambda s: s[1]))double = lambda x: x * 2 # works, but a def would be clearerprint(double(21), double.__name__)Output[('Cara', 95), ('Ana', 88), ('Ben', 72)] ('Cara', 95) 42 <lambda>Follow-up: What is the classic lambda trap in a loop?
[lambda: i for i in range(3)]makes three functions that all return 2, becauseiis looked up when they are called; bind it early withlambda i=i: i.Go deeper: Python Lambda Functions: When to Use (and When Not To)
18.What do
map(),filter()andzip()return?In Python 3 all three return lazy iterators, not lists.
map(f, items)appliesfto each item,filter(f, items)keeps the items for whichfreturns something truthy, andzip(a, b)pairs items up and stops at the shortest input. Nothing is computed until you loop over the result or wrap it inlist(), and each one can be consumed only once.map_filter_zip.pyprices = [10, 25, 40]names = ["pen", "book", "bag"]doubled = map(lambda p: p * 2, prices)print(type(doubled).__name__)print(list(doubled))print(list(doubled)) # already used upprint(list(filter(lambda p: p > 15, prices)))print(list(zip(names, prices)))print(dict(zip(names, prices)))Outputmap [20, 50, 80] [] [25, 40] [('pen', 10), ('book', 25), ('bag', 40)] {'pen': 10, 'book': 25, 'bag': 40}Follow-up: How do you make
zip()refuse inputs of different lengths? Passstrict=True(Python 3.10+), which raisesValueErrorinstead of silently dropping the extra items.Go deeper: Python map(), filter(), reduce(), and Higher-Order Functions
19.What is a generator, and how is it different from a list?
A generator produces its values lazily, one at a time and only when asked, instead of storing them all in memory like a list. That makes it a good fit for large or endless sequences and step-by-step pipelines. The trade-off is that you can loop over a generator only once, and it has no
len()and no indexing. You create one with a generator function (one that usesyield) or a generator expression in parentheses.generators_vs_lists.pysquares_list = [n * n for n in range(5)]squares_gen = (n * n for n in range(5))print(squares_list)print(type(squares_gen).__name__)print(next(squares_gen), next(squares_gen))print(list(squares_gen)) # the restprint(list(squares_gen)) # empty: used uptotal = sum(n * n for n in range(1_000_000)) # never builds a big listprint(total)Output[0, 1, 4, 9, 16] generator 0 1 [4, 9, 16] [] 333332833333500000
Follow-up: How do you process a file too big for memory? Loop over the file object itself, which yields one line at a time, or chain generators so each step handles one line before the next is read.
Go deeper: Python Generators: Process Millions of Items Without Running Out of Memory
20.What does
yielddo?yieldturns a function into a generator function: calling it does not run the body, it returns a generator. Eachnext()runs the body up to the nextyield, hands that value out and pauses, keeping every local variable. The next call resumes right after thatyield. When the function ends, the generator raisesStopIteration, which aforloop handles for you.yield_keyword.pydef countdown(n):print("starting")while n > 0:yield nn -= 1print("finished")gen = countdown(3)print("created, nothing has run yet")print(next(gen))print(next(gen))for value in gen:print(value)Outputcreated, nothing has run yet starting 3 2 1 finished
Follow-up: What does
yield fromdo? It delegates to another iterable and yields each of its values in turn, soyield from range(3)replaces a smallforloop aroundyield.Go deeper: Python Generators: Process Millions of Items Without Running Out of Memory
21.What is a decorator? Write one that times a function.
A decorator is a function that takes a function and returns a new one, usually a wrapper that adds behavior before or after the original call. Writing
@timerabovedef totalis shorthand fortotal = timer(total). The wrapper takes*args, **kwargsso it fits any signature, andfunctools.wrapscopies the name and docstring of the original onto it. Decorators are used for logging, caching, timing, retries and access checks.timing_decorator.pyimport functools, timedef timer(func):@functools.wraps(func)def wrapper(*args, **kwargs):start = time.perf_counter()result = func(*args, **kwargs)print(f"{func.__name__} took {time.perf_counter() - start:.2f} s")return resultreturn wrapper@timerdef total(n):return sum(range(n))print(total(1000), total.__name__) # the name survives, thanks to wrapsOutputtotal took 0.00 s 499500 total
Follow-up: How do you write a decorator that takes arguments, like
@retry(3)? Add one more outer layer:retry(3)runs first and returns the actual decorator, which then wraps the function.Go deeper: Python Decorators Explained: A Step-by-Step Guide
22.What is a closure?
A closure is an inner function that remembers variables from the function that created it, even after that outer function has returned. Python keeps those variables alive in cells attached to the inner function (
__closure__). Closures give you small configurable or stateful functions without writing a class, and they are how decorators hold on to the function they wrap.closures.pydef make_multiplier(factor):def multiply(x):return x * factor # factor is remembered from the outer callreturn multiplydouble = make_multiplier(2)triple = make_multiplier(3)print(double(10), triple(10))print(double.__closure__[0].cell_contents) # where factor livesOutput20 30 2
Follow-up: When would you pick a class over a closure? When the state needs several methods or should be visible from outside; a closure suits one function with a little private state.
Go deeper: Python Variable Scope, Closures, and the LEGB Rule
23.What is the LEGB rule, and what do
globalandnonlocaldo?LEGB is the order Python searches for a name: Local (the current function), Enclosing (any outer functions), Global (the module), then Built-in (names like
lenandprint); the first match wins. Assigning to a name inside a function makes it local to that function. To assign to a module-level name instead, declare itglobal; to assign to a variable of an enclosing function, declare itnonlocal.Reading an outer name needs neither keyword: only assignment does.
legb_global_nonlocal.pytotal = 0 # global: lives in the moduledef make_counter():clicks = 0 # enclosing: lives in make_counterdef click():global total # assign to the module's totalnonlocal clicks # assign to make_counter's clicksclicks += 1total += 1return clicksreturn clicka, b = make_counter(), make_counter()print(a(), a(), b(), "total:", total)Output1 2 1 total: 3
Follow-up: What happens if you assign to a global without declaring it? The name becomes local for the whole function, so reading it before the assignment, as in
count += 1, raisesUnboundLocalError.Go deeper: Python Variable Scope, Closures, and the LEGB Rule
24.What is the difference between an iterable and an iterator?
An iterable is anything you can loop over, such as a list, string, dict or file: its
__iter__()method hands out an iterator. An iterator is the object that does the stepping:__next__()returns the next item and raisesStopIterationat the end, and its own__iter__()returns itself. A list can be looped over again and again because every loop gets a fresh iterator, while an iterator is used up after one pass.A
forloop callsiter()once, thennext()untilStopIteration.iterable_vs_iterator.pycolors = ["red", "green"] # an iterableit = iter(colors) # an iterator over itprint(next(it))print(next(it))try:next(it)except StopIteration:print("StopIteration: no more items")print(iter(it) is it) # an iterator is its own iteratorprint(list(it), colors) # the iterator is spent, the list is notOutputred green StopIteration: no more items True [] ['red', 'green']
Follow-up: Is every iterator an iterable? Yes, since its
__iter__()returns itself, but not every iterable is an iterator: callingnext()on a list raisesTypeError.Go deeper: Python Iterators: How for Loops Really Work Under the Hood
25.What is the difference between
@staticmethodand@classmethod?A
@classmethodreceives the class as its first argument,cls, so it can read class attributes and create instances; its classic use is an alternative constructor such asfrom_string(). A@staticmethodreceives nothing automatically: it is a plain function that lives in the class because it belongs there logically. A regular method receives the instance,self. Both can be called on the class or on an instance.staticmethod_vs_classmethod.pyclass Pizza:def __init__(self, toppings):self.toppings = toppings@classmethoddef margherita(cls): # receives the classreturn cls(["tomato", "cheese"])@staticmethoddef slice_price(total, slices): # receives nothing extrareturn round(total / slices, 2)pizza = Pizza.margherita()print(type(pizza).__name__, pizza.toppings)print(Pizza.slice_price(12, 8))OutputPizza ['tomato', 'cheese'] 1.5
Follow-up: Why call
cls(...)rather than the class name inside a classmethod? So that a subclass calling the same method gets an instance of the subclass, not of the parent.Go deeper: Python @classmethod vs @staticmethod: When to Use Each
26.What are dunder (magic) methods?
Dunder methods, named with double underscores like
__init__,__repr__,__eq__,__len__and__add__, are hooks that Python calls for you behind built-in syntax and functions.len(x)callsx.__len__(),a + bcallsa.__add__(b), and printing uses__str__, falling back to__repr__. Defining them lets your own classes work with operators,len(), loops and comparisons just like built-in types. You rarely call them directly.dunder_methods.pyclass Money:def __init__(self, amount):self.amount = amountdef __repr__(self):return f"Money({self.amount})"def __add__(self, other):return Money(self.amount + other.amount)def __eq__(self, other):return self.amount == other.amountprint(Money(5) + Money(7))print(Money(3) == Money(3), [Money(1), Money(2)])OutputMoney(12) True [Money(1), Money(2)]
Follow-up: What happens to hashing when you define
__eq__? Python sets__hash__toNone, so instances can no longer be dict keys or set members until you also define__hash__.Go deeper: Python Magic Methods: The Complete __dunder__ Guide
27.Why must dictionary keys be hashable?
A dict finds a key by computing its hash, jumping straight to the matching slot and confirming with
==; that is what makes lookups O(1) on average. This only works if the hash of a key never changes while it is stored and equal keys have equal hashes. Immutable built-ins such as strings, numbers and tuples of hashable items qualify. Lists, dicts and sets do not: changing one would change its hash and the dict could no longer find the entry.hashable_dict_keys.pyplaces = {"home": "Pune", (18, 73): "grid cell"}print(places[(18, 73)])try:places[["a", "b"]] = "list key"except TypeError as error:print("TypeError:", error)print(hash((1, 2)) == hash((1, 2))) # equal tuples, equal hashesprint(hash(1) == hash(1.0), {1: "one"}[1.0])Outputgrid cell TypeError: unhashable type: 'list' True True one
Follow-up: How do you use a list as a key? Convert it to a tuple first,
counts[tuple(items)], or to afrozensetif the order does not matter.Go deeper: Python Dictionaries: The Complete Guide with Real Examples
28.What is the difference between
sort()andsorted()?list.sort()sorts the list in place and returnsNone, and it exists only on lists.sorted()takes any iterable (a tuple, a string, dict keys, a generator) and returns a new list, leaving the original untouched. Both acceptkey=andreverse=, use the same stable algorithm and run in O(n log n). A classic bug ismarks = marks.sort(), which replaces the list withNone.sort_vs_sorted.pymarks = [72, 95, 60]print(sorted(marks), marks) # a new list, the original is unchangedresult = marks.sort() # in placeprint(result, marks)print(sorted("banana"))words = ["Kiwi", "apple", "Banana"]print(sorted(words))print(sorted(words, key=str.lower))Output[60, 72, 95] [72, 95, 60] None [60, 72, 95] ['a', 'a', 'a', 'b', 'n', 'n'] ['Banana', 'Kiwi', 'apple'] ['apple', 'Banana', 'Kiwi']
Follow-up: What does stable mean? Items that compare equal keep their original order, so you can sort by a secondary key first and then by the main key.
Go deeper: Python Sorting: sort(), sorted(), Key Functions, and Custom Order
29.What does
enumerate()do, and why use it?enumerate(iterable)yields(index, item)pairs, so a loop gets the position and the value together without a manual counter orrange(len(items)). Thestart=argument sets the first number, handy for numbering from 1. It works on any iterable, not just lists, and it is lazy likezip().enumerate_function.pytasks = ["write", "test", "ship"]for number, task in enumerate(tasks, start=1):print(f"{number}. {task}")print(list(enumerate("abc")))scores = [40, 75, 90, 55]passed_at = [i for i, s in enumerate(scores) if s >= 60]print(passed_at) # positions of the passing scoresOutput1. write 2. test 3. ship [(0, 'a'), (1, 'b'), (2, 'c')] [1, 2]
Follow-up: How would you write
enumerate()yourself? As a generator: keep a countern = start, and for each itemyield n, item, then add 1 ton.Go deeper: Python For Loops: Iterate Over Anything with Examples
30.How do you merge two dictionaries?
Since Python 3.9,
a | breturns a new merged dict anda |= bupdatesain place.{**a, **b}also builds a new dict, anda.update(b)changesain place. In every form, when a key is in both, the value from the right-hand dict wins. All of them are shallow: a nested dict on the right replaces the one on the left instead of being merged into it.merge_dictionaries.pydefaults = {"theme": "dark", "font": 14, "lang": "en"}user = {"font": 16, "lang": "fr"}print(defaults | user) # Python 3.9+print({**defaults, **user})settings = defaults.copy()settings.update(user) # in placeprint(settings)print(defaults) # none of the three changed itOutput{'theme': 'dark', 'font': 16, 'lang': 'fr'} {'theme': 'dark', 'font': 16, 'lang': 'fr'} {'theme': 'dark', 'font': 16, 'lang': 'fr'} {'theme': 'dark', 'font': 14, 'lang': 'en'}Follow-up: How do you merge nested dicts? Write a small recursive function that merges the values that are both dicts and overwrites the rest, since
|andupdate()only merge the top level.Go deeper: Python Dictionaries: The Complete Guide with Real Examples
Advanced questions
Memory, the GIL, the MRO, metaclasses and descriptors.
31.What is the GIL, and how does it affect threads?
The GIL (global interpreter lock) is a lock in CPython that lets only one thread run Python bytecode at a time. So threads do not speed up CPU-bound work: two threads crunching numbers take turns on one core. They still help with I/O-bound work, because a thread waiting on the network or the disk releases the GIL and another thread runs meanwhile.
The GIL does not make your code thread-safe:
count += 1is several steps, a thread can be switched out between them, so shared data still needs athreading.Lock.Follow-up: Is the GIL going away? Since Python 3.13 there is an optional free-threaded build without it (PEP 703), but the standard build most people install still has the GIL.
Go deeper: Python Threading vs Multiprocessing vs asyncio: When to Use What
32.When would you use threads, processes or asyncio?
Threads suit I/O-bound work with a modest number of tasks, such as fetching 20 web pages with a blocking library: they are easy to add, and the GIL is released while each one waits. Processes suit CPU-bound work, because each process has its own interpreter and GIL and can keep a core busy; the cost is slower start-up and arguments that must be pickled to cross over.
asynciosuits I/O-bound work at high concurrency, like thousands of open sockets, where one thread switches between cheap coroutines at everyawait.The catch with
asynciois that the whole call chain must be async: one blocking call, such astime.sleeporrequests.get, stalls every task on the event loop.Follow-up: How do you switch between threads and processes easily?
concurrent.futuresgivesThreadPoolExecutorandProcessPoolExecutorthe samesubmitandmapinterface, so it is a one-word change.Go deeper: Python Threading vs Multiprocessing vs asyncio: When to Use What
33.What is a context manager, and how do you write one with
contextlib?A context manager is an object used with
withthat runs setup code on entry and cleanup code on exit, even when the block raises an exception: closing files, releasing locks, rolling back transactions.contextlib.contextmanagerturns a generator into one: the code beforeyieldis the setup, the yielded value is whatasbinds, and the code after it is the cleanup. Put theyieldinsidetry/finally, or the cleanup is skipped when the block fails.context_managers.pyfrom contextlib import contextmanager, suppress@contextmanagerdef tag(name):print(f"<{name}>")try:yield namefinally:print(f"</{name}>") # runs even when the block raiseswith suppress(ZeroDivisionError): # contextlib again: swallows the errorwith tag("div") as t:print("inside", t)1 / 0print("carried on after the error")Output<div> inside div </div> carried on after the error
Follow-up: How do you write one as a class? Define
__enter__, whose return value is whatasbinds, and__exit__(exc_type, exc, tb), which always runs and can returnTrueto swallow the exception.Go deeper: Python Context Managers: The with Statement and Building Your Own
34.How does Python manage memory?
CPython frees most objects by reference counting: each object counts the references to it (names, list items, attributes), and when the count drops to zero its memory is released at once. Reference counting alone cannot free a cycle, such as two objects that point at each other, so a cyclic garbage collector (the
gcmodule) runs from time to time, finds groups of objects reachable only from each other, and frees them. Small objects come from Python's own allocator, which keeps memory pools for reuse instead of handing every freed block back to the operating system.memory_management.pyimport gc, weakrefclass Node: passa = Node()weakref.finalize(a, print, "a freed by reference counting")del a # the count hits 0 on this linegc.disable() # pause automatic collection for the demox, y = Node(), Node()x.partner, y.partner = y, x # a cycle: each keeps the other aliveweakref.finalize(x, print, "x freed by the cycle collector")del x, yprint("names deleted, the cycle is still in memory")gc.collect()gc.enable()Outputa freed by reference counting names deleted, the cycle is still in memory x freed by the cycle collector
Follow-up: Does
del xdelete the object? No, it removes the namex; the object is freed only when its last reference goes away.Go deeper: How Python Works Under the Hood: Memory, GIL, and Bytecode
35.What does
__slots__do?__slots__declares a fixed set of attribute names, so instances store those values in compact slots instead of a per-instance__dict__. That saves memory when you create many small objects (millions of points or records) and makes attribute access slightly faster. The trade-off: you cannot add attributes that are not listed, which also turns a typo likep.nmae = 1into anAttributeErrorinstead of a silent new attribute.slots.pyclass Point:__slots__ = ("x", "y") # no per-instance __dict__def __init__(self, x, y):self.x = xself.y = yp = Point(2, 3)print(p.x, p.y)print("has __dict__:", hasattr(p, "__dict__"))try:p.z = 5 # z is not in __slots__except AttributeError as err:print("AttributeError:", err)Output2 3 has __dict__: False AttributeError: 'Point' object has no attribute 'z' and no __dict__ for setting new attributes
Follow-up: Does a subclass keep the saving? Only if it declares
__slots__too; otherwise its instances get a__dict__again.36.What is the MRO, and how does
super()work with multiple inheritance?The MRO (method resolution order) is the order in which Python searches a class and its bases for an attribute, and you can read it from
Class.__mro__. It comes from the C3 algorithm, which puts every class before its parents and keeps the bases in the order you listed them.super()means the next class after the current one in the MRO of the instance's class, not simply the parent. That is why, in a diamond, each method callssuper()once and every class in the chain runs exactly once.mro_and_super.pyclass Base:def hello(self): return ["Base"]class Left(Base):def hello(self): return ["Left"] + super().hello()class Right(Base):def hello(self): return ["Right"] + super().hello()class Child(Left, Right):def hello(self): return ["Child"] + super().hello()print([cls.__name__ for cls in Child.__mro__])print(Child().hello()) # super() in Left goes to Right, not BaseOutput['Child', 'Left', 'Right', 'Base', 'object'] ['Child', 'Left', 'Right', 'Base']
Follow-up: When does Python refuse to build an MRO? When the bases contradict each other, for example
class C(A, B)whereBalready inherits fromA, which raisesTypeError: Cannot create a consistent method resolution order.Go deeper: Python Multiple Inheritance and MRO: How It Actually Works
37.What is a metaclass?
A metaclass is the class of a class. An object is created by calling its class, and a class is created by calling its metaclass, which is
typeunless you say otherwise. A custom metaclass subclassestypeand overrides__new__or__init__to inspect or change every class as it is defined: registering plugins, enforcing naming rules, adding methods. They are rarely the right tool: class decorators and__init_subclass__handle most of those jobs more simply.metaclasses.pyDog = type("Dog", (), {"sound": "woof"}) # a class made by calling typeprint(Dog().sound, type(Dog).__name__)class Registry(type):classes = []def __new__(mcls, name, bases, namespace):cls = super().__new__(mcls, name, bases, namespace)mcls.classes.append(name)return clsclass Plugin(metaclass=Registry): passclass CsvExport(Plugin): pass # subclasses get the metaclass tooclass PdfExport(Plugin): passprint(Registry.classes, type(CsvExport).__name__)Outputwoof type ['Plugin', 'CsvExport', 'PdfExport'] Registry
Follow-up: What is
__init_subclass__? A hook on a base class that Python calls each time a subclass is defined, which covers plugin registration without a metaclass.Go deeper: Python Metaclasses: Control How Classes Are Created
38.What is the difference between
__new__and__init__?__new__creates and returns the new object;__init__then receives that object and sets its attributes, returningNone.__new__takes the class as its first argument and runs first, and Python calls__init__only if__new__returned an instance of that class. You override__new__only when__init__is too late: subclassing immutable types such asint,str,floatortuple, returning cached instances, or singletons.new_vs_init.pyclass Price(float):def __new__(cls, amount):print("1. __new__ builds the object")# float is immutable, so the value must be set herereturn super().__new__(cls, round(amount, 2))def __init__(self, amount):print("2. __init__ receives", self) # too late to change itprice = Price(19.9949)print(price, price * 2)Output1. __new__ builds the object 2. __init__ receives 19.99 19.99 39.98
Follow-up: What if
__new__returns an object of a different class? Python skips__init__and the call returns that object unchanged.Go deeper: __init__ vs __new__: What Actually Happens When You Create an Object
39.What does
@dataclassgenerate for you?From the annotated class attributes,
@dataclasswrites__init__, a readable__repr__and a field-by-field__eq__. Options add more:order=Truegenerates<,<=,>and>=(comparing the fields as a tuple, in order),frozen=Trueblocks assignment and adds__hash__, andslots=Trueadds__slots__. A mutable default needsfield(default_factory=list): a plain[]default raisesValueError, which guards against the shared-default bug.dataclass.pyfrom dataclasses import dataclass, field@dataclass(order=True)class Student:name: strmarks: int = 0tags: list = field(default_factory=list)asha = Student("Asha", 91)ravi = Student("Ravi", 78, ["sports"])print(asha) # generated __repr__print(asha == Student("Asha", 91)) # generated __eq__print(asha < ravi) # order=True: "Asha" < "Ravi"OutputStudent(name='Asha', marks=91, tags=[]) True True
Follow-up: Why is a normal dataclass unhashable? It defines
__eq__without__hash__, so Python sets__hash__toNone;frozen=Truegives it a hash built from the fields.Go deeper: Python Dataclasses: Less Boilerplate, More Readable Code
40.What is a property, and how does it relate to descriptors?
A property makes a method look like an attribute: reading
acct.balancecalls a getter and assigning to it calls a setter, so you can add validation or computed values later without changing how callers use the class.propertyis a descriptor: an object stored on the class whose type defines__get__,__set__or__delete__, which Python calls whenever the attribute is read or assigned on an instance. Methods,classmethodandstaticmethodwork the same way. When many attributes need the same logic, write your own descriptor class instead of repeating properties.property_and_descriptors.pyclass Account:@propertydef balance(self): return self._balance@balance.setterdef balance(self, value):if value < 0: raise ValueError("balance cannot be negative")self._balance = valueacct = Account()acct.balance = 100 # runs the setterprint(acct.balance, Account.balance.__get__(acct)) # one getter, two waystry:acct.balance = -5except ValueError as err:print("ValueError:", err)Output100 100 ValueError: balance cannot be negative
Follow-up: Why does a property beat an instance attribute with the same name? It defines
__set__, which makes it a data descriptor, and data descriptors on the class take priority over the instance__dict__; plain functions are non-data descriptors, so an instance attribute can shadow a method.Go deeper: Python Descriptors: The Secret Behind @property and ORM Fields
41.How does
functools.lru_cachework, and when is it unsafe?functools.lru_cachestores each result in a dict keyed by the call arguments, so a repeat call returns the saved value without running the function. With amaxsize(128 by default) it drops the least recently used entry when full;maxsize=None, orfunctools.cache, never drops anything. It is unsafe when the result depends on anything besides the arguments (the clock, a file, a database, global state), because callers get stale answers. It also returns the same object every time, so a cached list that one caller modifies is changed for everyone, and caching a method keeps everyselfalive in the cache.lru_cache.pyfrom functools import lru_cache@lru_cache(maxsize=None)def square(n):print(f"computing {n}")return n * nprint(square(4), square(4)) # computed once, then served from the cache@lru_cachedef default_tags(user):return ["new"]default_tags("asha").append("admin") # changes the cached list itselfprint(default_tags("asha")) # every later caller sees itOutputcomputing 4 16 16 ['new', 'admin']
Follow-up: Why does passing a list raise
TypeError? The arguments become a dict key, so they must be hashable; pass a tuple instead.Go deeper: Python functools: lru_cache, partial, reduce, singledispatch
42.What is duck typing, and what are type hints for?
Duck typing means code depends on what an object can do, not on its class: any object with the method you call will work. Type hints, such as
def total(prices: list[float]) -> float, document the intended types and let tools like mypy, pyright and your editor catch mistakes before the code runs. Python does not check them at runtime; it only stores them in__annotations__.typing.Protocoljoins the two ideas: it describes a duck type (anything with aspeak()method) that a type checker can verify without inheritance.duck_typing_and_type_hints.pyclass Duck:def speak(self): return "Quack"class Robot: # unrelated class, same methoddef speak(self): return "Beep"for thing in (Duck(), Robot()):print(thing.speak()) # works for anything with speak()def double(n: int) -> int:return n * 2print(double("ha")) # hints are not enforced at runtimeprint(double.__annotations__)OutputQuack Beep haha {'n': <class 'int'>, 'return': <class 'int'>}Follow-up: How would you enforce types at runtime? With explicit
isinstancechecks, or a library such as pydantic that reads the hints and validates incoming data.Go deeper: Python Type Hints: Add Types, Generics, and the typing Module
43.What do
*and**unpacking do, and what are keyword-only arguments?In a call,
*itemsspreads a sequence into positional arguments and**optionsspreads a dict into keyword arguments. In a definition they do the reverse:*argscollects extra positional arguments into a tuple and**kwargscollects extra keyword arguments into a dict. Parameters after*argsor a bare*are keyword-only: callers must pass them by name, which keeps calls such assort(reverse=True)readable and lets you add parameters later without breaking existing calls.unpacking_and_keyword_only.pydef order(item, *extras, qty=1, **notes): # qty is keyword-onlyprint(item, extras, qty, notes)order("pizza", "olives", "chilli", qty=2, table=7)order(*["tea"], **{"qty": 3, "sugar": False}) # spread a list and a dictdef connect(host, *, timeout=10): # bare *: timeout is keyword-onlyreturn f"{host} (timeout {timeout})"print(connect("db", timeout=5))try:connect("db", 5)except TypeError as err:print("TypeError:", err)Outputpizza ('olives', 'chilli') 2 {'table': 7} tea () 3 {'sugar': False} db (timeout 5) TypeError: connect() takes 1 positional argument but 2 were givenFollow-up: What does a
/in a signature mean? Parameters before it are positional-only, as indef area(width, height, /), so callers cannot pass them by name.Go deeper: Python *args, **kwargs, Default Values, and Keyword Arguments
44.How do
__eq__and__hash__relate?Objects that compare equal must have equal hashes, because a dict or set finds a key by its hash first and only then checks
==. By default both are based on identity, so they agree. If a class defines__eq__without__hash__, Python sets__hash__toNoneand its instances become unhashable, so you cannot break the rule by accident. Hash the same fields that__eq__compares, and only fields that never change: if a key's hash changes after it is stored, the dict can no longer find it.eq_and_hash.pyclass Point:def __init__(self, x, y):self.x, self.y = x, ydef __eq__(self, other):if not isinstance(other, Point): return NotImplementedreturn (self.x, self.y) == (other.x, other.y)def __hash__(self):return hash((self.x, self.y)) # the same fields as __eq__points = {Point(1, 2), Point(1, 2), Point(3, 4)}print(len(points), Point(1, 2) in points)class Tag:def __eq__(self, other): return True # __eq__ but no __hash__print(Tag.__hash__) # None: instances are unhashableOutput2 True None
Follow-up: Why are lists unhashable? They are mutable, so their hash would change with their contents; use a tuple as a dict key instead.
Go deeper: Python Magic Methods: The Complete __dunder__ Guide
45.What is monkey patching, and why is it risky?
Monkey patching means replacing an attribute of a module, class or object at runtime, for example swapping a library function for your own. It is useful in tests, where
unittest.mock.patchswaps something in and puts it back afterwards, and for hot-fixing a bug in a third-party library. It is risky because the change is global and invisible where the function is called: every caller in the program sees it, it breaks silently when the library changes its internals, and code that ranfrom math import sqrtbefore the patch still holds the original.monkey_patching.pyimport mathfrom math import sqrt # this name was bound before the patchoriginal = math.sqrtmath.sqrt = lambda x: "patched!" # every math.sqrt caller now gets thisprint(math.sqrt(16))print(sqrt(16)) # the early import still has the originalmath.sqrt = original # always put it backfrom unittest.mock import patchwith patch("math.sqrt", return_value=99): # patches, then restores itselfprint(math.sqrt(16))print(math.sqrt(16))Outputpatched! 4.0 99 4.0
Follow-up: How do you patch safely in tests? Use
unittest.mock.patchas a context manager or decorator, and patch the name where it is looked up (myapp.orders.send_email), not where it is defined.
Coding questions
Short tasks to write live, each with a solution and its complexity.
46.Write a function that checks whether a string is a palindrome, ignoring case, spaces and punctuation.
Keep only the letters and digits, lowercase them, and compare the result with its reverse.
str.isalnumdrops spaces and punctuation, and slicing with[::-1]reverses the list. It runs in O(n) time and uses O(n) extra space for the cleaned copy.palindrome_check.pydef is_palindrome(text):cleaned = [ch.lower() for ch in text if ch.isalnum()]return cleaned == cleaned[::-1]print(is_palindrome("A man, a plan, a canal: Panama!"))print(is_palindrome("Hello"))print(is_palindrome("")) # an empty string reads the same both waysOutputTrue False True
Follow-up: Can you do it with O(1) extra space? Yes: move two indexes inward from both ends, skip characters that are not alphanumeric, and compare as you go.
Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)
47.Write a function that returns the first non-repeating character in a string.
Count every character with
collections.Counterin one pass, then walk the string again and return the first character whose count is 1. Two passes make it O(n) time, and the counter holds one entry per distinct character. ReturnNonewhen every character repeats.first_non_repeating_character.pyfrom collections import Counterdef first_unique(text):counts = Counter(text)for ch in text:if counts[ch] == 1:return chreturn Noneprint(first_unique("swiss"))print(first_unique("level"))print(first_unique("aabb")) # every character repeatsOutputw v None
Follow-up: Why not call
text.count(ch)inside the loop? Each call scans the whole string, so that version is O(n^2).Go deeper: Python collections: Counter, defaultdict, deque, and More
48.Write a function that counts how often each word appears in a sentence.
Lowercase the text, pull out the words with a regular expression so punctuation does not stick to them, and count them with
collections.Counter, a dict subclass that maps each word to its count. It runs in O(n) time for a text of length n.most_common(k)gives the top words, and a missing word counts as 0 instead of raisingKeyError.word_frequency.pyimport refrom collections import Counterdef word_counts(sentence):words = re.findall(r"[a-z']+", sentence.lower())return Counter(words)counts = word_counts("The cat saw the dog. The dog didn't see the cat!")print(counts.most_common(2))print(counts["dog"], counts["bird"]) # a missing word counts as 0print(word_counts(""))Output[('the', 4), ('cat', 2)] 2 0 Counter()Follow-up: How would you do it without
Counter? Loop over the words on a plain dict withcounts[word] = counts.get(word, 0) + 1.Go deeper: Python collections: Counter, defaultdict, deque, and More
49.Two sum: given a list of numbers and a target, return the indexes of two numbers that add up to the target.
Walk the list once, keeping a dict that maps each number seen so far to its index. For each number, check whether
target - numis already in the dict: if it is, you have the pair. That is O(n) time and O(n) space, against O(n^2) for checking every pair with two loops.two_sum.pydef two_sum(nums, target):seen = {} # number -> its indexfor i, num in enumerate(nums):if target - num in seen:return seen[target - num], iseen[num] = ireturn Noneprint(two_sum([2, 7, 11, 15], 9))print(two_sum([3, 3], 6)) # the same value twiceprint(two_sum([1, 2, 3], 100)) # no pair adds upOutput(0, 1) (0, 1) None
Follow-up: What if the list is sorted? Use two pointers from both ends, moving the left one up when the sum is too small and the right one down when it is too big: O(n) time and O(1) space.
Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)
50.Write FizzBuzz: count from 1 to n, but say Fizz for multiples of 3, Buzz for multiples of 5 and FizzBuzz for multiples of both.
The trap is order: with an if/elif chain you must test for 15 (both) before 3 and 5, or 15 comes out as Fizz. Building the word from parts sidesteps that, and adding a new rule becomes one more line. It runs in O(n) time.
fizzbuzz.pydef fizzbuzz(n):result = []for i in range(1, n + 1):word = ("Fizz" if i % 3 == 0 else "") + ("Buzz" if i % 5 == 0 else "")result.append(word or str(i))return resultprint(", ".join(fizzbuzz(15)))print(fizzbuzz(0)) # nothing to countOutput1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, FizzBuzz []
Follow-up: How would you add Bazz for multiples of 7? Keep the rules as a list of (divisor, word) pairs and join the words whose divisor matches, so a new rule is one more pair.
Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)
51.Write a function that reverses the order of words in a sentence.
Split the sentence into words with
split(), reverse the list, and join it back with single spaces.split()with no argument splits on any run of whitespace and ignores leading and trailing spaces, so messy input still gives clean output. It runs in O(n) time and space.reverse_words.pydef reverse_words(sentence):return " ".join(reversed(sentence.split()))print(reverse_words("Python is fun"))print(reverse_words(" hello world ")) # extra spaces disappearprint(repr(reverse_words("")))Outputfun is Python world hello ''
Follow-up: Can you do it in place? Not on a
str, which is immutable; on a list of characters, reverse the whole list and then reverse each word back.Go deeper: Every Python String Method Explained with Examples
52.Write a function that checks whether two strings are anagrams.
Two strings are anagrams when they contain the same characters the same number of times, so compare
Counter(a) == Counter(b), which is O(n) time.sorted(a) == sorted(b)is shorter but O(n log n). Ask whether case and spaces count; here both are ignored.anagram_check.pyfrom collections import Counterdef clean(text):return text.replace(" ", "").lower()def is_anagram(a, b):return Counter(clean(a)) == Counter(clean(b))print(is_anagram("Listen", "Silent"))print(is_anagram("Dormitory", "Dirty room"))print(is_anagram("aab", "abb")) # same letters, different countsOutputTrue True False
Follow-up: What if the strings hold only lowercase English letters? A list of 26 counts indexed by
ord(ch) - 97does the same job with O(1) extra space.Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)
53.Find the missing number in a list that holds every number from 1 to n except one.
The numbers 1 to n add up to
n * (n + 1) // 2, so the missing one is that total minus the sum of the list, with n one more than the list's length. That is O(n) time and O(1) extra space, and Python integers never overflow. In languages with fixed-size integers, XOR-ing everything with 1 to n gives the same answer without a large sum.missing_number.pydef missing_number(nums):n = len(nums) + 1 # one number is missingreturn n * (n + 1) // 2 - sum(nums)print(missing_number([3, 7, 1, 2, 8, 4, 5]))print(missing_number([1, 2, 3])) # the last number is the missing oneprint(missing_number([])) # n is 1, so 1 is missingOutput6 4 1
Follow-up: What if two numbers are missing? Use the sum and the sum of squares to get two equations, or put the list in a set and check every number from 1 to n against it.
54.Write a function that flattens a nested list of any depth.
Recurse: for each item, if it is a list, flatten it and pass its items on, otherwise pass the item itself. A generator with
yield fromkeeps it short and lazy. It is O(n) in the total number of items and sublists. Nesting deeper than about 1,000 levels hits the recursion limit.flatten_nested_list.pydef flatten(items):for item in items:if isinstance(item, list):yield from flatten(item)else:yield itemprint(list(flatten([1, [2, [3, [4, [5]]]], 6])))print(list(flatten([[], [[]], "ab"]))) # strings stay wholeprint(list(flatten([])))Output[1, 2, 3, 4, 5, 6] ['ab'] []
Follow-up: How do you flatten without recursion? Keep a stack of iterators: push a sublist when you meet one and pop when its iterator runs out, so depth is limited only by memory.
Go deeper: Python Generators: Process Millions of Items Without Running Out of Memory
55.Find the second largest number in a list.
Track the largest and second largest distinct values in one pass: O(n) time and O(1) space.
sorted(set(nums))[-2]is shorter but O(n log n). Agree on duplicates with the interviewer: for[5, 5, 3]the usual answer is 3, and a list without two distinct values has no answer, so returnNone.second_largest.pydef second_largest(nums):first = second = Nonefor n in nums:if first is None or n > first:first, second = n, firstelif n != first and (second is None or n > second):second = nreturn secondprint(second_largest([12, 45, 2, 41, 45]))print(second_largest([5, 5, 3])) # duplicates of the largest are skippedprint(second_largest([7, 7])) # no second distinct valueOutput41 3 None
Follow-up: How would you find the k-th largest?
heapq.nlargest(k, set(nums))[-1]keeps only k items in a heap, which is O(n log k).Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)
56.Merge two sorted lists into one sorted list.
Walk both lists with two indexes, always taking the smaller front item, then add whatever is left of the list that did not run out. That is O(n + m) time, which is the point of the question:
sorted(a + b)works but costs O((n + m) log(n + m)). Using<=takes from the first list on ties, so equal items keep their order.merge_sorted_lists.pydef merge_sorted(a, b):result, i, j = [], 0, 0while i < len(a) and j < len(b):if a[i] <= b[j]:result.append(a[i])i += 1else:result.append(b[j])j += 1return result + a[i:] + b[j:] # one of these is already emptyprint(merge_sorted([1, 4, 9], [2, 3, 10, 12]))print(merge_sorted([], [5, 6]))print(merge_sorted([2, 2], [2]))Output[1, 2, 3, 4, 9, 10, 12] [5, 6] [2, 2, 2]
Follow-up: Is there a built-in?
heapq.merge(a, b)merges any number of sorted iterables lazily, one item at a time.Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)
57.Write a function that checks whether the brackets in a string are balanced.
Use a stack: push every opening bracket, and on a closing bracket check that the stack is not empty and that the bracket popped from the top is its partner. The string is balanced if the stack is empty at the end. Other characters are skipped. It runs in O(n) time and O(n) space in the worst case.
balanced_brackets.pydef is_balanced(text):pairs = {")": "(", "]": "[", "}": "{"}stack = []for ch in text:if ch in "([{":stack.append(ch)elif ch in pairs:if not stack or stack.pop() != pairs[ch]:return Falsereturn not stackprint(is_balanced("{[a + b] * (c - d)}"))print(is_balanced("([)]")) # right counts, wrong orderprint(is_balanced("((")) # never closedOutputTrue False False
Follow-up: Why not just count opening and closing brackets? A count checks the totals but not the order, so
([)]would pass; the stack remembers which bracket must close next.Go deeper: 50 Python Practice Problems with Instant Answer Checking (Beginner to Advanced)
58.Write a Fibonacci function that uses memoization.
Plain recursive Fibonacci recomputes the same values over and over, so its running time grows exponentially. Memoization stores each result the first time it is computed, so every
fib(k)is worked out once and the whole call is O(n) time and O(n) space. In real code,@functools.cacheon the plain recursive version adds the cache in one line.fibonacci_memoization.pydef fib(n, memo=None):if memo is None:memo = {}if n < 2:return nif n not in memo:memo[n] = fib(n - 1, memo) + fib(n - 2, memo)return memo[n]print([fib(i) for i in range(10)])print(fib(90)) # instant; uncached, this would take thousands of yearsOutput[0, 1, 1, 2, 3, 5, 8, 13, 21, 34] 2880067194370816120
Follow-up: Can you do it without recursion? Yes: keep only the last two values and run
a, b = b, a + bn times, which is O(n) time, O(1) space and has no recursion limit.Go deeper: Python Recursion: How It Works, When to Use It, and Examples
59.Move all zeros in a list to the end, keeping the order of the other numbers.
Keep a write index: copy each non-zero value forward to that index, then fill the rest of the list with zeros. Every item is visited once, so it is O(n) time and O(1) extra space, done in place. If a new list is fine,
[n for n in nums if n != 0]padded with the right number of zeros is just as fast.move_zeros_to_end.pydef move_zeros(nums):write = 0for num in nums:if num != 0:nums[write] = numwrite += 1for i in range(write, len(nums)):nums[i] = 0return numsprint(move_zeros([0, 1, 0, 3, 12]))print(move_zeros([0, 0, 0]))print(move_zeros([4, 2])) # no zeros: unchangedOutput[1, 3, 12, 0, 0] [0, 0, 0] [4, 2]
Follow-up: Why not
nums.sort(key=lambda n: n == 0)? It works, because the sort is stable and keeps the non-zero order, but it is O(n log n) instead of O(n).Go deeper: Python Lists: Create, Access, Modify, and Loop Through Lists
60.Group words that are anagrams of each other.
Anagrams have the same letters once sorted, so use the sorted letters as a dict key and append each word to its group;
defaultdict(list)creates each group on first use. For n words of up to k letters that is O(n k log k) time. Using a tuple of 26 letter counts as the key removes the sort and makes it O(n k).group_anagrams.pyfrom collections import defaultdictdef group_anagrams(words):groups = defaultdict(list)for word in words:key = "".join(sorted(word)) # "eat" and "tea" both become "aet"groups[key].append(word)return list(groups.values())print(group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))print(group_anagrams(["solo"]))print(group_anagrams([]))Output[['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']] [['solo']] []
Follow-up: Why is the key a string and not the sorted list itself? Dict keys must be hashable, and a list is not; a string or a tuple is.
Go deeper: Python Dictionaries: The Complete Guide with Real Examples
Practise against the clock
A timed mock interview gives you two problems and a clock, and the 50 practice problems check your code as you go.