00Overview 01Python semantics 02Python runtime 03NumPy 04pandas 05Operating systems 06Networking 07Resume project 0812-interview plan
01 · Python semantics

The object model,
not the syntax.

You write Python every day, so this page is verification rather than learning. The goal is that every rule below stops being a rule you remember and becomes something you can rebuild on the spot from one fact about how objects are laid out in memory.

Mechanism

Why a list cannot be a dict key

The short answer people give is "because lists are mutable". That is true but it is not the mechanism, and an interviewer who is paying attention will ask the follow-up. The real answer is about where a dict physically puts things.

What a dict actually does with a key

A dict is an array of slots. When you write d[k] = v, Python does four things in order:

  1. Compute hash(k), a plain integer.
  2. Turn that integer into an index by masking off the low bits: i = hash(k) & (table_size - 1).
  3. Look at slot i. If it is empty, store the key there and stop. If it holds something, compare: first the cached hashes, then == on the keys themselves. Equal means overwrite the value. Not equal means a collision, so probe to another slot and repeat.
  4. Remember that slot number. The dict never recomputes it.

Point four is the whole ballgame. The key's position in the table is a function of its hash at the moment it was inserted. Nothing watches the key afterwards.

Predict before revealing

Suppose Python let you use a list as a key. What happens here?

# hypothetical: imagine lists were hashable
k = [1, 2]
d = {k: "hello"}
k.append(3)
print(d[k])
print(list(d.keys()))

d[k] raises KeyError, and yet list(d.keys()) still shows [[1, 2, 3]]. The key is visibly sitting in the dict and is simultaneously unreachable.

Why: the entry was filed under slot hash([1,2]) & mask. After the append, the lookup computes hash([1,2,3]) & mask, which is almost certainly a different slot. Python looks in the new slot, finds it empty, and reports the key missing. The data is not corrupted, it is just filed under an address nobody will ever look at again.

Iteration still finds it because iteration walks the storage array in order and never consults a hash at all.

The invariant this protects

Everything above collapses into a single contract that any hashable object must satisfy:

The hash invariant If a == b then hash(a) == hash(b), and an object's hash must not change for as long as it lives.

Python cannot enforce the second half at runtime without watching every mutation, which would be ruinously expensive. So it enforces it structurally instead: the mutable built-in containers simply do not implement __hash__. list, dict, set and bytearray set __hash__ = None, which is what produces TypeError: unhashable type: 'list'. The immutable ones, including tuple and frozenset, hash fine because their contents can never change.

The sharp edge

A tuple is hashable only if everything inside it is. hash((1, 2)) works, hash((1, [2])) raises, because tuple's hash is computed from the hashes of its elements. So "tuples are hashable" is really "tuples are hashable when their contents are".

The same rule bites on your own classes. Define __eq__ and Python sets __hash__ to None for you, because your new equality almost certainly disagrees with the default identity-based hash and would break the invariant silently. You have to opt back in by defining __hash__ yourself, and it must be built from the same fields your __eq__ compares. This is exactly what @dataclass(frozen=True) does: freeze the fields, then generate a matching hash.

Because the hash is the address. A hash table is fast for exactly one reason: it converts a key into a memory position arithmetically, so it can jump straight there instead of searching. If the address a key computes today differs from the address it computed at insert time, the jump lands in the wrong place and the table gives a wrong answer rather than a slow one.

Two reasons, and either alone would be fatal. First, the list has no idea it is being used as a key, so it cannot notify anyone. You would need every mutable object to carry a registry of every container that has ever hashed it, which is a per-object cost paid by every program whether it uses dicts or not.

Second, rehashing is O(n) in the table size. A single append becoming a full table rebuild would destroy the performance guarantee that justifies using a dict at all.

Because the hash is a pure function of the object's contents, and if the contents provably cannot change then the output provably cannot change. There is nothing left to synchronise. The stability the table needs is guaranteed by construction rather than by discipline or by bookkeeping.

Bedrock A hash table stores a position computed once at insert time and never revisits that decision. Any key whose hash can change breaks the one assumption the entire data structure is built on. Immutability is not a style preference here, it is the precondition for the lookup being correct.
Mechanism

O(1) average, and what makes it degrade

"Dicts are O(1)" is an average over a probability distribution, not a guarantee. Knowing which assumptions that average rests on is the interesting part, because those assumptions are what a pathological workload violates.

The layout since Python 3.6

