python_coding / 01 · python-essentials lesson 1 / 19

Python essentials for interviews

In a coding interview you are judged on two axes at once: did you solve the problem, and did you write it the way a fluent Python programmer would. This lesson is the small set of idioms that collapse five lines of bookkeeping into one readable expression.

Why this matters

Knowing the core idioms frees your working memory for the actual algorithm. Not knowing them costs you time and signals inexperience. The lesson is linearized: we start with how to transform sequences (comprehensions), then how to iterate them cleanly (enumerate / zip), then how to destructure them (unpacking / slicing), then how to order them (sorted + key), then the small expression-level tools (f-strings, ternary, any/all, walrus, truthiness), then two philosophies (EAFP vs LBYL), and finally the gotchas — the bugs that bite everyone, each shown broken and then fixed.

The mental model

Almost every Python idiom exists to answer one question: "can I express this transformation declaratively instead of mutating a variable in a loop?" When the answer is yes, prefer the declarative form — it is shorter, faster (the loop runs in C), and harder to get wrong.

Comprehensions — list / dict / set / generator

A comprehension is "build a collection by transforming or filtering an iterable". Read it as [ EXPR for ITEM in ITERABLE if CONDITION ]. It runs the loop in C, so it is both faster and clearer than append-in-loop.

nums = [1, 2, 3, 4, 5]

# LIST comprehension: eager, materialized list. Use when you need a list.
squares = [n * n for n in nums]                  # O(n) time, O(n) space
evens = [n for n in nums if n % 2 == 0]          # filtering with `if`
assert squares == [1, 4, 9, 16, 25]
assert evens == [2, 4]

# Nested comprehension == nested loop. Order reads left-to-right, same as
# the equivalent for-loops written top-to-bottom.
pairs = [(x, y) for x in range(2) for y in range(2)]
assert pairs == [(0, 0), (0, 1), (1, 0), (1, 1)]

The same syntax builds dicts and sets. A dict comprehension is the classic way to invert a sequence into a "value → index" lookup; a set comprehension dedupes while it transforms.

# DICT comprehension: build a mapping. Classic "value -> index" inverse.
index_of = {val: i for i, val in enumerate(nums)}
assert index_of[3] == 2

# SET comprehension: dedupe while transforming.
parities = {n % 2 for n in nums}
assert parities == {0, 1}

The fourth flavour is the generator expression — written with parentheses instead of brackets. It is lazy: it produces items on demand in O(1) memory, never building the intermediate list. Feed one straight into a reducer like sum, any, or max.

# GENERATOR expression: LAZY. Produces items on demand, O(1) memory.
# Use it as an argument to sum/any/all/max so you never build the list.
total = sum(n * n for n in nums)                 # no temporary list built
assert total == 55
gen = (n * n for n in nums)
assert next(gen) == 1                            # pulls one value lazily
When to use a generator
Reach for a generator expression for large or infinite streams, or any time you immediately feed the result into a reducer (sum, any, all, max, min). You get the readability of a comprehension with O(1) memory.

enumerate — index + value without manual counters

The anti-pattern is for i in range(len(xs)): x = xs[i]. Use enumerate instead — it yields (index, value) pairs, and start= sets the first index (handy for human-facing line numbers).

letters = ["a", "b", "c"]
out = []
for i, ch in enumerate(letters):                 # O(n)
    out.append((i, ch))
assert out == [(0, "a"), (1, "b"), (2, "c")]

# start=1 is handy for human-facing line numbers.
numbered = [f"{i}. {ch}" for i, ch in enumerate(letters, start=1)]
assert numbered == ["1. a", "2. b", "3. c"]

zip and zip(*) — pair sequences, and the transpose trick

zip pairs elements positionally and stops at the shortest input. The starred form zip(*matrix) transposes rows and columns in one line — a one-liner interviewers love.

names = ["alice", "bob", "carol"]
ages = [30, 25, 40]
paired = list(zip(names, ages))                  # O(n); stops at shortest
assert paired == [("alice", 30), ("bob", 25), ("carol", 40)]

# Build a dict directly from two parallel lists.
d = dict(zip(names, ages))
assert d == {"alice": 30, "bob": 25, "carol": 40}

# TRANSPOSE: zip(*rows) unpacks rows as separate args, re-zips by column.
matrix = [[1, 2, 3],
          [4, 5, 6]]
transposed = [list(col) for col in zip(*matrix)]  # O(rows*cols)
assert transposed == [[1, 4], [2, 5], [3, 6]]

Unpacking & starred assignment

Tuple unpacking destructures in one line. The starred target *rest absorbs the middle or the remainder, so you can grab head and tail cleanly. It works even when nothing is left over.

a, b = 1, 2
a, b = b, a                                      # swap with no temp var
assert (a, b) == (2, 1)

