Study Notes
Notefoundation

The Everyday Python Toolkit

The small, sharp tools experienced Python programmers reach for constantly — introspection (getattr / isinstance), * and ** unpacking, lambdas, the walrus, comprehensions, decorators, lru_cache & partial, dataclasses, ABCs vs Protocols, asyncio + httpx, and tenacity retries. Each with the gotcha that bites and the interview answer.

45 min read·verified Python 3.12·reviewed 2026-08-30

After this you should be able to

  • Choose the right everyday Python tool
  • Explain the trade-off behind a concise implementation
Decoratorsasyncio + httpxdataclasses
Python Study Reference

A ground-up lesson on the Python features and libraries you keep bumping into: introspection, unpacking, protocols and dataclasses, decorators, caching, concurrent HTTP, and resilient calls. Built for real code and interview answers alike.

Section 1
Notebook Summary

Hand-copyable, telegraphic. Margin markers: ⭐ trick · ⚠ pitfall · 💡 tip. Everything here is derived from the Detailed Notes below.

Introspection#

getattr(obj, "x", default) → attr by name string; default avoids AttributeError setattr(o,"x",v) / hasattr(o,"x") → set / test dynamically ⭐dispatch by name: getattr(self,"do_"+cmd)() instead of if/elif chain ⚠hasattr swallows all errors as False (Py3) → hides bugs in properties

Type checks#

isinstance(x, T) → True if x is T or subclass; tuple T = any-of ⚠type(x)==T ≠ isinstance → rejects subclasses; prefer isinstance 💡bool is subclass of int → isinstance(True,int)==True doodle: ladder of classes; isinstance = "on this rung or below"

lambda#

lambda a,b: expr → tiny anon fn, single expression only use as key= in sorted/max/min ⚠late binding: [lambda: i for i in ...] all see final i → use i=i

Unpacking * / **#

*iterable → spread into list/call; **dict → spread into kwargs [*a,*b] merge · {**d1,**d2} merge (right wins) a,*rest = seq → capture middle/tail doodle: box tipped over, items pouring into a bigger box

Ellipsis ...#

... = the Ellipsis object; a real value, not syntax stub body: def f(): ... · type stubs · Callable[..., int] ⚠... ≠ pass in behavior only by convention; both no-op in a body

Function signatures#

*args → extra positionals as tuple; **kwargs → extra keywords as dict bare * → everything after is keyword-only / → everything before is positional-only ⚠mutable default def f(x=[]) → shared across calls; use None

Walrus :=#

:= assign inside an expression while (line:=f.readline()): · [y for x in xs if (y:=g(x))] 💡avoids calling g(x) twice in a comprehension

Comprehensions#

[f(x) for x in xs if cond] list · { } set/dict · ( ) generator gen exp = lazy, one-pass, memory-cheap ⚠nested order = same as nested for-loops, left→right

Decorators#

@deco = f = deco(f); wraps a fn to add behavior pattern: def deco(f): @wraps(f) def w(*a,**k): ...; return w ⭐@wraps(f) keeps name/docstring → don't skip it deco with args = deco factory (3 nested fns) doodle: gift box (fn) → wrapping paper (wrapper) → same box inside

Caching & partial#

@lru_cache(maxsize=N) → memoize by args; @cache = unbounded (3.9+) ⚠args must be hashable (no list/dict); cache holds refs → leaks partial(f, a) → freeze some args → new callable

dataclasses#

@dataclass → auto __init__/__repr__/__eq__ from annotations field(default_factory=list) for mutable defaults frozen=True → immutable + hashable; slots=True → less memory ⚠plain x: list = [] class attr = shared → use default_factory

ABC vs Protocol#

ABC = nominal: must inherit + implement abstractmethods Protocol = structural: "has these methods" → no inheritance needed table: ABC = "is-a by declaration" · Protocol = "acts-like duck typing" 💡@runtime_checkable Protocol → isinstance checks methods exist

asyncio + httpx#

async def → coroutine; await yields control while waiting asyncio.run(main()) starts loop; gather(*tasks) = concurrent ⭐concurrency ≠ parallelism: 1 thread, overlaps waiting (I/O) ⚠one blocking call (time.sleep, requests) freezes the whole loop httpx: async with AsyncClient() as c: await c.get(url) · reuse client doodle: 1 waiter taking many orders while kitchen cooks