Modern CPython splits a dict into two arrays rather than one:

  • A sparse index array, sized to a power of two, holding small integers. Most entries are the "empty" marker.
  • A dense entries array, holding (hash, key, value) triples packed together in insertion order with no gaps.

A lookup masks the hash to get a position in the index array, reads the small integer there, and uses it to index into the dense array. That indirection buys two things at once. Memory drops sharply, because the sparse array that must stay mostly empty now holds 1-byte or 2-byte integers instead of full 24-byte triples. And insertion order falls out for free, because the dense array is only ever appended to.

Worth stating precisely

Insertion order preservation was an implementation detail in 3.6 and became a documented language guarantee in 3.7. It is a side effect of a memory optimisation that was then promoted to a promise.

Collision handling

CPython uses open addressing, not chaining. There are no linked lists hanging off buckets. When slot i is taken by a different key, the probe sequence moves to another slot in the same array using a recurrence that mixes in the still-unused high bits of the hash:

perturb = hash
j = hash & mask
while occupied(j) and not_our_key(j):
    perturb >>= 5
    j = (5*j + 1 + perturb) & mask

Feeding the high bits back in matters. Plain linear probing would send every key that collides in the low bits down the identical path, so one collision would make the next more likely, a feedback loop called primary clustering. Perturbing scatters colliding keys onto different paths and keeps clusters from feeding on each other.

Open addressing is chosen because it keeps everything in one contiguous allocation. A probe reads adjacent memory, which is cache friendly, whereas chaining would follow a pointer into a separately allocated node and very likely miss cache. That trade is the same one you will make again on the operating systems page.

Widget

Hash table probe visualizer

Insert keys and watch them land. Use "force collision" to add keys that all mask to the same slot, then watch probing walk them along. Insert past the load factor to trigger a resize, or mutate a stored key to reproduce the KeyError from the previous section.

Table size8
Entries0
Load factor0.00
Probes, last op0

What actually degrades the average

Cause Effect Do you meet it in practice?
High load factor Probe sequences lengthen, more slots inspected per lookup No. CPython resizes when the table passes roughly two thirds full, so occupancy is bounded by construction.
Resize One insert costs O(n) because every entry is reinserted into a bigger table Yes, but amortised. The table grows geometrically, so the total resize work across n inserts is O(n), giving O(1) per insert on average.
Degenerate __hash__ Every key masks to the same slot, so the dict becomes a linear scan and lookups go to O(n) Yes, and this one is real. It is nearly always a custom class whose __hash__ returns a constant or ignores most of its fields.
Adversarial keys An attacker crafts inputs that all collide, turning an O(n) parse into O(n²) Mitigated. String and bytes hashing is salted per process by PYTHONHASHSEED, so an attacker cannot precompute colliding keys for your run.
The one that bites you

Writing def __hash__(self): return 1 to make a class "work" as a key is technically correct and satisfies the invariant. It is also how you turn every dict holding those objects into a linked list with extra steps. Legal, and quietly quadratic.

Note also that the small-integer path is special: hash(n) == n for ordinary ints. That is not a flaw, since the mask then makes the low bits the slot, and consecutive integers spread across consecutive slots perfectly. But it means {i * 8: ... for i in range(1000)} with a table size that happens to be a multiple of 8 has more clustering than you would naively expect, which is precisely why the perturbation exists.

Because the expected probe count depends on the load factor and not on the number of entries. If the table is at most two thirds full, then any given slot is free with probability at least one third, so the expected number of slots you inspect before finding a free or matching one is a small constant near 2 or 3. Add a million more keys and the table grows to keep that ratio, so the constant does not move. A quantity that does not grow with n is what O(1) means.

The "one third of slots are free" step quietly assumes your key is equally likely to land in any slot. That is a statement about the hash function, not about the table. If all your keys hash to the same value then the free slots exist but are unreachable without walking the whole probe chain, and the expected probe count becomes proportional to the number of entries already there.

So O(1) is conditional on approximate hash uniformity. Built-in types provide it. Your classes provide it only if you write them that way.

Because doubling means the resizes happen at sizes 8, 16, 32, 64 and so on, and the cost of each is proportional to its size. Summing that series to reach n gives roughly 2n total work, since a geometric series is dominated by its last term. Spread over n insertions, that is a constant per insertion.

Grow by a fixed amount instead, say 8 slots at a time, and you resize n/8 times at average cost n/2, which is O(n²) total. The geometric growth is what makes the amortisation work.

