../

Dictionaries & sets

dict, set and their collections relatives in Python 3.14: creating, reading and writing, views, what can be a key, merging and copying, Counter and defaultdict, sets, costs, the hash-map idioms DS&A solutions are built from, typing, and how JS Object, Map and Set map over. Group-by and dedupe recipes are in Fundamentals; lists are in Lists.

Creating

A dict maps hashable keys to values and keeps insertion order (guaranteed since 3.7).

ExpressionResultNotes
{}, dict(){}an empty dict; an empty set is set()
{"a": 1, "b": 2}literala repeated key keeps the last value
dict(a=1, b=2){'a': 1, 'b': 2}keyword keys are always strings that are valid names
dict([("a", 1), ("b", 2)])from pairsany iterable of 2-item iterables
dict(zip(keys, values, strict=True))from parallel listsstrict raises ValueError on unequal lengths
{w: len(w) for w in words}comprehensionlater duplicates overwrite earlier ones
{x: i for i, x in enumerate(xs)}value → indexthe last index wins for repeated values
dict.fromkeys(xs)keys from xs, values Nonededupes and keeps first-seen order
dict.fromkeys(xs, 0)every value 0fine for immutable defaults
dict.fromkeys(xs, [])one list shared by every keyuse {x: [] for x in xs}
Counter(xs), defaultdict(list)counting and grouping dictssee Counter, defaultdict & friends
words = ["apple", "bob", "apple", "cy"]
lengths = {w: len(w) for w in words}
# {'apple': 5, 'bob': 3, 'cy': 2}
last = {w: i for i, w in enumerate(words)}
# {'apple': 2, 'bob': 1, 'cy': 3}: last index wins
pairs = dict(zip("abc", [1, 2, 3], strict=True))
# {'a': 1, 'b': 2, 'c': 3}
 
shared: dict[str, list[int]] = dict.fromkeys("ab", [])
shared["a"].append(1)
print(shared)  # {'a': [1], 'b': [1]}: one list
# the fix: a new list per key
own: dict[str, list[int]] = {k: [] for k in "ab"}

Reading & writing

OperationReturnsNotes
d[k]the valueKeyError if k is missing
d.get(k), d.get(k, default)the value, else None or defaultnever raises
k in d, k not in dboolchecks keys only; v in d.values() is a linear scan
d[k] = vinsert or overwrite; a new key goes last, an overwritten key keeps its place
d.setdefault(k, default)the value now storedinserts default only when k is missing
d.update(other), d |= otherNoneoverwrite from a dict, pairs, or update(k=v) keywords
d.pop(k), d.pop(k, default)the removed valueKeyError without a default
d.popitem()the last (key, value) insertedLIFO; KeyError when empty
del d[k]KeyError if missing
d.clear()Noneempties it for every name that refers to it
len(d)number of keysO(1)
stock = {"apple": 3, "pear": 0}
stock["apple"]  # 3
stock.get("kiwi")  # None
stock.get("kiwi", 0)  # 0
"pear" in stock  # True: in checks the keys
0 in stock.values()  # True: a linear scan of the values
 
stock["kiwi"] = 5  # a new key goes last
stock["apple"] = 4  # an overwrite keeps its place
list(stock)  # ['apple', 'pear', 'kiwi']
stock.setdefault("fig", 1)  # 1: inserted
stock.setdefault("fig", 9)  # 1: already there
stock.pop("pear")  # 0
stock.pop("plum", None)  # None: no KeyError
stock.popitem()  # ('fig', 1): the last inserted
stock  # {'apple': 4, 'kiwi': 5}

Iterating & views

ExpressionGivesNotes
for k in dkeysin insertion order
d.keys(), d.values(), d.items()live viewsthey reflect later changes; list(...) takes a snapshot
for k, v in d.items()key-value pairs
reversed(d), reversed(d.items())newest first3.8+
d.keys() & other, d.keys() - other, d.items() & othera new setkey and item views act like sets; value views don't (TypeError)
sorted(d)sorted keys
sorted(d.items(), key=itemgetter(1))pairs by valuereverse=True for largest first; ties keep insertion order
max(d, key=d.__getitem__)the key with the largest valueties: the first in order; key=d.get runs too, but type checkers reject it because get may return None
next(iter(d))the first keyStopIteration when empty
from operator import itemgetter
 