tenacity retries#

@retry(...) → auto re-run on failure stop_after_attempt(n) · wait_exponential() · retry_if_exception_type() ⭐exponential + jitter → avoids thundering-herd retry storms ⚠only retry transient errors (timeouts/5xx), not 4xx/ValueError
Section 2
Detailed Study Reference

Ground-up teaching notes. Each concept: plain definition → intuition/analogy → real use → figure where it helps → why it matters.

01The Hook#

You are reading someone else's Python and you hit this: a function that calls getattr(self, "do_" + command)(), decorated with @retry, that awaits an httpx client inside an async def, and takes *args, **kwargs. None of it is advanced computer science. It is a dozen small, sharp tools that experienced Python programmers reach for constantly, and that tutorials rarely explain together.

The gap is not knowing what each does in isolation, it is knowing why you'd choose it and where it bites you. Miss the mutable-default trap or the async blocking trap and your code works in the demo and fails in production. This guide stitches these tools into one mental model: Python lets you treat code, arguments, and types as data you can inspect, reshape, wrap, and defer. Everything below is a variation on that theme.

02Introspection: getattr, setattr, hasattr#

Ask an object about its attributes using a name written as a string, at runtime, instead of hard-coding the dot access.

Normally you write obj.color. But what if the attribute name only becomes known while the program runs, for example it came from a config file, a web request, or a user command? You cannot write obj. followed by a variable. That is what these three built-ins are for, the "spread-the-load box" of attribute access, they turn the name into an ordinary value you can compute.

Analogy. A hotel front desk. obj.room101 is walking straight to a room you already know. getattr(hotel, room_number) is handing the clerk a slip of paper with the room number on it, the number can be anything, decided a second ago.
# getattr(object, name[, default])
color = getattr(car, "color")              # same as car.color
color = getattr(car, "color", "black")     # default if missing, no crash

# the name is computed, that is the whole point
field = "do_" + command
getattr(self, field)()                    # call self.do_start(), self.do_stop()...

setattr(car, "color", "red")             # car.color = "red", name as string
if hasattr(car, "turbo"): ...            # True/False, does the attr exist?
⭐ Trick — dispatch tables without the if/elif ladder A long if cmd == "start": ... elif cmd == "stop": ... chain collapses into one line: name your methods do_<command> and call getattr(self, "do_" + cmd, self.unknown)(). This is exactly how Python's own cmd and http.server modules route commands. It scales without editing the dispatcher.
command = "stop" "do_" + cmd getattr(self, ·) self.do_start self.do_stop ◄ picked self.do_pause one dispatcher, methods added freely, no elif chain to edit
String-named attribute lookup routes a command to the right method.
⚠ Pitfall — hasattr hides real bugs In Python 3, hasattr returns False for any exception raised while accessing the attribute, not just AttributeError. If a @property throws a KeyError because of a genuine bug, hasattr quietly reports "no such attribute" and you chase the wrong problem. Prefer getattr(obj, name, sentinel) and compare, or catch the specific error.
Why it matters Interviewers use "how would you route a variable command to a handler?" to see if you know introspection exists. The strong answer is a dispatch dict or getattr, not a giant conditional. Bonus points for mentioning that getattr with a default is safer and faster to read than try/except AttributeError.

03isinstance & honest type checks#

Ask "is this value one of these types (or a subtype)?" and get back True or False.

The "is-this-the-right-kind-of-thing check" (a runtime type test) answers whether a value belongs to a class or any of its subclasses. Pass a tuple of types to mean "any of these".

Analogy. A club's ID check. isinstance asks "are you a member, or a member of any affiliated club?" — subclasses count. type(x) == T is the bouncer who only admits people holding this exact card and turns away affiliated members.
if isinstance(x, int): ...
if isinstance(x, (int, float)): ...     # any-of: int OR float

# why not type()?
class Admin(User): ...
a = Admin()
isinstance(a, User)     # True  — an Admin IS a User
type(a) == User        # False — exact match only, ignores inheritance
object User Admin (a) isinstance(a, User) = True (below) type(a)==User = False (not exact)
isinstance accepts anything on this rung or below it; type() demands the exact rung.
💡 Tip — bool is an int isinstance(True, int) is True because bool subclasses int. So a function guarded by isinstance(x, int) will happily accept True as the number 1. If you must reject booleans, check type(x) is int or add and not isinstance(x, bool). Classic interview gotcha.
⚠ Pitfall — overusing isinstance defeats duck typing Python prefers "if it walks like a duck". Littering code with isinstance branches to decide behavior often means you should be using polymorphism, a Protocol (§13), or functools.singledispatch. Reserve isinstance for boundary validation and genuine either-or type handling.