first, *middle, last = [10, 20, 30, 40, 50]
assert first == 10 and middle == [20, 30, 40] and last == 50

# *rest absorbs zero or more — works even when nothing is left.
head, *tail = [99]
assert head == 99 and tail == []

# Nested unpacking mirrors the structure of the data.
(name, (lo, hi)) = ("range", (1, 9))
assert name == "range" and lo == 1 and hi == 9

Slicing — start:stop:step, negatives, and reversal

The grammar is seq[start:stop:step]. stop is exclusive. Negative indices count from the end. Crucially, slicing never raises on out-of-range bounds — it clamps — unlike indexing.

xs = [0, 1, 2, 3, 4, 5]
assert xs[1:4] == [1, 2, 3]                      # [start, stop)
assert xs[:3] == [0, 1, 2]                       # omit start -> 0
assert xs[3:] == [3, 4, 5]                       # omit stop  -> len
assert xs[-2:] == [4, 5]                         # last two
assert xs[::2] == [0, 2, 4]                      # every other (step=2)
assert xs[::-1] == [5, 4, 3, 2, 1, 0]            # REVERSE: step=-1, O(n)
assert xs[10:20] == []                           # out-of-range -> clamped

# The reverse idiom works on strings too (strings are sequences).
assert "hello"[::-1] == "olleh"

# Slice assignment can splice into a list (mutates in place).
ys = [1, 2, 3, 4]
ys[1:3] = [9]                                    # replace middle with one
assert ys == [1, 9, 4]

sorted + key — tuple keys, reverse, and stability

sorted(iterable, key=fn, reverse=False) returns a new list; list.sort() mutates in place. The key maps each element to a comparison value computed once per element (the decorate-sort-undecorate pattern, done for you). Python's sort is stable: equal keys keep their original relative order — the foundation of multi-pass sorting.

words = ["banana", "apple", "fig", "cherry"]

# Sort by length; ties would keep input order (stability).
by_len = sorted(words, key=len)                  # O(n log n)
assert by_len == ["fig", "apple", "banana", "cherry"]

# TUPLE KEY = multi-level sort: primary by length, tie-break alphabetically.
by_len_then_alpha = sorted(words, key=lambda w: (len(w), w))
assert by_len_then_alpha == ["fig", "apple", "banana", "cherry"]

# Mixed direction: length DESC, then name ASC. Negate the numeric field to
# flip just that one (reverse= flips the whole comparison).
people = [("alice", 30), ("bob", 30), ("carol", 25)]
ranked = sorted(people, key=lambda p: (-p[1], p[0]))
assert ranked == [("alice", 30), ("bob", 30), ("carol", 25)]

Stability lets you build a multi-key sort by sorting by the secondary key first, then the primary: equal primaries preserve the secondary ordering from the earlier pass.

data = [("a", 2), ("b", 1), ("c", 2), ("d", 1)]
data = sorted(data, key=lambda t: t[0])          # pass 1: by letter
data = sorted(data, key=lambda t: t[1])          # pass 2: by number (stable)
assert data == [("b", 1), ("d", 1), ("a", 2), ("c", 2)]
Complexity
sorted / list.sort() is O(n log n) (Timsort) and stable. The key function is called exactly once per element, so an expensive key is computed n times, not n log n times.

Expression-level tools — f-strings, ternary, any/all, min/max(key), walrus

These are the small expressions that keep code on one line and readable. f-strings interpolate inline expressions, take format specs, and the = self-doc form prints both the name and value.

x, y = 3, 4
assert f"{x} + {y} = {x + y}" == "3 + 4 = 7"
assert f"{3.14159:.2f}" == "3.14"                # 2 decimal places
assert f"{x=}" == "x=3"                          # debug: prints name+value

# TERNARY: value-if-true if CONDITION else value-if-false.
n = 7
parity = "odd" if n % 2 else "even"
assert parity == "odd"

# any / all: short-circuiting boolean reducers over an iterable.
assert any(v > 4 for v in [1, 2, 5]) is True
assert all(v > 0 for v in [1, 2, 5]) is True
assert any([]) is False and all([]) is True      # vacuous truth edge cases

# min/max with key: find the extreme by a derived value, keep the element.
words = ["python", "is", "fun"]
assert max(words, key=len) == "python"
assert min(words, key=len) == "is"

The walrus operator := assigns inside an expression. It is ideal for "compute once, test, then reuse" — for example to avoid calling len() twice or re-reading input.

values = [1, 2, 3, 4]
if (total := sum(values)) > 5:                   # bind and test in one shot
    assert total == 10                           # reuse the bound value

Truthiness — what counts as False

Empty containers, 0, 0.0, "", None, and False are all "falsy". Everything else is truthy. This lets you write if items: instead of if len(items) > 0.

