Core language & data model
Questions in this set 10
- 01What is the difference between a list and a tuple?
- 02Is Python pass-by-value or pass-by-reference?
- 03is vs == — when do they differ, and why does a is b sometimes surprise you?
- 04What is truthiness? Which objects are falsy?
- 05Explain shallow vs deep copy.
- 06What does a list comprehension actually compile to, and when should you not use one?
- 07Explain *args, **kwargs, and keyword-only / positional-only parameters.
- 08How does string formatting differ across %, .format(), and f-strings — and when must you avoid f-strings?
- 09What are __str__ and __repr__ for?
- 10What is the walrus operator good for?
These are the warm-up questions. Getting them slightly right is the difference between "junior" and "junior who has actually read the docs". Answer each in two sentences, then offer the deeper detail — interviewers reward candidates who signal depth without monologuing.
What is the difference between a list and a tuple?
Short answer. A list is mutable and variable-length; a tuple is immutable and fixed-length. That immutability makes tuples hashable (if their contents are), so they can be dict keys and set members.
The deeper reasons this matters:
point = (3, 4)
{point: "origin-ish"} # fine — tuples hash by value
{[3, 4]: "nope"} # TypeError: unhashable type: 'list'- Semantics. Convention says a tuple is a record (heterogeneous, position means something:
(x, y)) and a list is a collection (homogeneous, order may be incidental). - Memory/speed. Tuples are allocated exactly once, are slightly smaller, and CPython caches small ones. Do not lead with this — it is a micro-optimisation, and leading with it suggests you think performance is the main difference.
- Immutability is shallow.
t = ([1], 2)— you cannot rebindt[0], but you can dot[0].append(9). This is a favourite follow-up.
Follow-up they will ask: "Is a tuple always hashable?" No — only if every element is. hash(([1],)) raises TypeError.
Is Python pass-by-value or pass-by-reference?
Short answer. Neither, exactly. Python passes object references by value: the function gets its own name bound to the same object. Mutating the object is visible to the caller; rebinding the name is not.
def f(xs, n):
xs.append(1) # mutation — caller sees it
xs = [9] # rebinding — caller does NOT see it
n += 1 # ints are immutable, so this is also just a rebind
data, num = [], 0
f(data, num)
print(data, num) # [1] 0The name for this is call by sharing (or "call by object reference"). Say that phrase — it is a strong signal.
Trap: Mutable default arguments.
def add(item, bucket=[]): # BUG: the list is created once, at def time
bucket.append(item)
return bucket
add(1); add(2) # [1, 2] — surprise
def add(item, bucket=None): # correct
bucket = [] if bucket is None else bucket
bucket.append(item)
return bucketis vs == — when do they differ, and why does a is b sometimes surprise you?
== calls __eq__ (value equality). is compares identity — the same object in memory. Use is only for singletons: None, True, False, and sentinel objects.
a, b = 256, 256
a is b # True — CPython interns small ints (-5..256)
a, b = 257, 257
a is b # often False in a REPL, True inside one compiled function bodyThat inconsistency is a CPython implementation detail, not a language guarantee. The correct interview answer is: "never rely on it; compare with ==." Linters flag x is 257 for exactly this reason.
What is truthiness? Which objects are falsy?
An object is falsy if __bool__ returns False, or (absent that) __len__ returns 0. Falsy built-ins: None, False, 0, 0.0, Decimal(0), "", [], {}, (), set(), range(0).
Trap that shows up in real code review:
def f(timeout=None):
if not timeout: # BUG: timeout=0 means "don't wait", but is falsy
timeout = 30
if timeout is None: # correct — distinguishes "unset" from "zero"
timeout = 30Explain shallow vs deep copy.
copy.copy builds a new outer container holding the same inner references. copy.deepcopy recursively copies, tracking already-seen objects with a memo dict so cycles do not blow the stack.
import copy
grid = [[0] * 3] * 3 # BUG: three references to ONE row
grid[0][0] = 1 # -> [[1,0,0],[1,0,0],[1,0,0]]
grid = [[0] * 3 for _ in range(3)] # correctdeepcopy is slow and will happily copy things you did not want copied (a DB connection, a lock). In production code prefer constructing new objects explicitly, or dataclasses.replace.
What does a list comprehension actually compile to, and when should you not use one?
It compiles to a loop that appends into a list, run in its own scope (so the loop variable does not leak in Python 3). Prefer it when the expression fits on one line and there is at most one if.
[f(x) for x in xs if p(x)] # good
(f(x) for x in xs) # generator: lazy, O(1) memory
{k: v for k, v in pairs} # dict comp
{x.id for x in xs} # set compDo not use one for side effects ([print(x) for x in xs] builds a garbage list), and do not nest three levels deep — a plain loop is more readable and equally fast.
Follow-up: "How do you flatten a nested list?" [x for row in grid for x in row] — note the clause order matches the equivalent nested for loops, which is the part people get backwards.
Explain *args, **kwargs, and keyword-only / positional-only parameters.
def f(a, b=1, *args, c, d=2, **kwargs): ...
# ^positional-or-keyword ^keyword-only (after *)
def g(a, b, /, c, *, d): ...
# ^ a,b positional-only ^ d keyword-onlyPositional-only (/, Python 3.8+) lets you rename parameters later without breaking callers — it is why C-implemented builtins like len(obj, /) use it. Keyword-only (*) forces call sites to be self-documenting; use it for booleans and options: connect(host, *, timeout=5, retries=3).
How does string formatting differ across %, .format(), and f-strings — and when must you avoid f-strings?
f-strings (3.6+) are fastest and clearest; they are evaluated eagerly at the call site. Avoid them where you want deferred interpolation:
logger.info("user %s failed %d times", user, n) # correct: lazy, only formats if emitted,
# and log aggregators can group by template
logger.info(f"user {user} failed {n} times") # formats always; every message is uniqueSame reasoning applies to SQL: never f-string user input into a query — use parameter binding.
What are __str__ and __repr__ for?
__repr__ is for developers (unambiguous, ideally eval-able); __str__ is for users. repr() falls back to <Class at 0x…>; str() falls back to __repr__. In containers and tracebacks Python always shows __repr__ — which is why a class with only __str__ looks useless in a debugger. Define __repr__ first; define __str__ only if it genuinely differs.
What is the walrus operator good for?
:= assigns inside an expression, avoiding a duplicated call or a pre-loop read.
while (chunk := f.read(8192)):
process(chunk)
if (m := pattern.search(line)) is not None:
use(m.group(1))Use it when it removes a repeated computation. Do not use it to cram two ideas into one line — readability is the whole point.