Bedrock Dict performance rests on two independent things: a bounded load factor, which keeps the expected probe count constant, and geometric growth, which amortises the rebuilds. Uniform hashing is the assumption underneath both. Break the hashing and no amount of clever probing saves you.
Mechanism

Generators and lazy evaluation

A generator is not a lazily produced list. It is a function whose execution can be paused, and understanding it that way explains every behaviour it has, including the surprising ones.

What yield does to a function

When the compiler sees yield anywhere in a function body, it flags the whole function as a generator function. Calling it now does something quite different from a normal call: it builds a generator object holding a suspended stack frame and returns immediately. None of the body has run. Not one line.

Each next() resumes that frame at the saved instruction pointer, with local variables exactly as they were, runs until it hits the next yield, hands back the value, and suspends again. The frame is a heap-allocated object that outlives any individual call, which is why the locals survive across suspensions.

def gen():
    print("starting")
    yield 1
    print("middle")
    yield 2

g = gen()          # prints nothing at all
next(g)            # prints "starting", returns 1
next(g)            # prints "middle",   returns 2
next(g)            # raises StopIteration

That first line explains a bug people hit constantly: validation written at the top of a generator function does not run when you call it. It runs when someone first iterates it, which may be somewhere else entirely, inside a different try block, or never.

Where the memory actually goes

Compare processing a large file two ways:

# eager: the whole file becomes a list of str objects
lines = f.readlines()
total = sum(len(l) for l in lines)

# lazy: one line alive at a time
total = sum(len(l) for l in f)

The eager version allocates one str object per line and a list of pointers to them, so peak memory is proportional to the file. The lazy version holds one line, sums into an int, and lets the previous line's refcount drop to zero so it is freed immediately. Peak memory is proportional to the longest line. For a 10 GB file on a 16 GB machine that is the difference between working and not working, and no amount of faster hardware changes the answer.

The saving is not about speed. Both versions do the same amount of per-line work and the generator version is often marginally slower per item because of the resume overhead. What you buy is a bounded working set.

When laziness is the wrong choice

This is the part people leave out, and it is what separates knowing the feature from knowing when to reach for it.

Situation Why lazy hurts
You need to traverse twice A generator is exhausted after one pass. The second loop sees nothing, silently, with no error. If you need two passes you need a list, or you regenerate and pay the cost again.
You need len() There is nothing to count without running to the end, which consumes it. Generators have no length.
You need indexing or slicing g[5] is a TypeError. You get itertools.islice, which still walks from the front.
The data is small and hot Per-item resume overhead is real. For a few thousand items that fit in cache, a list comprehension usually wins outright.
You are feeding NumPy or pandas They want a contiguous buffer. Handing them a generator forces an internal materialisation anyway, so you paid the overhead and got none of the benefit.
The quiet one

Generators capture variables by reference, not by value, and they run later. A generator built inside a loop that closes over the loop variable will see that variable's final value when it finally runs, which is the same late binding problem covered further down this page. Laziness and late binding compound.

One-sentence version A generator trades random access and reuse for a bounded working set, so reach for it when the data is larger than memory or when you may stop early, and reach for a list when you will touch the data more than once.
Mechanism

Shallow and deep copy on nested structures

Everything here follows from one fact that you already know but may not have pushed on: a Python container does not contain objects, it contains references to objects. A list of three lists is an array of three pointers.

Three levels of copying

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

alias   = original                 # no copy: same object
shallow = original[:]              # new outer list, same inner lists
deep    = copy.deepcopy(original)  # new everything, recursively
original alias shallow deep outer outer [1, 2] [3, 4] [1, 2] [3, 4] shared by original, alias, shallow shared by original, alias, shallow fresh objects, owned by deep

Three arrows into one inner list is the entire bug surface of shallow copying.

Predict before revealing
original = [[1, 2], [3, 4]]
shallow  = original[:]

shallow[0].append(99)
shallow.append([5, 6])

print(original)

[[1, 2, 99], [3, 4]]

The two statements look symmetric and are not. shallow[0].append(99) follows the pointer in slot 0 to the inner list, which original also points at, and mutates that shared object. Both names see it.

shallow.append([5, 6]) mutates the outer list, which is genuinely a separate object, so original is untouched.

The rule that makes this predictable: a shallow copy makes the top level independent and leaves every level below it shared. Depth 1 is yours, depth 2 and beyond is not.

What deepcopy has to solve