04lambda#

A throwaway, unnamed function written in one line, holding a single expression.

A "one-line unnamed function" (a lambda) is just a function you don't bother to name because you'll use it once, right where you define it. Its body is a single expression whose value is returned automatically, no return, no statements.

Analogy. A sticky note versus a printed form. A def is a filed, titled document you reference by name. A lambda is a sticky note you scribble, use immediately, and throw away.
sorted(people, key=lambda p: p.age)          # sort by age
max(words, key=lambda w: len(w))            # longest word
add = lambda a, b: a + b                    # works, but a def is clearer
⚠ Pitfall — late binding in loops funcs = [lambda: i for i in range(3)] gives three functions that all return 2. Lambdas capture the variable i, not its value at creation time, and by the time you call them the loop has finished with i == 2. Fix by binding a default: lambda i=i: i. This trips up nearly everyone once.
Why it matters PEP 8 discourages assigning a lambda to a name (use def then). The idiomatic home for a lambda is an inline key=, a small callback, or a default factory. If your lambda needs a conditional or spans logic, it wants to be a real function.

05The * and ** spread operators#

Pour the contents of a collection into another place: * spreads a sequence's items, ** spreads a dict's key/value pairs.

You have a box of things and want its contents, not the box, poured into a function call, a new list, or a new dict. That is unpacking, the "tip-the-box-out" operator. * works on any iterable (list, tuple, set, generator); ** works on mappings (dicts) and spreads them as keyword arguments.

Analogy. Emptying grocery bags onto the counter. [*bag1, *bag2] is dumping two bags into one pile. f(*items) is handing each item over individually rather than passing the whole bag.
pair = (3, 4)
point(*pair)                 # point(3, 4) — items become positional args

merged = [*a, *b, *c]        # flatten several iterables into one list
combined = {**base, **overrides}   # merge dicts; right side wins on clashes

first, *rest = [1,2,3,4]     # first=1, rest=[2,3,4] — capture the tail
*init, last = [1,2,3,4]      # init=[1,2,3], last=4

defaults = {"timeout": 5}
requests.get(url, **defaults)     # same as requests.get(url, timeout=5)
pair = (3, 4) ( 3 , 4 ) *pair point( 3 , 4 ) the tuple's items arrive as separate positional arguments
One star spreads a sequence's items into individual arguments.
💡 Tip — merge is the modern dict update {**a, **b} is the clean way to merge dicts into a new one (later keys win). Since Python 3.9 you can also write a | b. Both leave the originals untouched, unlike a.update(b) which mutates a in place.

06The ... (Ellipsis)#

Three dots are a real, singleton object called Ellipsis, not special syntax, most often used as a "nothing here yet / anything goes" placeholder.

Newcomers assume ... is magic. It is simply a built-in constant, like None or True, that evaluates to the one Ellipsis object. Because it reads as "to be filled in," Python culture uses it as a tidy stand-in in a few specific places.

Analogy. The "…" in a screenplay stage direction, a marker that says "something belongs here" without committing to what. It's a placeholder that is itself a concrete token on the page.
def fetch(url: str) -> bytes: ...     # stub body — "implement later", like pass

class Reader(Protocol):
    def read(self, n: int) -> bytes: ...   # interface declaration, no logic

Callable[..., int]        # typing: "any arguments" -> int
arr[..., 0]              # NumPy: "all remaining axes" then index 0
⚠ Pitfall — ... does not mean "skip" In a function body, ... and pass both do nothing, but ... is by convention "intentional stub / defined elsewhere" (common in .pyi type stubs), while pass means "deliberately empty". Don't read ... as "the rest is omitted from view", it is not a code-folding marker, it evaluates to the actual Ellipsis object.
Why it matters In type stubs and Protocol definitions you'll see ... everywhere; recognizing it as "no implementation here, just the shape" prevents confusion. And knowing it's a genuine object explains why x = ... is valid and why libraries (NumPy, typing) can give it special meaning.

07Flexible signatures: *args, **kwargs, and the bare * / /#