score = {"ann": 90, "bo": 72, "cy": 90}
keys = score.keys()
score["di"] = 85
list(keys)  # ['ann', 'bo', 'cy', 'di']: a live view
 
score.keys() & {"bo", "zed"}  # {'bo'}
sorted(score.keys() - {"bo"})  # ['ann', 'cy', 'di']
max(score, key=score.__getitem__)  # 'ann': first of ties
sorted(score.items(), key=itemgetter(1), reverse=True)
# [('ann', 90), ('cy', 90), ('di', 85), ('bo', 72)]
sorted(score, key=lambda k: (-score[k], k))  # score, name
# ['ann', 'cy', 'di', 'bo']
 
for name in list(score):  # a snapshot: safe to delete
    if score[name] < 80:
        del score[name]
score  # {'ann': 90, 'cy': 90, 'di': 85}

Keys & hashing

A key must be hashable: its hash() never changes, and keys that compare equal hash equal. A lookup hashes the key, jumps to a slot, then confirms the match with ==.

indices [0] [1] [2] [3] [4] [5] [6] [7] slot = hash & 7 entries entry hash key value 0 1 2 3 4 (at most 5) 0 3 3 'a' 1 11 11 'b' 2 5 5 'c' probe: (3*5 + 0 + 1) & 7 = 0 hash 3 != 11: probe to slot 0 iteration walks the entries, not the slots d = {} d[3] = 'a' d[11] = 'b' d[11] = 'b' d[5] = 'c' d[11] d[11] # 'b' list(d) # [3, 11, 5] new checked found

1/8

A small dict is two arrays: an indices table of 8 slots and an entries array that holds up to 5 entries (two thirds of 8) in insertion order. Both start empty.

Key typeHashableNotes
int, float, str, bytes, bool, Noneyes
tupleif every item is(1, 2) is; (1, [2]) is not; see Tuples
frozensetyesan unordered collection as a key
list, dict, set, bytearrayno3.14 says TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
a class instance (default)yes, by identitytwo objects with equal fields are two different keys
a class with __eq__ but no __hash__nodefining __eq__ sets __hash__ to None
@dataclass(frozen=True)yes, by field valuesa plain @dataclass is unhashable for the same reason
float("nan")yes, butnan != nan, so only the very same nan object finds its entry
from dataclasses import dataclass
 
d = {1: "int", 1.0: "float", True: "bool"}
print(d)  # {1: 'bool'}: 1 == 1.0 == True, so one key
 
grid = {(0, 1): "#"}  # tuple keys: coordinates
grid[0, 1]  # '#': the parentheses are optional
 
 
@dataclass(frozen=True)
class Point:
    x: int
    y: int
 
 
seen = {Point(1, 2)}
Point(1, 2) in seen  # True: hashed by field values
 
path = [1, 2]
by_path = {tuple(path): 1}  # a list key raises TypeError
hash(-1) == hash(-2)  # True in CPython; still two keys
  • Equal hashes don't make equal keys. hash(-1) == hash(-2) in CPython (-1 is reserved), yet -1 and -2 are separate keys, because the lookup confirms with ==.
  • Never mutate an object that is a key. If its hash changes, its entry can't be found again; that is why the hashable built-ins are immutable.
  • str and bytes hashes are salted per process (PYTHONHASHSEED), so a set of strings iterates in a different order on each run. Ints hash to themselves, so a set of small ints often looks sorted ({3, 1, 2} prints {1, 2, 3}), but {33, 10, 2} prints {33, 10, 2}. Sort before you print, compare or return.

Merging, copying & comparing

ExpressionGivesNotes
a | ba new dictb wins on shared keys (3.9+); both operands must be dicts
a |= b, a.update(b)a, changed in placethe right side may also be pairs: a |= [("k", 1)]
{**a, **b, "k": 1}a new dictworks with any mappings and mixes in literal keys
ChainMap(a, b)a layered view, no copylookups try a first
d.copy(), dict(d), {**d}shallow copynested lists and dicts are shared
copy.deepcopy(d)deep copynothing shared
a == bboolsame keys and values; order is ignored
OrderedDict == OrderedDictboolorder matters only when both sides are OrderedDicts
import copy
from collections import OrderedDict
 