Recursively copying sounds trivial until you consider two structures that break naive recursion:

inner  = [1, 2]
shared = [inner, inner]      # same object twice

cyclic = [1, 2]
cyclic.append(cyclic)        # contains itself

Naive recursion gives shared two distinct copies, silently breaking the aliasing the original relied on, and recurses forever on cyclic. So deepcopy carries a memo dict mapping id(original_object) to its copy. Before copying anything it checks the memo. A hit returns the existing copy instead of making a new one.

That single mechanism handles both cases. Shared references stay shared in the copy, because the second visit finds the first copy in the memo. Cycles terminate, because by the time recursion comes back around to the outer object, that object is already in the memo, having been registered before its children were processed.

Cost and the escape hatch

deepcopy is slow: it is a Python-level recursive walk that touches every object and does a dict insert per object. It also respects __deepcopy__ and the pickle protocol, so classes can customise or cheapen it. For plain nested data, json.loads(json.dumps(x)) is often much faster, at the price of only supporting JSON types and silently converting tuples into lists.

Not everything needs deep copying

Immutable leaves never need copying, and deepcopy knows this: it returns ints, strs, floats and tuples-of-immutables as-is. So a deep copy of a nested list of numbers duplicates the lists and shares the numbers, which is both correct and what you want. Sharing is only dangerous when the shared thing can be mutated.

Mechanism

The mutable default argument

The famous one. Most people can recite the fix. The mechanism is a one-liner and it is worth being able to say precisely, because it also explains decorators and closures.

Predict before revealing
def add(item, bucket=[]):
    bucket.append(item)
    return bucket

print(add(1))
print(add(2))
print(add(3, []))
print(add(4))

[1], then [1, 2], then [3], then [1, 2, 4].

Calls one, two and four all share one list. Call three passes its own, so it is unaffected and does not disturb the shared one either. Call four picks the shared list back up, still carrying 1 and 2.

Why

def is a statement that executes. When Python runs it, it evaluates the default expressions once, right there, and stores the resulting objects on the function object itself:

def add(item, bucket=[]): ...

add.__defaults__      # ([],)  one list object, stored on the function
id(add.__defaults__[0])  # the same id on every call, forever

A call that omits bucket does not re-evaluate []. It binds the parameter to the object already sitting in __defaults__. Since a list is mutable and append mutates in place, every such call is editing the one object that lives on the function. The function is carrying state it never asked for.

Immutable defaults are immune for the obvious reason: def f(x=0) also stores one object, but nothing can mutate 0, so sharing it is unobservable. The trap is exactly and only about mutability, which puts it in the same family as the hashability rule at the top of this page.

The fix, and why None

def add(item, bucket=None):
    if bucket is None:
        bucket = []          # evaluated per call, in the body
    bucket.append(item)
    return bucket

Moving the [] into the body moves it from def-time to call-time, which is the whole repair. None is the sentinel because it is a singleton, so is None is an identity check that cannot be fooled by a user-supplied value that merely compares equal, and because an empty list is falsy, which makes if not bucket a subtly wrong test that would also replace a deliberately passed empty list.

The same rule, elsewhere

Def-time evaluation also explains why def f(x=CONFIG['size']) freezes whatever CONFIG held at import time, and why the loop-variable capture bug in the next section is usually fixed with def f(x, _v=v). Default evaluation is early. Body evaluation and closure lookup are late. Almost every surprise in this area is that one boundary.

Mechanism

Closures and late binding

The counterpart to the previous section. Defaults are evaluated too early to be intuitive. Closures are resolved too late.

Predict before revealing
fns = []
for i in range(3):
    fns.append(lambda: i)

print([f() for f in fns])

[2, 2, 2], not [0, 1, 2].

All three lambdas close over the same variable i, not over three snapshots of its value. By the time any of them is called, the loop has finished and i holds 2. Each lambda then reads 2.

Note the second, quieter fact: a for loop does not create a scope in Python, so i is one variable in the enclosing function for the whole loop and still exists after it ends.

What a closure physically is

When a nested function refers to a name from an enclosing function, the compiler cannot store that name as an ordinary local in either frame, because the inner function may outlive the outer one. So it puts the variable in a cell, a tiny heap object with one slot, and both functions reference the cell rather than the value.

def outer():
    x = 10
    def inner():
        return x
    return inner

f = outer()
f.__closure__            # (<cell at 0x...: int object at 0x...>,)
f.__closure__[0].cell_contents   # 10
f.__code__.co_freevars   # ('x',)