Let a function accept an unknown number of extra positional arguments (*args) or extra keyword arguments (**kwargs), and control how callers are allowed to pass them.

Inside a definition, the stars do the reverse of §5: instead of spreading a collection out, they gather leftover arguments up. *args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. This is what makes wrappers and decorators able to accept "whatever the real function takes".

Analogy. A catch-all inbox. Named parameters are labeled folders; *args and **kwargs are the "everything else" tray that scoops up whatever didn't have a folder.
def log_call(*args, **kwargs):
    print("positional:", args)     # a tuple
    print("keyword:", kwargs)      # a dict

log_call(1, 2, mode="fast")
# positional: (1, 2)   keyword: {'mode': 'fast'}

# control the calling convention:
def connect(host, /, port, *, timeout):
    ...
# host  : positional-only  (before /)
# port  : either way       (between / and *)
# timeout: keyword-only    (after *)  -> connect("db", 5432, timeout=3)
host positional-only / port either way * timeout keyword-only / closes positional-only region · * opens keyword-only region
The / and bare * markers partition a signature into three calling zones.
⚠ Pitfall — the mutable default argument def add(item, bag=[]): looks innocent but the list is created once, when the function is defined, and shared by every call that relies on the default. Call it twice and the second call sees the first call's items. Always use bag=None then bag = bag or [] (or if bag is None:) inside. This is the single most-asked Python gotcha in interviews.
⭐ Trick — forwarding with *args, **kwargs A wrapper that must call the original function with exactly the arguments it received writes def wrapper(*args, **kwargs): return func(*args, **kwargs). The stars gather on the way in and spread on the way out, so the wrapper is signature-agnostic. This is the backbone of every decorator (§10).

08The walrus operator := +#

Assign a value to a name and use that value in the same expression, in one step.

Normal assignment (x = 5) is a statement, it can't sit inside an if or a while condition. The walrus operator (named for its := "eyes and tusks" look, introduced in Python 3.8) is an expression that both assigns and yields the value, so you can capture a result exactly where you test it.

Analogy. Signing for a package while reading it at the same time. Instead of "receive it, then check it" as two steps, you check-and-keep in one motion.
# before: compute twice, or an extra line
n = len(data)
if n > 10: print(n)

# after: capture inside the test
if (n := len(data)) > 10:
    print(n)

while (chunk := f.read(1024)):     # read until empty, no priming read
    process(chunk)

# star use: avoid calling g(x) twice in a comprehension
results = [y for x in xs if (y := g(x)) is not None]
💡 Tip — its best use is the comprehension filter Without the walrus, filtering a comprehension on a computed value forces you to compute it twice: once in the if and once in the output. [y for x in xs if (y := g(x))] computes g(x) once and reuses it. That is the case where it clearly beats the alternatives, elsewhere use it sparingly to keep expressions readable.

09Comprehensions & generator expressions +#

Build a list, set, dict, or lazy stream in one expression by describing what you want, not looping and appending.

A comprehension is a compact "for-each that produces a collection". The brackets decide the type: [] list, {} set or dict, () a generator (a lazy, one-item-at-a-time stream that never builds the whole thing in memory).

Analogy. A list comprehension is a factory that runs the whole batch and hands you a full crate. A generator expression is a vending machine, it makes each item only when you press the button, and forgets it after.
squares  = [x*x for x in nums]                 # list
uniq     = {w.lower() for w in words}          # set
by_id    = {u.id: u for u in users}           # dict
stream   = (x*x for x in nums)                 # generator — lazy, no list built

total = sum(x*x for x in big_file)          # memory-cheap over huge input

grid = [(r, c) for r in rows for c in cols]  # nested = same order as nested loops
⭐ Trick — use a generator when you only iterate once If you're going to feed the result straight into sum, any, max, or a for loop, use ( ) not [ ]. The generator never allocates the full list, so it handles multi-gigabyte inputs in constant memory. Switching one pair of brackets can turn a memory crash into a clean stream.
⚠ Pitfall — nested comprehension order & one-shot generators In [... for a in A for b in B] the loops read left-to-right, same as if you nested them, people often reverse it mentally. Also, a generator is exhausted after one pass: iterate it twice and the second time yields nothing.

10Decorators +#

A function that takes another function and returns a new one with extra behavior wrapped around it, applied with the @ line above a definition.