base = {"db": {"host": "x", "port": "1"}}
over = {"db": {"host": "y"}}
base | over  # {'db': {'host': 'y'}}: port is gone
# | replaces whole values; deep merge is in Recipes
 
shallow = base.copy()
shallow["db"]["host"] = "z"  # base changes too
deep = copy.deepcopy(base)  # shares nothing
 
{"a": 1, "b": 2} == {"b": 2, "a": 1}  # True
od1 = OrderedDict(a=1, b=2)
od2 = OrderedDict(b=2, a=1)
od1 == od2, od1 == dict(od2)  # (False, True)

Counter, defaultdict & friends

ToolDoesWatch out
Counter(iterable)counts itemsc[missing] is 0 and does not insert the key
c.most_common(k)the top k as (item, count) pairsties keep first-seen order; no k sorts them all
c.total()the sum of the counts (3.10+)
c1 + c2, c1 - c2add, subtract countsresults drop counts of zero or less
c1 & c2, c1 | c2min, max of each count
c.update(xs), c.subtract(xs)add, subtract in placesubtract keeps zero and negative counts; +c drops them
c1 <= c2, c1 == c2inclusion, equality (3.10+)a missing key counts as 0: Counter(a=1, b=0) == Counter(a=1)
c.elements()each item repeated by its count
defaultdict(list), (int), (set)a missing key gets list(), 0, set()reading d[k] inserts k; test with k in d or d.get(k)
defaultdict(lambda: defaultdict(int))two-level countsd[a][b] += 1
OrderedDictmove_to_end(k), popitem(last=False)LRU bookkeeping; a plain dict's popitem() only removes the newest
ChainMap(*maps)layered lookupswrites and deletes go to the first map
functools.cachea memo dict around a functionthe arguments must be hashable
from collections import Counter, OrderedDict, defaultdict
 
c = Counter("abracadabra")
c["a"], c["z"]  # (5, 0)
"z" in c  # False: reading a missing key didn't insert it
c.most_common(2)  # [('a', 5), ('b', 2)]
c.total()  # 11
Counter(a=3, b=1) - Counter(a=1, b=2)  # Counter({'a': 2})
Counter("aab") <= Counter("aabbc")  # True: fits inside
 
graph: defaultdict[str, list[str]] = defaultdict(list)
graph["a"].append("b")
graph["c"]  # [], and "c" is now a key
sorted(graph)  # ['a', 'c']
 
lru: OrderedDict[str, int] = OrderedDict(a=1, b=2, c=3)
lru.move_to_end("a")  # a is now the newest
lru.popitem(last=False)  # ('b', 2): evict the oldest

Sets

A set works like a dict with keys and no values: unordered, unique, hashable items, O(1) average in.

ExpressionGivesNotes
set(), {1, 2}a set{} is an empty dict
set(xs), {x % 3 for x in xs}from an iterable, a comprehensionduplicates collapse
frozenset(xs)an immutable, hashable setusable as a dict key or a set member
s.add(x), s.update(xs)Noneadd("ab") adds one string; update("ab") adds 'a' and 'b'
s.remove(x), s.discard(x)Noneremove raises KeyError when x is missing; discard doesn't
s.pop()an arbitrary itemKeyError when empty; not "the smallest" or "the oldest"
a | b, a & b, a - b, a ^ bunion, intersection, difference, symmetric differenceboth operands must be sets
a.union(xs), a.intersection(xs), a.difference(xs)the samethe methods accept any iterable
a |= b, a &= b, a -= bin place
a <= b, a < b, a >= bsubset, proper subset, supersetalso issubset, issuperset
a.isdisjoint(b)no shared itemsstops at the first shared item
len(s), x in ssize, membershipO(1) average membership, the reason to use a set
set.intersection(*sets), set().union(*sets)across many collectionsthe unbound set.intersection() raises TypeError when given nothing; set().union() is safe with zero arguments
frozenset(a) | {2}a frozenseta mixed operation returns the left operand's type
sorted(s), min(s), max(s)ordered outputiteration order is arbitrary: sort before printing or returning
a = {1, 2, 3}
b = {3, 4}
a | b, a & b, a - b, a ^ b
# ({1, 2, 3, 4}, {3}, {1, 2}, {1, 2, 4})
{1, 2} <= a, a < a, a.isdisjoint({9})  # (True, False, True)
a.union([5, 6])  # methods take any iterable
# a | [5, 6] raises TypeError
 
