Interview track
Full-Stack Developer interview prep
A spaced-repetition deck of 735+ Full-Stack Developer interview questions — organised by topic and difficulty, and resurfaced right before you'd forget. Preview a few cards below, then sign in to study the whole track on an Anki-style SM-2 schedule.
Free · sign in with GitHub · your progress stays yours.
What's covered
Every topic in this track, grouped the way you'd study it.
Python
130 cardsDatabases
104 cardsBackend
175 cardsFrontend
151 cardsCS Fundamentals
76 cardsDevOps & Infra
35 cardsSystem Design
29 cardsBehavioral
35 cardsSample questions
A few cards from the deck — reveal each answer, then sign in to study the full set on a schedule.
What's the difference between mutable and immutable types?
What's the difference between mutable and immutable types?
Short answer: Mutable objects can be changed in place without creating a new object (list, dict, set, bytearray); immutable ones cannot (int, float, str, bytes, tuple, frozenset, bool, None). Any "change" to an immutable object creates a new object.
In depth: Mutability is about whether an object can change its contents while keeping the same id() (its address in memory).
# Immutable: the operation creates a NEW object
s = "hello"
print(id(s))
s += " world" # a new string is created
print(id(s)) # id changed — it's a different object
# Mutable: the same object is changed
lst = [1, 2, 3]
print(id(lst))
lst.append(4) # change in place
print(id(lst)) # same id
Why it matters:
- Hashability. Only objects that are immutable (by content) can be used as
dictkeys orsetelements. If a key could change, its hash would drift and it couldn't be found. - Safety when sharing. An immutable object can be safely shared across threads/functions — no one can corrupt it.
- Function arguments. Passing a mutable object means the function can change it (see the question on argument passing).
⚠️ Gotcha: A tuple is immutable, but if it holds a list, that list can still be changed:
t = ([1, 2], 3)
t[0].append(99) # OK! the list inside is mutable
print(t) # ([1, 2, 99], 3)
# t[0] = [...] # THIS is a TypeError — you can't reassign an element
Also, hash(([1,2], 3)) will fail — a tuple is unhashable if it contains an unhashable element.
What's the difference between list and tuple?
What's the difference between list and tuple?
Short answer: list is mutable, tuple is not. A tuple is hashable (if all its elements are hashable), uses less memory, and is created slightly faster. Use list for homogeneous, mutable collections and tuple for fixed, heterogeneous records.
In depth:
| Property | list | tuple |
|---|---|---|
| Mutability | yes | no |
| Hashability | no | yes (if elements are hashable) |
| Memory | more (reserves room for growth) | less |
| Creation | slower | faster (a literal can be cached) |
| Semantics | collection of homogeneous elements | record with a fixed structure |
import sys
print(sys.getsizeof([1, 2, 3])) # ~88 bytes
print(sys.getsizeof((1, 2, 3))) # ~64 bytes
Why list is bigger: it keeps an over-allocation — it reserves space in advance for future appends to amortize the cost of growth to O(1). A tuple is fixed and needs no spare room.
When to choose which:
tuple— when the count and meaning of the elements are fixed: coordinates(x, y), returning multiple values from a function, a dictionary key.list— when the collection grows/changes/gets sorted.
⚠️ Gotcha: "a tuple is faster" is true only for creating a literal and for access, not magically for everything. And remember nested mutable objects: a tuple guarantees the immutability of references, not of the objects they point to.
What is hashability and why is it needed?
What is hashability and why is it needed?
Short answer: An object is hashable if it has a __hash__() that returns a stable value over its lifetime, plus an __eq__(). Hashability is needed to be a dict key or a set element.
In depth: The contract: if a == b, then hash(a) == hash(b). The reverse need not hold (collisions are allowed). By default, user-defined objects are hashed by id().
hash((1, 2)) # ok
hash(frozenset())) # ok
# hash([1, 2]) # TypeError: unhashable type: 'list'
If you override __eq__, then __hash__ automatically becomes None (the object stops being hashable) — Python protects the contract. You need to set __hash__ explicitly:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
__hash__ = lambda self: hash((self.x, self.y))
⚠️ Gotcha: Don't base __hash__ on a mutable field. If a key object changes after insertion, its hash drifts and d[key] won't find it — it gets stuck in the dictionary forever.
How does Python pass arguments to a function?
How does Python pass arguments to a function?
Short answer: "Pass by object reference" (a.k.a. "call by sharing"). A reference to the same object is passed into the function. A mutable object can be changed from inside, but rebinding the name inside cannot.
In depth: A name in Python is a label on an object. On a call, the parameter becomes a new label on the same object.
def mutate(lst):
lst.append(99) # change the object itself → visible outside
def rebind(lst):
lst = [0, 0, 0] # rebind the LOCAL name → not visible outside
x = [1, 2]
mutate(x); print(x) # [1, 2, 99]
rebind(x); print(x) # [1, 2, 99] — unchanged
So it's neither "pass by value" (there's no copy) nor classic "pass by reference" (you can't reassign the caller's variable). The value of the reference is passed.
⚠️ Gotcha: Immutable objects create the illusion of "pass by value":
def inc(n): n += 1 # n = n + 1 creates a new int, the name is local
a = 5; inc(a); print(a) # 5 — unchanged
To "return" a change to an immutable, return the value via return.
What's the difference between is and ==?
What's the difference between is and ==?
Short answer: == compares values (it calls __eq__). is compares identity — whether it's the same object in memory (id(a) == id(b)).
In depth:
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True — values are equal
a is b # False — different objects
c = a
a is c # True — the same object
is is used to compare against singletons: None, True, False.
if x is None: ... # correct
if x == None: ... # works, but bad style and slower
⚠️ Gotcha: is sometimes "accidentally" matches == due to object caching (see the next question), and beginners write if x is 256 — it works in the REPL for small numbers and breaks for large ones. Never use is to compare the values of numbers/strings.
What are *args and **kwargs, and what kinds of arguments are there?
What are *args and **kwargs, and what kinds of arguments are there?
Short answer: *args collects extra positional arguments into a tuple; **kwargs collects extra keyword ones into a dict. In a signature, / separates positional-only arguments and * separates keyword-only ones.
In depth:
def f(a, b, /, c, d, *args, e, f=10, **kwargs):
...
# ^^^^ ^^^^^ ^^^^^^^^^
# positional- regular keyword-only (after *)
# only (before /)
- Before
/— positional-only (can't be passed by name). - After
*or*args— keyword-only. *args→tuple,**kwargs→dict.
Unpacking at the call site:
def add(a, b, c): return a + b + c
nums = [1, 2, 3]
add(*nums) # unpack a list into positionals
d = {"a": 1, "b": 2, "c": 3}
add(**d) # unpack a dict into keyword args
Why positional-only (/): so that parameter names don't become part of the public API and can be renamed. Why keyword-only (*): to force the caller to write func(verbose=True) for readability and to guard against a mixed-up order.
⚠️ Gotcha: The names args/kwargs are a convention; what matters is the * and **. And the order when unpacking multiple sources must be valid: f(*a, *b, **c, **d) is allowed, but the keys in **c/**d must not conflict.
Ready to make it stick?
Start your first session in under a minute. Your future self, mid-interview, will thank you.