A decorator is "wrapping paper for a function". @timing above def work(): is pure shorthand for work = timing(work). The decorator receives the original function, builds a replacement that does something before/after (log, time, cache, retry, check permissions), and hands that replacement back under the same name.

Analogy. Gift wrapping. The present (your function) is unchanged inside, but now it has a bow and a label (logging, timing). Whoever picks it up interacts with the wrapping first, then the gift.
from functools import wraps

def timing(func):
    @wraps(func)                       # keep func's name/docstring — don't skip!
    def wrapper(*args, **kwargs):     # accept ANY signature (§7)
        start = time.perf_counter()
        result = func(*args, **kwargs)  # call the real function
        print(f"{func.__name__} took {time.perf_counter()-start:.3f}s")
        return result
    return wrapper

@timing
def work(): ...
# work = timing(work)
work @timing wrapper start timer work() print elapsed work (now wrapped)
The name work is rebound to a wrapper that sandwiches the original call.

Decorators that take arguments#

To write @retry(times=3) you need one more layer: an outer function that takes the arguments and returns a real decorator. Three nested functions, argument layer → decorator layer → wrapper layer.

def retry(times):                 # 1) takes the arg
    def decorator(func):           # 2) the real decorator
        @wraps(func)
        def wrapper(*a, **k):      # 3) the replacement
            for _ in range(times):
                try: return func(*a, **k)
                except Exception: pass
        return wrapper
    return decorator

@retry(times=3)
def flaky(): ...
⚠ Pitfall — forgetting @wraps Without @wraps(func), the decorated function reports the wrapper's name (wrapper) and loses its docstring and signature. This breaks help text, debuggers, and anything that introspects the function. It's the most common decorator mistake, and an easy point to lose in an interview. @wraps copies the metadata across.
Why it matters Decorators are how Python does cross-cutting concerns, the same wrapper (auth, caching, timing, retries) applied to many functions without editing their bodies. @lru_cache, @dataclass, @retry, framework routes like @app.get, they're all this one pattern. Understanding the "function returns a function" chain is the key to reading modern Python.

11Caching (lru_cache / cache) & partial #

@lru_cache remembers a function's results keyed by its arguments so repeat calls are instant; partial pre-fills some arguments to make a new, simpler function.

These both live in functools. Caching (memoization) is "don't recompute what you already computed", the decorator stores a mapping from arguments to result. partial is "freeze a couple of arguments now, supply the rest later".

Analogy. Caching is a barista who writes your usual order on a card, next time you just say "the usual" and skip re-deciding. partial is a coffee machine with your milk and sugar pre-set, you only press "go".
from functools import lru_cache, cache, partial

@lru_cache(maxsize=128)          # keep 128 most-recent results
def fib(n):
    return n if n < 2 else fib(n-1) + fib(n-2)

@cache                          # Py3.9+, unbounded lru_cache(maxsize=None)
def settings(): ...

fib.cache_info()                # hits / misses / current size — inspect it!
fib.cache_clear()               # wipe the cache

# partial: freeze arguments
basetwo = partial(int, base=2)   # a new fn: parse binary strings
basetwo("1010")                  # 10
⚠ Pitfall — unhashable args & silent memory growth @lru_cache keys on the arguments, so they must be hashable: passing a list or dict raises TypeError. And @cache / maxsize=None never evicts, it holds every result (and references to those objects) forever, which quietly leaks memory in long-running services. Bound the size, or clear it.
💡 Tip — cache turns exponential into linear Naive recursive Fibonacci is exponential because it recomputes the same subproblems. One @lru_cache line makes each n compute once, dropping it to linear time, a favorite interview demonstration of memoization. Just remember the cache persists across calls, which is usually what you want but occasionally surprising in tests.

12dataclasses +#

A decorator that writes the boilerplate (__init__, __repr__, __eq__) for a "just holds data" class straight from its type annotations.

Most small classes exist only to bundle a few fields. Writing the constructor, the repr, and equality by hand is tedious and error-prone. @dataclass reads your annotated fields and generates all of it, you just declare the shape.

Analogy. A form template. You list the field labels once (name, age, email) and the system auto-builds the blank form, the "print this record" view, and the "are these two records identical?" check.
from dataclasses import dataclass, field

@dataclass
class Point:
    x: int
    y: int = 0                       # default value

