If you've written Python for any length of time, you've almost certainly hit a KeyError at the worst possible moment. Python defaultdict is the elegant fix hiding in plain sight inside the collections module — and once you understand how it works, you'll wonder how you ever lived without it. A defaultdict behaves exactly like a regular Python dictionary, with one game-changing difference: instead of raising a KeyError when you access a key that doesn't exist, it automatically creates that key and assigns it a default value. No try-except blocks, no .get() workarounds, just clean readable code that does what you mean.

How python defaultdict Works Under the Hood

The defaultdict is a subclass of Python's built-in dict. When you create one, you pass it a callable — called a factory function — that gets invoked automatically whenever a missing key is accessed. The return value of that callable becomes the default value for the new key, which is then stored and returned to you.

Here's the basic syntax:

from collections import defaultdict

d = defaultdict(factory_function)

The factory_function can be any callable: int, list, str, set, or even a custom lambda or regular function. When you access d["some_missing_key"], Python calls factory_function() behind the scenes, stores the result under that key, and returns it to you — all in one silent, automatic step.

This behavior is defined in the official Python collections.defaultdict documentation.

Creating a defaultdict with int, list, and set

The three most common factory functions passed to python defaultdict are int, list, and set. Each gives your dictionary a different default value behavior, making them suited to completely different problems.

Using int as the factory:

int() called with no arguments returns 0, which makes defaultdict(int) perfect for tallying and counting. Every new key starts at zero automatically.

from collections import defaultdict

scores = defaultdict(int)
scores["Alice"] += 10
scores["Alice"] += 5
scores["Bob"] += 20

print(dict(scores))
{'Alice': 15, 'Bob': 20}

Without python defaultdict, you'd need to check if "Alice" in scores before incrementing, or use the more verbose scores["Alice"] = scores.get("Alice", 0) + 10. With defaultdict(int), the first access to a missing key automatically sets it to 0, so += 10 just works.

Using list as the factory:

list() returns an empty list [], which makes grouping and bucketing data trivial. Appending to a missing key works on the first try, no pre-initialization required.

from collections import defaultdict

assignments = defaultdict(list)
assignments["Math"].append("Homework 1")
assignments["Math"].append("Homework 2")
assignments["Science"].append("Lab Report")

print(dict(assignments))
{'Math': ['Homework 1', 'Homework 2'], 'Science': ['Lab Report']}

Using set as the factory:

set() returns an empty set, which is ideal when you want to collect unique values per key and automatically deduplicate.

from collections import defaultdict

visitors = defaultdict(set)
visitors["homepage"].add("user_101")
visitors["homepage"].add("user_202")
visitors["homepage"].add("user_101")  # duplicate — silently ignored
visitors["about"].add("user_303")

print(dict(visitors))
{'homepage': {'user_101', 'user_202'}, 'about': {'user_303'}}

defaultdict vs dict — Why KeyError Disappears

The most important distinction when comparing defaultdict vs dict in Python is how each one handles a missing key. A regular dict raises KeyError immediately and stops execution. Python defaultdict silently creates the key instead, filling it with whatever the factory returns.

Here's a direct side-by-side demonstration:

from collections import defaultdict

# Regular dict raises KeyError
regular = {}
try:
    regular["missing_key"] += 1
except KeyError as e:
    print(f"Regular dict error: {e}")

# defaultdict handles it gracefully
smart = defaultdict(int)
smart["missing_key"] += 1
print(f"defaultdict result: {smart['missing_key']}")
Regular dict error: 'missing_key'
defaultdict result: 1

Notice that python defaultdict didn't need an if check or a try-except block anywhere. It just worked. This is particularly valuable inside loops where you're building up data structures incrementally and checking for key existence on every iteration would add noise without adding clarity.

One important subtlety: accessing a missing key in a defaultdict creates that key as a side effect. If you want to check whether a key exists without accidentally creating it, use .get() or the in operator — both work on a defaultdict exactly as they do on a regular dict, without triggering the factory function at all.

python defaultdict list — Grouping and Bucketing Data

One of the most practical real-world uses of python defaultdict list is grouping related records together by a shared attribute. Before defaultdict, this required an annoying if key not in dict: dict[key] = [] check before every append. With defaultdict(list), you skip straight to the logic.

Imagine you have a list of sales transactions and need to group them by customer:

from collections import defaultdict

transactions = [
    ("Alice", 49.99),
    ("Bob", 120.00),
    ("Alice", 15.50),
    ("Charlie", 89.00),
    ("Bob", 34.75),
    ("Alice", 200.00),
]

grouped = defaultdict(list)

for customer, amount in transactions:
    grouped[customer].append(amount)

for customer, amounts in grouped.items():
    total = sum(amounts)
    print(f"{customer}: {amounts} — Total: ${total:.2f}")
Alice: [49.99, 15.5, 200.0] — Total: $265.49
Bob: [120.0, 34.75] — Total: $154.75
Charlie: [89.0] — Total: $89.00

The loop itself reads exactly like the problem statement: for each transaction, append the amount to that customer's list. There's no defensive scaffolding, no pre-seeding, no post-processing to fix up empty keys. The defaultdict handles all of that silently in the background.

python defaultdict int — Counting Word and Event Frequencies

Frequency counting is one of the most common tasks across data processing, log analysis, and text manipulation. Python defaultdict int makes this pattern so concise it almost disappears into the logic itself.

from collections import defaultdict

sentence = "the quick brown fox jumps over the lazy dog the fox"
word_count = defaultdict(int)

for word in sentence.split():
    word_count[word] += 1