The cell is read when the inner function runs, not when it is defined. That is what "late binding" means, stated concretely. In the loop example all three lambdas hold the same cell, the loop writes 0, 1 then 2 into that one cell, and all three later read the final contents.

Two fixes, and why each works

# 1. default argument: binds at def time, one per lambda
fns = [lambda v=i: v for i in range(3)]

# 2. a real enclosing scope per iteration: a fresh cell each call
def make(v):
    return lambda: v
fns = [make(i) for i in range(3)]

# 3. same idea, spelled with the stdlib
from functools import partial
fns = [partial(lambda v: v, i) for i in range(3)]

Fix one converts a late-bound free variable into an early-bound default, evaluated once per lambda at the moment of definition, which is exactly the def-time behaviour that made the previous section a trap. The same mechanism, used deliberately, is the cure here.

Fix two gives each iteration its own make frame, so each lambda gets its own cell holding its own value. This is the version to prefer in real code, because it says what it means and does not put a decoy parameter in the signature.

Writing to a closed-over variable

Reading an enclosing name works automatically. Assigning to it makes the name local to the inner function unless you say nonlocal x, which tells the compiler to keep using the enclosing cell. global is the same idea aimed at module scope. Both exist because assignment, not reading, is what determines a name's scope in Python.

Where this actually bites

Callbacks and deferred work: building handlers in a loop, registering retry functions, or building a list of generators. Anything defined in a loop and called after the loop has the bug. Since it only shows up when the call is deferred, the immediate-call version works fine in testing and the deferred version fails in production.

Because names in Python are bindings in a namespace, not storage locations with values baked into compiled code. A function body is compiled once, and the compiler records "this name comes from the enclosing scope", not "this name is currently 3". Resolution has to happen at call time because that is the only time you know what the namespace holds.

Capturing by value would also break mutual recursion and any pattern where a nested function is defined before the value it needs is assigned, both of which are common and useful.

Because Python's scopes are function-level, not block-level. Only def, lambda, class and module bodies introduce a namespace. A for statement introduces none, so i is a single local of the enclosing function that the loop rebinds on each pass, which is also why it is still readable after the loop ends.

Languages where the loop-in-a-closure case does what you expect, such as modern JavaScript with let, get that by creating a fresh binding per iteration. Python does not, so you create the fresh binding yourself with a function call.

Bedrock A closure captures a variable, meaning a cell, and never a value. A Python for loop creates exactly one variable. One cell shared by every function you build in the loop is therefore the only possible outcome, and any fix must work by manufacturing a separate binding per iteration.
Recognize and use

Decorators, context managers, and the standard library shelf

Depth calibration drops here. You do not need to derive these from first principles, you need to recognise the situation and reach for the right one without hesitating.

Decorators

A decorator is a function that takes a function and returns a replacement. The @ syntax is pure sugar: @d above def f means f = d(f) after the def runs.

import functools, time

def timed(fn):
    @functools.wraps(fn)                # copies __name__, __doc__, __wrapped__
    def wrapper(*args, **kwargs):
        t0 = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:                        # still times a call that raised
            print(fn.__name__, time.perf_counter() - t0)
    return wrapper

functools.wraps is not cosmetic. Without it the decorated function reports the wrapper's name, loses its docstring and type hints, and breaks anything that introspects it, which includes pytest fixtures, Sphinx, dataclasses and most serialisation. Always include it.

A decorator that takes arguments needs one more layer, because @retry(3) means "call retry(3), then apply whatever it returns as the decorator":

def retry(n):
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*a, **kw):
            for attempt in range(n):
                try:
                    return fn(*a, **kw)
                except Exception:
                    if attempt == n - 1: raise
        return wrapper
    return deco

Context managers

The point of with is that cleanup happens on every exit path, including exceptions, return and break. Two ways to write one:

class Timer:
    def __enter__(self):
        self.t0 = time.perf_counter()
        return self                 # this is what "as t" receives
    def __exit__(self, exc_type, exc, tb):
        self.elapsed = time.perf_counter() - self.t0
        return False                # False propagates the exception, True swallows it

from contextlib import contextmanager

@contextmanager
def timer():
    t0 = time.perf_counter()
    try:
        yield                        # everything before is __enter__
    finally:
        print(time.perf_counter() - t0)   # after is __exit__