p = Point(3, 4)
print(p)          # Point(x=3, y=4)   <- free __repr__
Point(3,4) == Point(3,4)   # True             <- free __eq__

@dataclass(frozen=True, slots=True)   # immutable + hashable + memory-lean
class Config:
    name: str
    tags: list = field(default_factory=list)   # mutable default, done right
⚠ Pitfall — mutable class-level defaults You cannot write tags: list = [] in a dataclass, it would share one list across all instances (the §7 trap again). The dataclass machinery actually raises an error for this and directs you to field(default_factory=list), which creates a fresh list per instance. Same rule for dicts and sets.
💡 Tip — frozen + slots are the "value object" combo frozen=True makes instances immutable and automatically hashable (usable as dict keys / set members); slots=True skips the per-instance __dict__ for lower memory and faster attribute access. Together they give you an efficient, safe value object, a strong thing to mention when asked "how would you model an immutable record?"
Why it matters Interviewers ask "how is a dataclass different from a NamedTuple or a dict?" Answer: dataclasses are mutable-by-default real classes with methods and inheritance, generated boilerplate, and optional immutability; NamedTuples are immutable and tuple-compatible; dicts have no fixed shape or type hints. Knowing the trade-offs shows design maturity.

13ABCs & Protocols +#

Two ways to define "what a type must be able to do." An ABC enforces it by inheritance; a Protocol enforces it by shape (which methods exist).

Sometimes you want to say "anything I accept here must have a .read() method." There are two philosophies. An Abstract Base Class (ABC) is nominal, a class only counts if it explicitly inherits from the ABC and implements its abstract methods. A Protocol is structural, a class counts if it merely has the right methods, no inheritance or declaration required. This is duck typing made checkable.

Analogy. Getting into a members' event. An ABC is a guest list, your name must be written down in advance. A Protocol is a dress code, anyone wearing the right outfit gets in, regardless of who they are.
from abc import ABC, abstractmethod

class Shape(ABC):                 # nominal: subclasses MUST inherit
    @abstractmethod
    def area(self) -> float: ...

class Circle(Shape):              # must implement area() or can't instantiate
    def area(self): return 3.14 * self.r**2

from typing import Protocol, runtime_checkable

@runtime_checkable
class Readable(Protocol):        # structural: no inheritance needed
    def read(self, n: int) -> bytes: ...

