BASIC Python (Beginner Level) 📂 Basic Python Programming · 10 of 15 47 min read

Python Dictionaries — Keys, Values, Hashing

Master Python dictionaries from the ground up. This tutorial explains keys and values, how dicts are stored in memory as hash tables (with animated diagrams), what hashing is and why it makes lookups O(1), which keys are valid (hashable) and which are not, safe access with get() and setdefault(), all dictionary methods (keys, values, items, update, pop, etc.), for-loop patterns, dict comprehensions, nested dictionaries, and a full word-frequency practice program.

Section 01

The Story That Explains Dictionaries

The Old Library Card Catalog
Picture a library before computers. You want to find The Great Gatsby. You could walk every aisle scanning spines — thousands of books, one by one. Or you could go to the card catalog: a wooden cabinet of drawers, each label taking you to a section like "F–K". You slide open the drawer, flip to Fitzgerald, and the card tells you exactly which shelf and slot the book lives on. Total time: seconds, no matter how big the library is.

A Python dictionary is that card catalog. You give it a key (the book title, or a person's name, or a coordinate) and it hands you back the value instantly — no scanning, no counting. The secret behind that magic is a mathematical trick called hashing: Python turns your key into a number that tells it exactly which "drawer" the value is stored in.

Dictionaries are Python's most powerful built-in data type. They power language servers, JSON, class attributes, function arguments, database records, caches — practically every non-trivial Python program uses at least one. Understand dictionaries, and you understand Python.

A dictionary (dict) is Python's built-in mapping type — a collection of key-value pairs where each key is unique. You look things up by key, not by position, and lookups are O(1) constant time: it takes the same time to find a value in a dict of 1,000 items as in a dict of 10 million.

🔐
Key → Value
The mapping model
Each entry has two parts. The key is a unique label used to look up the entry. The value is whatever data belongs to that key — anything Python can hold.
👩‍🎓
Unique Keys
One value per key
No two entries can share a key. Assigning to an existing key overwrites its value. If you need multiple values per key, store a list as the value.
O(1) Lookup
Instant retrieval
Finding a value takes constant time regardless of dict size. This is possible only because of hashing. A million-entry dict is just as fast to query as a ten-entry one.

Section 02

Keys and Values — The Basic Structure

Every dictionary entry is a key-value pair: two objects glued together with a colon :. The key is what you ask for; the value is what you get back. Multiple pairs sit inside curly braces {}, separated by commas.

user = {"name": "Alice", "age": 30, "city": "NYC"} "name" KEY (str) "Alice" VALUE (str) "age" 30 "city" "NYC" Three unique keys · each mapping to one value · order preserved (Python 3.7+)
# ─── FIRST DICTIONARY ────────────────────────────────────
user = {
    "name": "Alice",
    "age": 30,
    "city": "NYC"
}

# Look up by key
print(user["name"])          # Alice
print(user["age"])           # 30

# Keys can be different types
mixed = {
    "name": "Bob",        # str key
    42: "answer",          # int key
    (1, 2): "point",        # tuple key — allowed!
    True: "yes"            # bool key
}

# Values can be ANYTHING — including lists, dicts, functions
inventory = {
    "apples": 12,                     # int value
    "scores": [88, 92, 75],           # list value
    "config": {"tax": 0.15},            # dict value
    "active": True                    # bool value
}
print(inventory["scores"])              # [88, 92, 75]
print(inventory["config"]["tax"])       # 0.15 — nested access!
💡
Since Python 3.7 — Insertion Order Is Preserved

Dictionaries remember the order you inserted keys. When you loop over a dict, you get the keys in the order they were added. This wasn't guaranteed in Python 3.6 and earlier, so old tutorials may claim dicts are "unordered". They aren't — not anymore.


Section 03

Creating Dictionaries — Five Ways

# ─── 1. LITERAL SYNTAX (most common) ────────────────────
empty  = {}
prices = {"apple": 1.2, "bread": 3.5, "milk": 2.8}

# ─── 2. dict() CONSTRUCTOR — keyword args ───────────────
user = dict(name="Alice", age=30, city="NYC")
# NOTE: keys become strings automatically — but this only works
# for string keys that are also valid Python identifiers.

# ─── 3. FROM A LIST OF (key, value) TUPLES ──────────────
pairs = [("a", 1), ("b", 2), ("c", 3)]
d = dict(pairs)                # {'a': 1, 'b': 2, 'c': 3}

# ─── 4. dict.fromkeys() — same value for many keys ──────
zeroed = dict.fromkeys(["x", "y", "z"], 0)
print(zeroed)                # {'x': 0, 'y': 0, 'z': 0}

# ─── 5. DICT COMPREHENSION ──────────────────────────────
squares = {n: n**2 for n in range(1, 6)}
print(squares)               # {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

# ─── EMPTY DICT vs EMPTY SET — SNEAKY! ──────────────────
print(type({}))              # dict — empty braces are a DICT
print(type(set()))           # set  — use set() for empty set!

Section 04

What Is Hashing? — The Core Idea

The Coat-Check Number
Imagine you check your coat at a fancy restaurant. The attendant doesn't remember faces — that would be slow. Instead, she hangs your coat on a numbered hook and hands you a ticket with that number. When you come back, you show the ticket, she reads the number, walks straight to that hook, and hands you your coat. She never searches. She just calculates.

That's hashing. A hash function takes any object (your coat = a Python key like "Alice") and produces a fixed-size number (the ticket = a hash value). Python uses that number to calculate exactly where the value lives in memory. No searching, no scanning — just arithmetic.

A hash function is a mathematical function that turns any input into a fixed-size integer called a hash. Python has one built in: hash(). It works on any immutable object.

hash("Alice") → 8398... → bucket index in the hash table "Alice" KEY (any immutable object) hash() function HASH FUNCTION (built-in) 8398423712 (a big integer) HASH VALUE % 8 modulo 0 BUCKET INDEX Two properties that make hashing useful: 1. DETERMINISTIC — the same key always produces the same hash 2. UNIFORM — different keys distribute evenly across buckets
# ─── SEE THE HASH FUNCTION IN ACTION ────────────────────
print(hash("Alice"))              # e.g. 8398423712 (varies per session)
print(hash(42))                   # 42 (small ints hash to themselves)
print(hash(3.14))                 # 322818021289917443
print(hash((1, 2, 3)))             # 529344067295497451 (tuples hash!)

# ─── DETERMINISTIC — same key ALWAYS same hash ──────────
print(hash("Alice") == hash("Alice"))   # True

# ─── DIFFERENT KEYS → USUALLY DIFFERENT HASHES ──────────
print(hash("Alice") == hash("Bob"))     # False (almost always)

# ─── MUTABLE OBJECTS CANNOT BE HASHED ───────────────────
# hash([1, 2, 3])    → TypeError: unhashable type: 'list'
# hash({"a": 1})     → TypeError: unhashable type: 'dict'
# hash({1, 2, 3})    → TypeError: unhashable type: 'set'

Section 05

How Dictionaries Are Stored in Memory

A Python dict is a hash table: an array of "buckets" (slots) where entries live. To store d["Alice"] = 30, Python:

01
Compute the hash
hash("Alice") → some big integer.
02
Reduce to a bucket index
Take the hash modulo the table size — that's the bucket number.
03
Store the entry
Put the (hash, key, value) triple in that bucket.
04
To read: repeat steps 1-2, then fetch
Same key → same hash → same bucket → direct fetch. O(1).

The Hash Table in Memory

user = {"Alice": 30, "Bob": 25, "Carol": 35} user HASH TABLE (array of buckets) [0] empty [1] Alice 30 hash: 839... [2] empty [3] Bob 25 hash: 512... [4] empty [5] empty [6] Carol 35 hash: 741... [7] empty 8 buckets, 3 occupied · Python resizes when the table gets ⅔ full Looking up user["Bob"]: "Bob" hash() 512... % 8 3 bucket [3] 25 value! Total operations: hash + modulo + one array access. Constant time.

What About Collisions?

What if two different keys hash to the same bucket index? Python handles this with a strategy called open addressing with probing: it looks at the next bucket, and the next, until it finds an empty one. This works well because the table stays mostly empty — Python resizes it (usually doubling its capacity) once it becomes about two-thirds full.

"Xyz" and "Abc" both hash to bucket [2] — collision! [0] [1] [2] TAKEN Xyz 42 [3] free! Abc 99 [4] [5] [6] probe: try next bucket
💡
Why the Sparse Table?

Python keeps hash tables at least 1/3 empty on purpose. A packed table has many collisions, which turns O(1) lookups into O(n) scans. A sparse table wastes a little memory but keeps lookups genuinely constant time. When occupancy climbs above about 2/3, Python allocates a bigger table and re-hashes all existing entries.


Section 06

Rules for Valid Dictionary Keys

Not every Python object can be a dictionary key. The rule: a key must be hashable. In practice, that means it must be immutable — because if the object could change, its hash would change too, and Python would lose track of where the entry lives.

✅ Valid Keys (Hashable)
TypeExample
int42
float3.14
str"name"
boolTrue, False
tuple (of hashables!)(1, 2)
frozensetfrozenset({1, 2})
NoneNone
❌ Invalid Keys (Unhashable)
TypeExample
list[1, 2, 3]
dict{"a": 1}
set{1, 2, 3}
tuple w/ mutable inside(1, [2, 3])
bytearraybytearray(b"hi")
Any mutable object— TypeError
# ─── VALID KEYS ─────────────────────────────────────────
d = {}
d[42]        = "int key"
d["name"]    = "str key"
d[3.14]      = "float key"
d[(1, 2)]    = "tuple key"            # great for coordinates!
d[None]      = "None as key"
print(d[(1, 2)])                       # tuple key

# ─── INVALID KEYS ───────────────────────────────────────
# d[[1, 2]] = "x"            → TypeError: unhashable type: 'list'
# d[{"a": 1}] = "x"          → TypeError: unhashable type: 'dict'
# d[{1, 2}] = "x"            → TypeError: unhashable type: 'set'
# d[(1, [2])] = "x"          → TypeError (tuple contains a list!)

# ─── COMMON PATTERN: TUPLE KEY FOR 2D LOOKUP ────────────
grid = {}
grid[(0, 0)] = "start"
grid[(1, 2)] = "tree"
grid[(3, 4)] = "goal"
print(grid[(1, 2)])                    # tree

Section 07

Accessing and Modifying Values

Reading — Three Ways to Access a Value

user = {"name": "Alice", "age": 30, "city": "NYC"}

# ─── 1. SQUARE BRACKETS — crashes if key missing ────────
print(user["name"])                # Alice
# print(user["email"])           → KeyError!

# ─── 2. .get() — safe, returns None (or default) ────────
print(user.get("name"))            # Alice
print(user.get("email"))           # None (no crash!)
print(user.get("email", "n/a"))    # n/a (custom default)

# ─── 3. 'in' — check first, then read ───────────────────
if "email" in user:
    print(user["email"])
else:
    print("no email on file")

Writing — Add, Update, Delete

# ─── ADD OR UPDATE (same syntax) ────────────────────────
user = {"name": "Alice", "age": 30}

user["city"] = "NYC"            # add a new key
user["age"]  = 31               # update an existing key (birthday!)
print(user)                        # {'name': 'Alice', 'age': 31, 'city': 'NYC'}

# ─── DELETE — del or pop ────────────────────────────────
del user["city"]                # remove key (crashes if missing)
age = user.pop("age")              # remove AND return the value
print(age, user)                   # 31 {'name': 'Alice'}

# .pop() with default — safe removal
email = user.pop("email", None)     # None (no error even if missing)

Section 08

Dictionary Methods — The Complete Toolkit

View Methods — .keys(), .values(), .items()

scores = {"Alice": 92, "Bob": 78, "Carol": 85}

# ─── .keys() — all the keys ─────────────────────────────
print(scores.keys())          # dict_keys(['Alice', 'Bob', 'Carol'])
print(list(scores.keys()))    # ['Alice', 'Bob', 'Carol']

# ─── .values() — all the values ─────────────────────────
print(scores.values())        # dict_values([92, 78, 85])
print(sum(scores.values()))    # 255

# ─── .items() — all (key, value) pairs as tuples ────────
print(scores.items())         # dict_items([('Alice', 92), ('Bob', 78), ...])

# THE KEY INSIGHT: these are LIVE VIEWS, not copies!
keys_view = scores.keys()
scores["Dan"] = 88            # mutate the dict
print(keys_view)                # view reflects the change automatically!

Mutators — Change the Dictionary

scores = {"Alice": 92, "Bob": 78}

# ─── .update() — merge another dict (or iterable) ──────
scores.update({"Carol": 85, "Dan": 73})
print(scores)         # {'Alice': 92, 'Bob': 78, 'Carol': 85, 'Dan': 73}

# Update also OVERWRITES existing keys
scores.update({"Alice": 99})     # bumps Alice's score
print(scores["Alice"])              # 99

# ─── .pop(key) — remove & return value ──────────────────
val = scores.pop("Bob")             # 78
# scores.pop("Missing")           → KeyError
val = scores.pop("Missing", -1)      # -1 (safe with default)

# ─── .popitem() — remove & return LAST inserted pair ────
last = scores.popitem()               # ('Dan', 73)
print(last)

# ─── .setdefault() — get, or add if missing ─────────────
# Perfect for building dicts of lists
groups = {}
for word in ["apple", "ant", "bee", "ball", "cat"]:
    groups.setdefault(word[0], []).append(word)
print(groups)
# {'a': ['apple', 'ant'], 'b': ['bee', 'ball'], 'c': ['cat']}

# ─── .clear() — remove ALL entries ─────────────────────
scores.clear()
print(scores)                        # {}

# ─── .copy() — shallow copy ─────────────────────────────
original = {"a": 1, "b": 2}
duplicate = original.copy()          # independent dict
duplicate["c"] = 3
print(original)                      # {'a': 1, 'b': 2} — untouched
print(duplicate)                     # {'a': 1, 'b': 2, 'c': 3}

Complete Methods Reference

CategoryMethodWhat It DoesReturns
Views.keys()Live view of all keysdict_keys
.values()Live view of all valuesdict_values
.items()Live view of (key, value) tuplesdict_items
Read.get(k, default)Safe lookup — returns default if missingvalue or default
k in dMembership test (fast, hash-based)bool
len(d)Number of entriesint
Mutated[k] = vAdd or update entry
.update(other)Merge another dict or pairsNone
.setdefault(k, def)Get, or add if missingexisting or new value
.pop(k, default)Remove & return valuevalue
.popitem()Remove & return last (key, value)tuple
Deletedel d[k]Remove entry by key
.clear()Remove all entriesNone
Build.copy()Shallow copynew dict
dict.fromkeys(keys, v)Build dict with same value for many keysnew dict

Section 09

The for Loop — Iterating Over Dictionaries

Looping over a dict is where you spend most of your dictionary time. Python offers three neat patterns, one for each view method, plus useful patterns for filtering and building new dicts.

Pattern 1 — Loop Over Keys (the default)

ages = {"Alice": 30, "Bob": 25, "Carol": 35}

# Default iteration gives you the KEYS
for name in ages:
    print(name)

# OUTPUT: Alice, Bob, Carol

# Equivalent (explicit)
for name in ages.keys():
    print(name)

Pattern 2 — Loop Over Values

for age in ages.values():
    print(age)

# OUTPUT: 30, 25, 35

# Aggregate quickly
print(sum(ages.values()))          # 90
print(max(ages.values()))          # 35
print(sum(ages.values()) / len(ages))  # 30.0 average

Pattern 3 — Loop Over Both With .items()

# The MOST COMMON pattern — unpack each (key, value) tuple
for name, age in ages.items():
    print(f"{name} is {age}")

# OUTPUT:
# Alice is 30
# Bob is 25
# Carol is 35

# Filter while looping
adults = [name for name, age in ages.items() if age >= 30]
print(adults)                        # ['Alice', 'Carol']

# Sort by value
for name, age in sorted(ages.items(), key=lambda x: x[1]):
    print(f"{name}: {age}")
# OUTPUT:
# Bob: 25
# Alice: 30
# Carol: 35

Pattern 4 — Dict Comprehensions

# ─── BUILD A DICT WITH A COMPREHENSION ──────────────────
# {key_expr: value_expr for item in iterable}
squares = {n: n**2 for n in range(1, 6)}
print(squares)                       # {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

# ─── TRANSFORM AN EXISTING DICT ─────────────────────────
prices = {"apple": 1.0, "bread": 3.0, "milk": 2.5}
with_tax = {name: price * 1.15 for name, price in prices.items()}
print(with_tax)                      # {'apple': 1.15, 'bread': 3.45, 'milk': 2.875}

# ─── SWAP KEYS AND VALUES ───────────────────────────────
inverted = {v: k for k, v in prices.items()}
print(inverted)                     # {1.0: 'apple', 3.0: 'bread', 2.5: 'milk'}

# ─── FILTER WHILE BUILDING ──────────────────────────────
cheap = {name: price for name, price in prices.items() if price < 3}
print(cheap)                        # {'apple': 1.0, 'milk': 2.5}
⚠️
Don't Modify a Dict While Iterating

Adding or deleting keys while looping over a dict raises RuntimeError: dictionary changed size during iteration. If you need to remove entries, either build a new dict with a comprehension, or iterate over a copy of the keys: for k in list(d.keys()):.


Section 10

Nested Dictionaries — Structured Data

A dictionary's value can itself be a dictionary. This is how you model JSON, database rows, configuration files, and any hierarchical data in Python. Nested dicts are so common that JSON literally uses this exact structure.

# ─── A REGISTRY OF USER RECORDS ─────────────────────────
users = {
    "alice": {
        "name": "Alice Johnson",
        "age": 30,
        "skills": ["Python", "SQL"],
        "active": True
    },
    "bob": {
        "name": "Bob Smith",
        "age": 25,
        "skills": ["Go", "Rust"],
        "active": False
    }
}

# Nested access with chained brackets
print(users["alice"]["name"])           # Alice Johnson
print(users["alice"]["skills"][0])       # Python

# Safer nested access with .get() chained
email = users.get("carol", {}).get("email", "n/a")
print(email)                          # n/a

# ─── LOOP OVER NESTED DATA ──────────────────────────────
for username, info in users.items():
    status = "active" if info["active"] else "inactive"
    print(f"{info['name']:<20} — {status} — {len(info['skills'])} skills")

# ─── UPDATE A NESTED VALUE ──────────────────────────────
users["alice"]["skills"].append("Kubernetes")
print(users["alice"]["skills"])       # ['Python', 'SQL', 'Kubernetes']

Section 11

Complete Practice Program — Word Frequency Counter

# word_counter.py — a real dictionary workout

def count_words(text):
    """Return a dict mapping word → count."""
    counts = {}
    for word in text.lower().split():
        word = word.strip(".,!?;:\"'")
        if word:
            counts[word] = counts.get(word, 0) + 1
    return counts

def main():
    text = """
    The quick brown fox jumps over the lazy dog.
    The dog barks. The fox runs. The lazy dog sleeps again.
    Python makes counting words a breeze — the dict does the work.
    """

    line = "─" * 50
    counts = count_words(text)

    # ─── BASIC STATS ────────────────────────────────────
    print(f"{line}\n  WORD FREQUENCY REPORT\n{line}")
    print(f"  Unique words: {len(counts)}")
    print(f"  Total words:  {sum(counts.values())}")

    # ─── TOP 5 MOST COMMON ─────────────────────────────
    top5 = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:5]

    print(f"\n  {'WORD':<15}{'COUNT':>6}{'BAR':>20}")
    for word, count in top5:
        bar = "█" * count
        print(f"  {word:<15}{count:>6}  {bar}")

    # ─── DICT COMPREHENSION — filter words appearing 2+ times ─
    frequent = {w: c for w, c in counts.items() if c >= 2}
    print(f"\n  Words appearing 2+ times: {frequent}")

    # ─── setdefault: GROUP WORDS BY LENGTH ─────────────
    by_length = {}
    for word in counts:
        by_length.setdefault(len(word), []).append(word)
    print(f"\n  Grouped by length:")
    for length, words in sorted(by_length.items()):
        print(f"    {length} chars: {words}")

    # ─── SAFE LOOKUP with .get() ───────────────────────
    for query in ["the", "missing"]:
        found = counts.get(query, 0)
        print(f"  '{query}' appears {found} time(s)")