seen: set[tuple[int, int]] = set()
seen.add((0, 0))  # coordinates as tuples
(0, 0) in seen  # True
 
state = frozenset({"key", "door"})
best = {state: 3}  # an unordered state as a key
best[frozenset({"door", "key"})]  # 3
 
lists = [[1, 2, 3, 4], [2, 3, 5], [3, 2, 9]]
set.intersection(*map(set, lists))  # {2, 3}: in every list
sorted(set().union(*lists))  # [1, 2, 3, 4, 5, 9]
sorted(set("hello"))  # ['e', 'h', 'l', 'o']: sort to show
a | b == {1, 2, 3, 4} union a & b == {3} intersection a - b == {1, 2} difference a ^ b == {1, 2, 4} symmetric difference a = {1, 2, 3}, b = {3, 4} 3 is in both a b 1 2 3 4

1/5

a = {1, 2, 3} and b = {3, 4}. Only 3 is in both.

When a plain set isn't enough

NeedUseNotes
insertion orderdict.fromkeys(xs)the standard library has no ordered set; use the dict's keys
sorted order with inserts and removalsbisect.insort on a list, or SortedList from sortedcontainers (third-party, PyPI)there is no sorted set built in; insort is O(n) per insert
how many of eachCounterset("aab") - set("ab") is empty; Counter("aab") - Counter("ab") keeps the extra 'a'
the smallest item, repeatedly, with removalsheapq plus a set of removed itemslazy deletion: pop while the top is marked
members drawn from a small range of intsan int bitmask (next section)O(1) union and intersection; hashable for free
a set as a dict key or inside another setfrozenset{frozenset({1, 2})} works, {{1, 2}} raises TypeError

Bitmasks as sets

When every possible member maps to a small int (letters, up to about 20 nodes for bitmask DP, the keys collected in a maze), one int can hold the whole set: bit i set means member i is in. Bitmask states are ints, so they go straight into a seen set or a memo key. The bit tricks themselves are in Bits.

Set operationBitmask
empty set, all of n members0, (1 << n) - 1
add i, remove im | 1 << i, m & ~(1 << i)
i in sm >> i & 1
union, intersection, differencea | b, a & b, a & ~b
sizem.bit_count() (3.10+)
subset a <= ba & b == a
date bit 3 cherry bit 2 banana bit 1 apple bit 0 members a 0 1 0 1 0b101 apple, cherry b 1 1 0 0 0b1100 cherry, date a & b 0 1 0 0 0b100 cherry a | b 1 1 0 1 0b1101 apple, cherry, date a & ~b 0 0 0 1 0b1 apple
Bit i stands for FRUITS[i]; binary puts bit 0 on the right.
FRUITS = ["apple", "banana", "cherry", "date"]
bit = {f: 1 << i for i, f in enumerate(FRUITS)}
 
a = bit["apple"] | bit["cherry"]
b = bit["cherry"] | bit["date"]
both, either = a & b, a | b
only_a = a & ~b
bin(either), either.bit_count()  # ('0b1101', 3)
a & either == a  # True: a is a subset of either
 
 
def members(mask: int) -> list[str]:
    out: list[str] = []
    while mask:
        low = mask & -mask  # the lowest set bit
        out.append(FRUITS[low.bit_length() - 1])
        mask ^= low
    return out
 
 
members(only_a), members(both)  # (['apple'], ['cherry'])

Costs & memory

CPython's dict and set are open-addressing hash tables that grow before they fill up, so lookups stay O(1) on average. Every operation also pays to hash the key.