falsy = [0, 0.0, "", [], {}, set(), None, False]
assert all(not v for v in falsy)                 # every one is falsy
assert all([1, "x", [0], {"k": 1}])              # every one is truthy

# Idiomatic empty check.
items = []
assert (not items) is True

# `or` returns the first truthy operand — a default-value idiom.
name = "" or "anonymous"
assert name == "anonymous"
Gotcha
The x or default idiom treats 0, 0.0, and "" as "missing", which is sometimes wrong — a legitimate 0 or empty string will be silently replaced by the default. When you mean "if this key is absent", use dict.get(key, default) or an explicit is None check instead.

EAFP vs LBYL — two philosophies

LBYL ("look before you leap") tests preconditions first. EAFP ("easier to ask forgiveness than permission") just tries and catches the failure. Python culture favours EAFP — it avoids race conditions and double-lookups, and is faster on the happy path (exceptions are cheap unless raised).

d = {"a": 1}

# LBYL: check, then access. Reads the key twice; can race if shared/mutated.
val_lbyl = d["a"] if "a" in d else 0

# EAFP: try the access, handle the miss. One lookup, no race window.
try:
    val_eafp = d["a"]
except KeyError:
    val_eafp = 0

assert val_lbyl == val_eafp == 1

For dicts specifically, d.get("a", 0) beats both — but the EAFP philosophy matters for file, network, and attribute access where there is no .get() equivalent.

The classic gotchas — each shown broken then fixed

Mutable default argument. A mutable default ([], {}) is created once at def-time and shared across all calls, so it accumulates state between calls. Default to None and create a fresh object in the body.

# --- broken ---
def append_bad(item, bucket=[]):                 # the [] is shared!
    bucket.append(item)
    return bucket

assert append_bad(1) == [1]
assert append_bad(2) == [1, 2]                   # surprise: 1 leaked in

# --- fixed ---
def append_good(item, bucket=None):
    if bucket is None:                           # fresh list every call
        bucket = []
    bucket.append(item)
    return bucket

assert append_good(1) == [1]
assert append_good(2) == [2]                     # independent, as expected

Late-binding closure. Closures capture the variable, not its value. All lambdas made in a loop see the loop variable's final value. Bind the current value with a default argument, which is evaluated at def-time.

# --- broken ---
funcs_bad = [lambda: i for i in range(3)]
assert [f() for f in funcs_bad] == [2, 2, 2]     # all see final i == 2

# --- fixed ---
funcs_good = [lambda i=i: i for i in range(3)]   # capture value now
assert [f() for f in funcs_good] == [0, 1, 2]

is vs ==. == compares values; is compares identity (same object in memory). Use is only for the singletons None, True, False.

a = [1, 2]
b = [1, 2]
assert (a == b) is True                          # equal values
assert (a is b) is False                         # different objects

# Always use `is None`, never `== None`.
x = None
assert (x is None) is True
Gotcha
CPython interns small integers (roughly −5..256) and short strings, so is happens to return True for them — which lures people into using is for numbers. Outside that range, identical values are distinct objects and is is False. It is an implementation detail: 256 is 256 may be True while 257 is 257 is unspecified. Compare numbers with ==, always.

Shallow vs deep copy. A shallow copy duplicates the outer container but shares the inner objects, so mutating a nested object shows up in both copies. copy.deepcopy() recursively clones everything.

import copy
original = [[1, 2], [3, 4]]

# --- shallow: inner lists are shared references ---
shallow = copy.copy(original)                    # or original[:] / list(original)
shallow[0].append(99)
assert original[0] == [1, 2, 99]                 # mutation leaked back!

# --- deep: fully independent ---
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
deep[0].append(99)
assert original[0] == [1, 2]                     # untouched

Modifying a list while iterating it. Removing items while iterating skips elements, because the internal index keeps advancing as the list shrinks. Iterate a copy, or — better — rebuild a filtered list.

# --- broken: mutating the SAME list you iterate skips neighbors ---
nums = [1, 2, 2, 3]
for n in nums:                                   # iterate the live list (bug)
    if n == 2:
        nums.remove(n)
assert nums == [1, 2, 3]                         # one 2 survived — wrong

# --- fix: iterate a SNAPSHOT copy so removals don't shift the cursor ---
nums = [1, 2, 2, 3]
for n in list(nums):                             # iterate a copy
    if n == 2:
        nums.remove(n)
assert nums == [1, 3]                            # correct

# --- best: rebuild instead of mutate-in-place ---
nums2 = [1, 2, 3, 4, 5, 6]
nums2 = [n for n in nums2 if n % 2]              # keep odds; clear intent
assert nums2 == [1, 3, 5]
Self-check
Every routine in this lesson is asserted in the runnable source. The pattern throughout is the same: prefer the declarative, single-expression form, and treat the six gotchas above as a checklist you mentally scan before submitting.