if __name__ == "__main__":
    main()
OUTPUT
────────────────────────────────────────────────── WORD FREQUENCY REPORT ────────────────────────────────────────────────── Unique words: 20 Total words: 27 WORD COUNT BAR the 5 █████ dog 3 ███ fox 2 ██ lazy 2 ██ python 1 █ Words appearing 2+ times: {'the': 5, 'fox': 2, 'lazy': 2, 'dog': 3} Grouped by length: 3 chars: ['the', 'fox', 'dog'] 4 chars: ['over', 'lazy', 'runs', 'work'] 5 chars: ['quick', 'brown', 'jumps', 'barks'] 6 chars: ['python', 'breeze', 'sleeps'] 7 chars: ['counting', 'again'] ... 'the' appears 5 time(s) 'missing' appears 0 time(s)

Section 12

Golden Rules

🐍 Python Dictionaries — Non-Negotiable Rules
1
Keys must be hashable (i.e., immutable). str, int, float, bool, tuple (of hashables), frozenset, None. Lists, dicts, and sets cannot be keys because they're mutable. Try it and you get TypeError: unhashable type.
2
Keys are unique. Assigning to an existing key overwrites its value. If you need multiple values per key, store a list as the value (or use collections.defaultdict(list)).
3
Use .get() instead of [] when a key might be missing. d.get("k", default) never crashes. d["k"] raises KeyError. Save the crash for the rare cases where a missing key is a real bug.
4
Membership tests are hash-based → blazing fast. "alice" in users runs in constant time — same speed for 10 or 10 million entries. Use it liberally instead of scanning .keys() or a list.
5
Loop with .items() when you need both. Default iteration gives keys only. for k, v in d.items(): is the idiomatic way to walk through both — no double lookups, no ugly indexing.
6
Never modify a dict during iteration. Adding or deleting keys while looping raises RuntimeError: dictionary changed size during iteration. Iterate over list(d.keys()) or build a new dict with a comprehension.
7
Insertion order is preserved (Python 3.7+). Dicts remember the order keys were added. Old tutorials calling them "unordered" are out of date. This lets you build ordered mappings without reaching for OrderedDict.
8
{} creates an empty DICT, not a set. Use set() for an empty set. This surprises many beginners because {1, 2, 3} is a set literal. The distinction: empty braces mean dict; braces with items decide by whether they contain key: value pairs.