sorted_words = sorted(word_count.items(), key=lambda x: x[1], reverse=True)

for word, count in sorted_words:
    print(f"{word}: {count}")
the: 3
fox: 2
quick: 1
brown: 1
jumps: 1
over: 1
lazy: 1
dog: 1

Python also ships collections.Counter specifically for counting, but defaultdict(int) gives you the same core capability with more flexibility — you can mix in custom logic, accumulate weighted values, or blend counting with other operations in the same pass, all without switching containers mid-loop.

Using a Lambda as the Factory Function

You're not limited to built-in types as your factory. Any callable works, including lambdas. This lets you set truly custom default values that go beyond what int, list, or set can provide.

from collections import defaultdict

registry = defaultdict(lambda: "unknown")
registry["status"] = "active"

print(registry["status"])    # explicitly set
print(registry["category"])  # missing — gets default
print(dict(registry))
active
unknown
{'status': 'active', 'category': 'unknown'}

You can also use a lambda to set a numeric default other than zero — useful when your domain has a meaningful non-zero starting point, like everyone beginning at level one in a game:

from collections import defaultdict

levels = defaultdict(lambda: 1)
levels["Alice"] += 4
levels["Bob"] += 1

print(dict(levels))
{'Alice': 5, 'Bob': 2}

Nested defaultdict for Multi-Level Data Structures

When you need a dictionary of dictionaries, you can nest python defaultdict to create multi-level structures without any tedious initialization. The trick is to pass a lambda that itself returns a new defaultdict as the outer factory function.

from collections import defaultdict

inventory = defaultdict(lambda: defaultdict(int))

inventory["fruits"]["apples"] += 5
inventory["fruits"]["bananas"] += 3
inventory["vegetables"]["carrots"] += 10
inventory["vegetables"]["spinach"] += 7
inventory["fruits"]["apples"] += 2

for category, items in inventory.items():
    print(f"\n{category}:")
    for item, qty in items.items():
        print(f"  {item}: {qty}")

fruits:
  apples: 7
  bananas: 3

vegetables:
  carrots: 10
  spinach: 7

Each time you access a missing top-level key like inventory["dairy"], a brand new defaultdict(int) gets created automatically as its value, so you can immediately start accessing sub-keys without any setup phase. The nesting can go as deep as you need — though beyond two levels, a custom class or collections.namedtuple often becomes clearer.

The default_factory Attribute

Every python defaultdict exposes a default_factory attribute that holds the callable you originally passed in. You can inspect it at any time, and you can even reassign it after the defaultdict has already been used.

from collections import defaultdict

counter = defaultdict(int)
print(counter.default_factory)

counter.default_factory = list
counter["new_key"].append("hello")
print(dict(counter))
<class 'int'>
{'new_key': ['hello']}

Setting default_factory to None is especially useful when you want to lock down a defaultdict after its initial population phase — it disables auto-creation entirely, so any subsequent access to a missing key raises KeyError exactly like a regular dict would. This is a clean pattern for a "build, then freeze" workflow.

Full Working Example — Student Grade Tracker

This example brings together nested defaultdict, defaultdict(list) for grouping, and defaultdict(int) for accumulation in a single realistic program.

from collections import defaultdict

# Raw data: (student, subject, score)
raw_grades = [
    ("Alice",   "Math",    92),
    ("Bob",     "Math",    85),
    ("Alice",   "Science", 88),
    ("Charlie", "Math",    76),
    ("Bob",     "Science", 91),
    ("Charlie", "Science", 83),
    ("Alice",   "Math",    95),
    ("Bob",     "Math",    78),
    ("Charlie", "Science", 90),
]

# Group scores: student -> subject -> [scores]
grade_book = defaultdict(lambda: defaultdict(list))

for student, subject, score in raw_grades:
    grade_book[student][subject].append(score)

# Build per-student summary and collect class-wide subject totals
subject_totals = defaultdict(list)
student_summary = {}

for student, subjects in grade_book.items():
    all_scores = []
    subject_avgs = {}
    for subject, scores in subjects.items():
        avg = sum(scores) / len(scores)
        subject_avgs[subject] = round(avg, 2)
        all_scores.extend(scores)
        subject_totals[subject].extend(scores)
    overall = round(sum(all_scores) / len(all_scores), 2)
    student_summary[student] = {"subjects": subject_avgs, "overall": overall}

# Print individual student reports
print("=== Student Report ===")
for student, data in sorted(student_summary.items()):
    print(f"\n{student}:")
    for subject, avg in sorted(data["subjects"].items()):
        print(f"  {subject}: {avg}")
    print(f"  Overall Average: {data['overall']}")

# Print class averages by subject
print("\n=== Class Averages by Subject ===")
for subject, scores in sorted(subject_totals.items()):
    class_avg = round(sum(scores) / len(scores), 2)
    print(f"  {subject}: {class_avg}")

Output:

=== Student Report ===

Alice:
  Math: 93.5
  Science: 88.0
  Overall Average: 91.67

Bob:
  Math: 81.5
  Science: 91.0
  Overall Average: 84.67

Charlie:
  Math: 76.0
  Science: 86.5
  Overall Average: 82.33

=== Class Averages by Subject ===
  Math: 85.17
  Science: 88.4

Notice how no line in the main logic checks whether a key exists before using it. The nested defaultdict structure handles initialization at every level automatically, so the code reads exactly like the problem it solves: group the scores, average them, and summarize. That's the real payoff of python defaultdict — it lets you write the logic you care about without drowning it in defensive dictionary plumbing.