def load(src: Readable): ...     # accepts ANY object that has .read()
ABC — nominal Shape (ABC) must inherit Circle Protocol — structural Readable File Socket no line drawn — just needs .read()
ABC demands a declared inheritance line; Protocol matches any object of the right shape.
AspectABCProtocol
ConformanceBy inheritance (declared)By structure (has methods)
CouplingClass knows the baseClass knows nothing
Best forYour own hierarchy, shared base logicAccepting third-party / duck-typed objects
Enforced whenInstantiation (can't create if abstract)Type-check time; runtime only if @runtime_checkable
💡 Tip — runtime_checkable only checks names, not signatures A @runtime_checkable Protocol lets isinstance(x, Readable) work, but it only verifies the method exists, not that its arguments or return type match. So it can pass for an object whose read takes different arguments. Use Protocols mainly for static type-checking (mypy/pyright); treat runtime checks as a rough guard.

14asyncio & httpx#

A way to run many I/O-bound tasks that spend most of their time waiting (network, disk) on a single thread, by letting each one hand control back to a scheduler whenever it would otherwise block.

Most of the time a program spends waiting on a web request, it is doing nothing, just waiting for bytes. asyncio exploits that: while one request waits, the event loop runs another. async def marks a function as a coroutine (a pausable function); await is the point where it says "I'm about to wait, let someone else run". httpx is a modern HTTP client that speaks this async language (and a sync one too).

Analogy. One waiter, many tables. A blocking program is a waiter who stands frozen at table 1 until the kitchen finishes that dish. An async waiter takes table 1's order, and while the kitchen cooks, takes tables 2, 3, and 4. One person, but no idle standing, that's concurrency, not parallelism.
import asyncio, httpx

async def fetch(client, url):
    r = await client.get(url)         # await = "pause me while this I/O runs"
    return r.status_code

async def main(urls):
    async with httpx.AsyncClient() as client:   # reuse ONE client (pools connections)
        tasks = [fetch(client, u) for u in urls]
        return await asyncio.gather(*tasks)     # run them concurrently, collect results

asyncio.run(main(urls))               # entry point: starts & stops the event loop
Sequential (blocking) req A wait req B wait req C wait total = A+B+C Concurrent (async) A wait B wait waits overlap → total ≈ the longest single request
Async overlaps the idle waiting of independent I/O; wall-clock time collapses toward the slowest single call.
⭐ Trick — gather is where the speedup actually happens Writing await fetch(a); await fetch(b) is still sequential, you awaited A fully before starting B. The concurrency comes from launching them together: await asyncio.gather(fetch(a), fetch(b)). Ten 200 ms requests take ~200 ms total instead of 2 s. This "make tasks first, gather second" shape is the single most important async pattern to internalize.
⚠ Pitfall — one blocking call freezes the whole loop Async runs on a single thread, so a synchronous blocking call, time.sleep(), the requests library, a heavy CPU loop, stops every other task, not just the current one. Use await asyncio.sleep(), an async client like httpx.AsyncClient, and push CPU-bound work to asyncio.to_thread() or a process pool. Mixing sync blocking into async is the number-one async bug.
Why it matters The classic interview question is "concurrency vs parallelism". Async gives concurrency (interleaving tasks on one thread, ideal for I/O-bound work like many HTTP calls); it does not use multiple CPU cores, so it's the wrong tool for CPU-bound work (use multiprocessing there). Also know why httpx is preferred over requests for async: requests is synchronous and would block the loop, while httpx offers a true async client with connection pooling.

15tenacity: retries done right#

A library that automatically re-runs a function when it fails, with configurable limits, waits, and conditions, so you don't hand-roll retry loops.

Networks blip. A request that fails once often succeeds a moment later. Instead of writing for _ in range(3): try/except everywhere, tenacity gives you a @retry decorator (§10 in action) whose behavior you assemble from small policy pieces: how many attempts, how long to wait between them, and which errors are worth retrying.

Analogy. Redialing a busy phone line. You don't hammer redial instantly forever, you wait a bit longer each time (so you don't jam the line), you give up after a while, and you only redial for "busy", not for "wrong number".
from tenacity import (retry, stop_after_attempt,
                      wait_exponential, retry_if_exception_type)
import httpx

@retry(
    stop=stop_after_attempt(5),                        # give up after 5 tries
    wait=wait_exponential(multiplier=1, max=10),        # 1s,2s,4s,8s,10s...
    retry=retry_if_exception_type(httpx.TransportError),  # only transient errors
    reraise=True,                                    # raise the real error, not RetryError
)
async def get(client, url):
    r = await client.get(url)
    r.raise_for_status()
    return r.json()
time → (wait grows each attempt: 1s, 2s, 4s, 8s) try 1 ✗ try 2 ✗ try 3 ✗ try 4 ✓
Exponential backoff spaces retries further apart, easing pressure on a recovering service.
⭐ Trick — exponential backoff + jitter beats fixed delays If a service goes down and 1,000 clients all retry every 1 second, they hammer it in synchronized waves the moment it recovers (a "thundering herd"). Exponential backoff spreads retries out over time, and adding random jitter (wait_random_exponential) desynchronizes clients so they don't all hit at once. This is the difference between helping a service recover and re-crashing it.
⚠ Pitfall — retrying the wrong errors Retrying is only correct for transient failures: timeouts, connection resets, HTTP 5xx / 429. Retrying a 400 Bad Request, a 404, a ValueError, or an auth failure just repeats a guaranteed failure and wastes time, or worse, re-sends a non-idempotent action (a duplicate payment). Always scope retry_if_exception_type to errors that can plausibly succeed on a retry, and be careful retrying non-idempotent POSTs.
💡 Tip — combine with async cleanly tenacity's @retry works on both sync and async def functions transparently, and it composes with the httpx.AsyncClient pattern from §14. A resilient production HTTP call is usually: one reused async client, a @retry with exponential backoff scoped to transport/5xx errors, plus a per-request timeout. Mentioning that full stack signals real-world experience.

16Interview & Practice Q&A#

The follow-ups this material naturally invites. Read each answer aloud in 2–4 sentences.