OperationAverageWorst case / notes
d[k], d[k] = v, del d[k], k in dO(1)O(n) when many keys collide
d.get, d.setdefault, d.popO(1)
len(d)O(1)
for k in d, d.copy(), list(d.items())O(n)
v in d.values()O(n)a scan: keep a reverse dict or a set instead
a.update(b), a | bO(len(b)), O(len(a) + len(b))
set x in s, s.add(x), s.remove(x)O(1)O(n) with collisions
set a | b, a & b, a - bO(len(a) + len(b)), O(min(len(a), len(b))), O(len(a))
hash(s) of a strO(len(s)) the first timecached on the string after that
hash(t) of a tupleO(items)CPython 3.14 caches it too; 3.13 and earlier rehash on every lookup
import sys
 
sys.getsizeof({})  # 64: an empty dict (64-bit CPython)
sys.getsizeof({i: i for i in range(1000)})  # 36952
sys.getsizeof(set())  # 216
 
R, C = 3, 4
by_tuple = {(r, c): 0 for r in range(R) for c in range(C)}
by_int = {r * C + c: 0 for r in range(R) for c in range(C)}
r, c = divmod(7, C)  # back from an int key: (1, 3)

getsizeof counts the table, not the keys and values. Tuple keys are the clear choice for grid coordinates; on 3.14 a 300 × 300 grid of lookups ran about twice as fast with r * C + c int keys, because every (r, c) lookup builds a new tuple and hashes it. Switch only when profiling says so.

DS&A idioms

The hash-map moves that problem solutions are built from, and the trap in each. The patterns themselves, with TypeScript and JavaScript beside Python, are in the DS&A sheets (linked in the last column).