Two details worth having ready. The try/finally in the generator form is mandatory, because an exception in the body is thrown back in at the yield, and without finally your cleanup is skipped. And returning a truthy value from __exit__ suppresses the exception, which is how contextlib.suppress works and is a thing you should almost never do by hand.

Argument forms

def f(pos, /, normal, *args, kwonly, **kwargs):
    ...
#     ^        ^        ^       ^         ^
#     |        |        |       |         collects extra keywords into a dict
#     |        |        |       must be passed by name (after * )
#     |        |        collects extra positionals into a tuple
#     |        either position or name
#     positional only (before / ), name is not part of the API

At a call site * and ** unpack rather than collect: f(*seq, **mapping) spreads a sequence into positionals and a mapping into keywords. Positional-only parameters, marked by /, let you rename a parameter later without breaking callers, which is why the C-implemented builtins use them heavily.

Comprehensions

squares  = [x*x for x in xs if x > 0]        # list
uniq     = {x.id for x in xs}                # set
by_id    = {x.id: x for x in xs}             # dict
lazy     = (x*x for x in xs)                 # generator, evaluates nothing yet
flat     = [y for row in grid for y in row]  # nested: outer loop first, left to right

A comprehension has its own scope, so its loop variable does not leak into the enclosing function. That has been true for list comprehensions since Python 3 and is why the loop-variable-after-the-loop trick works for for statements but not for comprehensions.

The shelf: what to reach for, and when

Tool Reach for it when The cost it removes
collections.deque You push and pop at both ends, or you need a fixed-size sliding window via maxlen list.pop(0) is O(n) because it shifts every element. deque is O(1) at both ends.
collections.Counter Counting occurrences, then wanting the top k Hand-rolled count dicts, plus most_common(k) uses a heap internally.
collections.defaultdict Grouping into lists or sets keyed by something The setdefault dance and the branch on first sight of a key.
heapq You need the smallest item repeatedly but not a full sort, or a streaming top-k over more data than fits in memory Re-sorting on every insert. Push and pop are O(log n), peeking at h[0] is O(1). Min-heap only, so negate for a max-heap.
bisect Lookups into an already-sorted list, bucketing a value into ranges, or maintaining sorted order with insort Linear scans. bisect_left is O(log n). Note insort is still O(n) because of the shift, so it is for reads far outnumbering writes.
functools.lru_cache A pure function with repeated arguments, especially a recursive one Recomputation. Requires hashable arguments, which brings you straight back to section one. cache is the unbounded version, and cache_clear() matters in tests.
Memoisation and mutable state

lru_cache keeps strong references to arguments and results, so caching a method keeps every self it ever saw alive for the life of the process. That is a genuine leak in a long-running service, and it is why cached_property exists for the per-instance case.

Reflex

Complexity, stated without being asked

The goal is not to recite this table. It is that when you say "I would use a set here" the reason arrives in the same breath, unprompted. Use quiz mode until the numbers come before the thought.

Widget

Complexity reflex table

Filter by structure, or hide the answers and score yourself. Worst case is shown where it differs from the average, because the gap is usually the interesting part.

Structure Operation Average Worst Why
The three sentences that carry most of the value A list is a contiguous array of pointers, so indexing is constant but inserting or deleting anywhere except the end shifts everything after it. A dict or set is a hash table, so membership is constant on average but only because the hash spreads keys evenly. A deque is a doubly linked list of blocks, so both ends are constant and the middle is not.

Almost every complexity question about Python containers is answerable from those three structural facts. x in some_list is O(n) because there is nothing to do but scan. x in some_set is O(1) because there is an address to compute. list.insert(0, x) is O(n) because the array must shift. deque.appendleft(x) is O(1) because there is no array to shift. You are not remembering a table, you are reading the layout.

One line each

Know the name, move on

Nobody is testing these on an intern. Knowing what each word means is enough to keep a conversation moving if one comes up.

__slots__Declares a fixed set of attributes so instances skip the per-instance __dict__, cutting memory substantially for many small objects. The cost is that you can no longer add attributes at runtime.
metaclassThe class of a class. It controls what happens when a class statement is executed, which is how frameworks auto-register subclasses or validate their definitions. type is the default one.
descriptorAn object defining __get__ or __set__ that, when stored as a class attribute, intercepts attribute access on instances. It is the machinery behind property, classmethod and every ORM field.
__init_subclass__The modern, much simpler answer to most of what people once used metaclasses for. Worth naming if metaclasses come up.