Why prefer isinstance(x, T) over type(x) == T?
isinstance respects inheritance, it returns True for subclasses, which is almost always what you want (an Admin should count as a User). type(x) == T demands an exact class match and silently rejects subclasses. Use exact type comparison only in the rare case where subclasses genuinely must be excluded, and watch the bool-is-an-int edge case.
Explain the mutable default argument trap and how to fix it.
Default arguments are evaluated once when the function is defined, not on each call, so a default like def f(x=[]) creates a single list shared by every call that uses the default, causing state to leak between calls. The fix is to default to None and create the mutable object inside the body: def f(x=None): x = [] if x is None else x. Dataclasses enforce the same rule via field(default_factory=list).
What does @wraps do and why does it matter?
functools.wraps copies the wrapped function's metadata, __name__, __doc__, signature, onto the wrapper the decorator returns. Without it, the decorated function reports the wrapper's identity, breaking help text, logging, debuggers, and introspection tools. It's a one-line fix that every well-written decorator includes.
Concurrency vs parallelism, and where does asyncio fit?
Concurrency is interleaving multiple tasks so their waiting overlaps; parallelism is literally executing on multiple CPU cores at once. asyncio provides concurrency on a single thread, ideal for I/O-bound work like many network calls, but it does not parallelize CPU work. For CPU-bound workloads you need multiprocessing (or threads for releasing-the-GIL C extensions), not asyncio.
Why can a single blocking call ruin an async program?
The event loop runs on one thread and only switches tasks at await points. A synchronous blocking call, time.sleep, requests.get, a tight CPU loop, never yields control, so it stalls the entire loop and every other task waits behind it. Use async-native equivalents (asyncio.sleep, httpx.AsyncClient) or offload blocking work with asyncio.to_thread.
When should you NOT use @lru_cache?
Avoid it when arguments are unhashable (lists, dicts, arrays), because caching will raise TypeError; when the function has side effects or must return fresh results each call; and for unbounded caches in long-running processes, where holding every result forever leaks memory. If arguments have huge cardinality, the cache brings little benefit and much cost, bound maxsize and consider cache_clear().
ABC vs Protocol, when do you reach for each?
Use an ABC when you own a class hierarchy and want to enforce that subclasses implement certain methods, with the option to share base logic, conformance is by explicit inheritance. Use a Protocol for structural (duck) typing when you want to accept any object that simply has the right methods, including third-party classes you can't modify. Protocols shine with static type checkers; ABCs enforce at instantiation.
What makes a good retry policy?
Cap the number of attempts, use exponential backoff with jitter to avoid synchronized retry storms, and restrict retries to transient errors (timeouts, connection errors, 5xx/429) rather than deterministic failures like 4xx or validation errors. Be especially careful retrying non-idempotent operations (like payments), since a retried POST can duplicate the effect, add idempotency keys or only retry idempotent calls.
What's the difference between *args at a call site versus in a definition?
In a definition, *args gathers extra positional arguments into a tuple (and **kwargs gathers extra keywords into a dict). At a call site, *seq spreads a sequence into separate positional arguments (and **d spreads a dict into keyword arguments). Same symbols, opposite directions, gather when defining, spread when calling, which is exactly why decorators use def wrapper(*a, **k): func(*a, **k).

17Key Takeaways#

  1. One theme underlies most of this: Python lets you treat names, arguments, functions, and types as data you can inspect (getattr/isinstance), reshape (*/**), wrap (decorators), and defer (..., lambda, async).
  2. The stars are direction-sensitive: in a definition they gather leftover arguments; at a call site they spread a collection out. Decorators exploit both at once.
  3. The two biggest gotchas are the mutable default argument and lambda/loop late binding. Both come from "evaluated once / captured by reference" and both have clean fixes (None sentinel; i=i default).
  4. Decorators are one pattern, function-returns-function, and everything from @lru_cache to @dataclass to @retry is a variation. Always keep @wraps.
  5. Choose your "contract" tool by philosophy: ABC for declared inheritance, Protocol for structural duck typing, dataclass for pure data holders.
  6. Async is concurrency, not parallelism: it overlaps I/O waiting on one thread. gather is where the speedup lives; a single blocking call ruins it; httpx.AsyncClient is the async-native HTTP client.
  7. Resilience is a policy, not a loop: tenacity gives you bounded attempts, exponential backoff with jitter, and error-scoped retries, only for transient, idempotent failures.
Self-contained study reference · theme-aware · + marks content added beyond the requested topic list · No source conflicts to flag.