JobPythonWatch outMore
Count itemscnt = Counter(xs), or cnt[x] = cnt.get(x, 0) + 1a Counter read doesn't insert; a defaultdict(int) read doesCounter & defaultdict
Complement lookup (two-sum)if target - x in seen: … then seen[x] = istore after the check, or x pairs with itselfHash maps & sets
Value → indexpos = {x: i for i, x in enumerate(xs)}the last index wins; pos.setdefault(x, i) in a loop keeps the first
Group by a keygroups = defaultdict(list), groups[key(x)].append(x)anagram key: "".join(sorted(w)) or tuple(counts); lists can't be keysGroup anagrams
Visited setseen = {start}, then seen.add(v) when v is enqueuedmarking on dequeue lets one node enter the queue many timesBFS, Graphs
Word set as visitedwords = set(word_list), then words.remove(w) when w is enqueuedone set answers both "is it a word?" and "seen yet?"Word ladder
Duplicateslen(set(xs)) != len(xs); to stop early, return on the first x in seenthe one-liner always reads all of xsHash maps & sets
Common to every listset.intersection(*map(set, lists))raises TypeError when lists is empty
Missing or extra itemsset(range(n + 1)) - set(xs), set(a) ^ set(b)sets drop repeats: subtract Counters when counts matter
Cycle detectionwhile x not in seen: then seen.add(x) and x = step(x)O(n) memory; fast and slow pointers need O(1)Fast & slow pointers
Small universean int bitmask: m | 1 << i, m >> i & 1, m.bit_count()members must map to small intsBitmasks as sets
Lazy deletion from a heapremoved.add(x), then pop while h[0] in removedrepeated values need a Counter, not a setHeaps
Adjacency listg = defaultdict(list), g[u].append(v) (and g[v].append(u) if undirected)g[v] for a missing v inserts it; inside for u in g that raises RuntimeError, so read with g.get(v, [])Setup, Graphs
Prefix-sum countscount = {0: 1}, ans += count.get(pre - k, 0), then count[pre] = count.get(pre, 0) + 1the {0: 1} seed counts subarrays that start at index 0Prefix sums
Window, last-seen indexif last.get(ch, -1) >= lo: lo = last[ch] + 1, then last[ch] = ilo only moves right: an index from before the window must not pull it backSliding window
Window, distinct countcount[ch] -= 1, then del count[ch] at zeroa zero count is still a key, so len(count) overcountsSliding window
Memoization@cache on dp(i, j), or a memo dict keyed by (i, j)the arguments must be hashable: pass indexes or tuples, not listsTop-down DP
LRU cacheOrderedDict: move_to_end(k) on every use, popitem(last=False) to evicta plain dict's popitem() evicts the newestLRU cache
Top k frequentCounter(xs).most_common(k)O(n log k); buckets indexed by count give O(n)Top k frequent
Union-findparent = {x: x for x in nodes}, find with path halvingparent.setdefault(x, x) when nodes appear lazilyUnion-find, Python
Trienode = node.setdefault(ch, {}); mark word ends with a key like "$"the end marker must never be a real characterTries
Longest consecutive runs = set(xs); count up only from an x with x - 1 not in swithout that check the scan is O(n²)
Dedupe, keeping orderlist(dict.fromkeys(xs))set(xs) loses the orderDedupe
Float keysexact keys: Fraction, or a slope as (dy // g, dx // g) with g = gcd(dy, dx)0.1 + 0.2 and 0.3 are different keysNumbers
from collections import defaultdict
 
 
def two_sum(nums: list[int], target: int) -> list[int]:
    seen: dict[int, int] = {}  # value -> index
    for i, x in enumerate(nums):
        if target - x in seen:
            return [seen[target - x], i]
        seen[x] = i  # after the check: no self-pairs
    return []
 
 
def count_sums(nums: list[int], k: int) -> int:
    count = {0: 1}  # the empty prefix
    pre = ans = 0
    for x in nums:
        pre += x
        ans += count.get(pre - k, 0)
        count[pre] = count.get(pre, 0) + 1
    return ans
 
 
print(two_sum([3, 2, 4], 6))  # [1, 2]
print(count_sums([1, 1, 1], 2))  # 2
 
edges = [("a", "b"), ("b", "c")]
g: defaultdict[str, list[str]] = defaultdict(list)
for u, v in edges:
    g[u].append(v)
    g[v].append(u)
print(dict(g))  # {'a': ['b'], 'b': ['a', 'c'], 'c': ['b']}
nums [0] 1 [1] 1 [2] 1 nums[0:2], nums[1:3] count pre = 0 ans = 0 0: 1 pre = 1 ans = 0 0: 1 1: 1 look up pre - k = -1: absent, ans += 0 then count[1] = 1 pre = 2 ans = 1 0: 1 1: 1 2: 1 look up pre - k = 0: found, ans += 1 then count[2] = 1 pre = 3 ans = 2 0: 1 1: 1 2: 1 3: 1 look up pre - k = 1: found, ans += 1 then count[3] = 1 pre = 3 ans = 2 0: 1 1: 1 2: 1 3: 1 k = 2, and count starts as {0: 1} 2 subarrays sum to k new key found absent

1/5

count_sums([1, 1, 1], 2) starts with count = {0: 1}: the empty prefix sums to 0. pre and ans are 0.

Two string-shaped dict problems live on the Strings sheet: the longest unique substring window and the fixed-size anagram window.

Typing

AnnotationAcceptsUse for
dict[K, V]dictsvalues you build and mutate
Mapping[K, V]any read-only mapping: dict, Counter, MappingProxyType, ...parameters you only read
MutableMapping[K, V]dict-like objects you write toparameters you edit without demanding a dict
defaultdict[K, V], Counter[K], OrderedDict[K, V]those classesannotate them: defaultdict(list) alone infers a vague type
a TypedDict classdicts with a fixed set of string keys, each with its own typeJSON-like records
set[T], frozenset[T]setscollections.abc.Set[T] for read-only parameters

Import the abstract types from collections.abc; typing.Dict is a deprecated alias of dict.

from collections import Counter, defaultdict
from collections.abc import Mapping
from typing import NotRequired, TypedDict
 
 
def total(prices: Mapping[str, float]) -> float:
    return sum(prices.values())
 
 
counts: dict[str, int] = {"a": 1}
total(counts)  # OK: Mapping is covariant in its values
floats: dict[str, float] = counts  # ❌ type error: invariant
empty: dict[str, list[int]] = {}  # annotate empty dicts
groups: defaultdict[str, list[int]] = defaultdict(list)
freq: Counter[str] = Counter("abca")
 
 
class User(TypedDict):
    id: int
    name: str
    email: NotRequired[str]
 
 
u: User = {"id": 1, "name": "ann"}  # email may be absent
type Trie = dict[str, Trie]  # a recursive alias (3.12+)

dict is invariant in its values because it is writable: if dict[str, int] were accepted as dict[str, float], the callee could store 0.5 in your dict of ints. Mapping has no setters, so it is covariant in its values. Both stay invariant in their keys, because __getitem__ and get take a key as an argument.

Coming from JavaScript

The TypeScript versions of this sheet are Maps & sets and Objects.

JavaScriptPython
obj.key, obj["key"], map.get(k)d["key"] (raises KeyError), or d.get("key") (None)
obj?.a?.bd.get("a", {}).get("b")
"k" in obj"k" in d; JS also finds inherited keys ("toString" in {} is true), Python checks only the dict's own keys
Object.hasOwn(obj, k), map.has(k)k in d
Object.keys(o), Object.values(o), Object.entries(o)list(d), list(d.values()), list(d.items()); without list they are live views
Object.fromEntries(pairs)dict(pairs)
{ ...a, ...b }{**a, **b} or a | b
delete obj.k, map.delete(k)del d[k], or d.pop(k, None) when it may be missing
map.set(k, v), map.sized[k] = v, len(d)
obj[1] and obj["1"] are the same key1 and "1" are different keys; 1, 1.0 and True are the same key
integer-like object keys iterate first, in numeric orderevery key keeps insertion order
a Map compares object keys by identity: [1, 2] and [1, 2] are two keystuple keys compare by value: (1, 2) and (1, 2) are one key
new Set(xs), set.has(x), set.add(x)set(xs), x in s, s.add(x)
a.union(b), a.intersection(b), a.difference(b), a.symmetricDifference(b) (ES2025)a | b, a & b, a - b, a ^ b
a.isSubsetOf(b), a.isDisjointFrom(b)a <= b, a.isdisjoint(b)
Object.groupBy(xs, f), Map.groupBy(xs, f)a defaultdict(list) loop
JSON.stringify({ 1: "a" })json.dumps({1: "a"}) gives '{"1": "a"}': int keys come back from json.loads as strings
Object.freeze(obj)types.MappingProxyType(d), a read-only view
WeakMapweakref.WeakKeyDictionary
structuredClone(obj)copy.deepcopy(d)

Recipes

Invert a dict

When you need value → keys, and values may repeat.

from collections import defaultdict
 
owner = {"ann": "red", "bo": "blue", "cy": "red"}
by_color: defaultdict[str, list[str]] = defaultdict(list)
for name, color in owner.items():
    by_color[color].append(name)
print(dict(by_color))
# {'red': ['ann', 'cy'], 'blue': ['bo']}
 
unique = {"a": 1, "b": 2}
print({v: k for k, v in unique.items()})  # {1: 'a', 2: 'b'}

Deep merge

When layering config dicts and nested dicts should merge instead of being replaced.

from typing import Any
 
 
def deep_merge(
    a: dict[str, Any], b: dict[str, Any]
) -> dict[str, Any]:
    out = dict(a)
    for k, v in b.items():
        old = out.get(k)
        if isinstance(old, dict) and isinstance(v, dict):
            out[k] = deep_merge(old, v)
        else:
            out[k] = v
    return out
 
 
base = {"db": {"host": "x", "port": 5432}, "debug": False}
env = {"db": {"host": "prod"}, "debug": True}
print(deep_merge(base, env))
# {'db': {'host': 'prod', 'port': 5432}, 'debug': True}

Values that aren't merged are shared with the inputs, not copied; copy.deepcopy the result before mutating it.

Trie with nested dicts

When looking up words or prefixes one character at a time.

type Trie = dict[str, Trie]
END = "$"  # not a letter, so it can't clash
 
 
def insert(root: Trie, word: str) -> None:
    node = root
    for ch in word:
        node = node.setdefault(ch, {})
    node[END] = {}
 
 
def walk(root: Trie, s: str) -> Trie | None:
    node = root
    for ch in s:
        if ch not in node:
            return None
        node = node[ch]
    return node
 
 
root: Trie = {}
for w in ["car", "cart", "cat"]:
    insert(root, w)
found = walk(root, "car")
print(found is not None and END in found)  # True: a word
print(walk(root, "ca") is not None)  # True: a prefix
print(walk(root, "cow"))  # None

Longest consecutive run

When you need the longest run of consecutive integers in unsorted input, in O(n).

def longest_run(nums: list[int]) -> int:
    s = set(nums)
    best = 0
    for x in s:
        if x - 1 in s:
            continue  # not the start of a run
        y = x
        while y + 1 in s:
            y += 1
        best = max(best, y - x + 1)
    return best
 
 
print(longest_run([100, 4, 200, 1, 3, 2]))  # 4: 1 to 4

Loop over the set, not the list: duplicates in the input would otherwise repeat the work.

Word ladder

When BFS walks between words that differ by one letter; the word set doubles as the visited set.

from collections import deque
from string import ascii_lowercase
 
 
def ladder(begin: str, end: str, words: list[str]) -> int:
    unseen = set(words)  # remove a word once it's queued
    if end not in unseen:
        return 0
    unseen.discard(begin)
    q = deque([(begin, 1)])
    while q:
        word, steps = q.popleft()
        if word == end:
            return steps
        for i in range(len(word)):
            for ch in ascii_lowercase:
                nxt = word[:i] + ch + word[i + 1 :]
                if nxt in unseen:
                    unseen.remove(nxt)  # mark on enqueue
                    q.append((nxt, steps + 1))
    return 0
 
 
words = ["hot", "dot", "dog", "lot", "log", "cog"]
print(ladder("hit", "cog", words))  # 5
print(ladder("hit", "cog", words[:-1]))  # 0

Each word tries 26 × L one-letter changes, each an O(1) average lookup. Removing a word as it is queued keeps the BFS from revisiting it.

Happy number

When a process might loop: remember every state you've seen.

def is_happy(n: int) -> bool:
    seen: set[int] = set()
    while n != 1 and n not in seen:
        seen.add(n)
        n = sum(int(d) ** 2 for d in str(n))
    return n == 1
 
 
print(is_happy(19), is_happy(2))  # True False

The seen set costs memory for every state; Floyd's fast and slow pointers find the same cycle in O(1) space (Fast & slow pointers).

Can one multiset cover another

When checking a ransom note against a magazine, or a word against a rack of letter tiles.

from collections import Counter
 
 
def can_build(note: str, letters: str) -> bool:
    return Counter(note) <= Counter(letters)  # 3.10+
 
 
print(can_build("aab", "baa"), can_build("aa", "ab"))
# True False
missing = Counter("aabc") - Counter("ab")
print(sorted(missing.elements()))  # ['a', 'c']

Rank by count, then by key

When ordering items by frequency with a deterministic tie-break.

import heapq
from collections import Counter
 
words = "the cat and the hat and the bat".split()
cnt = Counter(words)
ranked = sorted(cnt.items(), key=lambda kv: (-kv[1], kv[0]))
print(ranked[:3])  # [('the', 3), ('and', 2), ('bat', 1)]
top = heapq.nsmallest(
    2, cnt.items(), key=lambda kv: (-kv[1], kv[0])
)  # [('the', 3), ('and', 2)]

most_common breaks ties by first appearance; the explicit key breaks them alphabetically, and nsmallest does it in O(n log k).

At most k distinct in a window

When the number of distinct keys in a sliding window is the constraint.

def longest_k_distinct(s: str, k: int) -> int:
    count: dict[str, int] = {}
    lo = best = 0
    for hi, ch in enumerate(s):
        count[ch] = count.get(ch, 0) + 1
        while len(count) > k:
            gone = s[lo]
            count[gone] -= 1
            if count[gone] == 0:
                del count[gone]  # else len(count) lies
            lo += 1
        best = max(best, hi - lo + 1)
    return best
 
 
print(longest_k_distinct("eceba", 2))  # 3: "ece"

References