LESSON 35 · DEEPER PYTHON27 min

Count and Group Data with Counter and defaultdict

Count repeated values and group records without writing fragile missing-key checks.

Before you start: Dictionaries · Loops · List Comprehensions

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Count and Group Data with Counter and defaultdict

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    Imagine a small issue tracker exporting labels such as bug, idea, and question. We need a frequency report and, later, a way to collect the actual messages under each label. A plain dictionary can do both jobs, but we have to decide what a missing key means. On the first encounter with bug, there is no count to increment. If we simply add one to a dictionary entry that does not exist, Python raises KeyError. That failure is useful because it shows us the exact assumption our code made. In this lesson we will run the broken code, choose Counter for frequencies, and use defaultdict when each category needs a growing list.

  2. 00:41

    We will also test an empty input and a label that never appeared. Both tools live in the standard-library collections module, so no package installation is needed. The companion lesson on PythonLessonLab has a runnable browser console, worked examples, a checked coding task, a quiz, and common mistakes. Follow along by predicting each output before we run it. A counting task may look trivial, but the same pattern appears in log summaries, product inventories, word frequencies, and data cleaning. The key is to make our missing-key policy explicit instead of relying on an accidental dictionary state.

  3. 01:21

    The very first label is bug. `counts['bug']` attempts to retrieve a value before `+= 1` can store an updated one. Because the dictionary is empty, that lookup raises KeyError. The loop never gets to idea or question, and the dictionary remains empty. One valid manual repair would be `counts[label] = counts.get(label, 0) + 1`. Another would test `if label not in counts` and initialize the count first. Those are useful techniques, especially when you need custom behavior. For a straightforward tally, Counter expresses the intent more directly. It can consume an iterable of labels and build counts for us.

  4. 02:03

    Do not respond to the error by wrapping the entire loop in a broad exception handler and returning an incomplete report. The real problem is the missing initial value, so correct that rule where the update happens. Notice also that counting is different from grouping. A count tells us how many bug labels occurred. A group might store the complete bug messages. If we use one tool for both jobs without thinking about the stored value, the code becomes harder to understand. We will inspect each abstraction separately before combining them into a useful report. Counter is a dictionary subclass designed for tallying hashable items.

  5. 02:44

    It stores two bugs and one idea. Reading a key that was never counted returns zero, which is natural for a frequency question. That read does not mean a count was inserted into the underlying mapping. A defaultdict behaves differently. We supplied the factory `list`, without parentheses. On a missing-key read, it calls that factory, stores a new empty list under the key, and returns it. That is why `groups['missing']` prints an empty list and the final membership check prints true. This side effect matters. If all you want to know is whether a category exists, test membership before reading it, or use `groups.get(label)` with a suitable fallback.

  6. 03:27

    Do not create empty categories merely by inspecting them. Counter values can also be changed, but its purpose is counts; defaultdict can use factories such as list, set, or int for other accumulation patterns. The factory must be callable. Passing `list()` would supply one already-created list rather than the list constructor, and defaultdict would reject it. Decide whether you need numbers or collections first. Then the code can name the appropriate intent clearly for a future reader. This code makes two separate promises. `counts` answers how many issues have each normalized category. `titles_by_category` answers which original titles belong under that category.

  7. 04:12

    We use strip and lower so a category written as space, Bug, space is counted together with lowercase bug. Counter builds the frequency map from those normalized values, while defaultdict creates a fresh title list for each category. Returning ordinary dictionaries gives the caller predictable lookup behavior; a missing category in the returned groups raises KeyError rather than silently creating an empty group. The counts dictionary also needs an explicit get call for a missing label because it no longer has Counter's special zero-on-missing lookup. That conversion is part of the website task's contract.

  8. 04:52

    We make the category list once, then pair each issue with its normalized category using zip. The function does not edit any issue dictionary or title. The prepared example on PythonLessonLab uses this exact function name and expects these two lines, so you can run the code and then use the practical checker. Try changing feature to FEATURE and predict the new grouping. A correct solution handles first occurrences without a special-case error and preserves the order in which titles arrive within each group. These checks are more valuable than merely seeing a plausible count on the happy path. Two bug titles remain in their input order after the categories are normalized, and the feature list stays separate.

  9. 05:38

    The source issue dictionaries remain unchanged. An unseen category is not inserted into either returned dictionary; to read its frequency as zero, the caller uses `counts.get('missing', 0)`. Empty input returns two empty ordinary dictionaries rather than raising an exception or inventing a category. Appending a new bug title does not affect the feature list, confirming that defaultdict created distinct lists per key before conversion. When you complete the PythonLessonLab task, run its checker and quiz, then experiment with your own categories. Consider Bug and bug with extra spaces: the explicit strip and lower policy combines them.

  10. 06:22

    That is a domain decision, not something Counter decides by itself. A missing category field in a record would still need a separate input policy. You might reject that record, map it to Unknown, or skip it, depending on the application. Counter and defaultdict remove repetitive initialization code; they do not remove the need to define what counts and groups mean. If a future program fails on a missing dictionary key, ask whether you need a safe lookup, a numeric tally, or an automatically created collection. The right answer follows from the data you intend to store.

Understand the concept

A normal dictionary can count things, but every new key needs an initial value. collections.Counter takes an iterable and builds a frequency map in one step. Counter(words)[word] is the number of occurrences; a missing item reads as zero. This makes it useful for log labels, survey responses, and repeated words. Count only values that represent the same thing: normalize case or whitespace before constructing the Counter if that is what the problem requires.

Counter.most_common(n) returns the n most frequent (item, count) pairs. It is convenient for a quick report, but ties deserve care: their order follows first encounter, so do not pretend that it is an alphabetical ranking. For a reproducible alphabetical tie break, sort explicitly with a key such as (-count, label). Counter can also accept updates from another iterable, which is useful when reading data in batches.

Grouping is a different task. A defaultdict(list) creates a new empty list on a missing key, so groups[key].append(value) works without an if statement. The factory must be list, not list(): the former creates a fresh list for each missing key. Unlike Counter's zero-returning lookup, indexing a missing defaultdict key creates a new entry. Use get when you need to inspect a group without changing the mapping.

The exercise combines both ideas for issue records with a category field. Return a normal dictionary from category to count and a second normal dictionary from category to list of issue titles. Normalize categories with strip and lower; leave titles untouched. Empty input should give two empty dictionaries. Tests cover repeated categories, input order inside a group, fresh lists, and an unchanged source.

  • Counter counts hashable values from an iterable
  • most_common returns frequency pairs
  • defaultdict(list) creates a fresh list for each missing key
  • Indexing a missing defaultdict key changes the mapping

See it step by step

Read the code, predict the output, then compare it with the result.

01. Count labels without a manual initialization branch

PYTHON
from collections import Counter

labels = ["bug", "feature", "bug", "question"]
counts = Counter(labels)
print(counts["bug"])
print(counts["missing"])
print(counts.most_common(2))
EXPECTED OUTPUT
2
0
[('bug', 2), ('feature', 1)]

Counter starts from the iterable, supplies zero for a missing label, and gives the most frequent labels as pairs.

02. Group titles by category

PYTHON
from collections import defaultdict

issues = [("bug", "Login"), ("feature", "Export"), ("bug", "Search")]
groups = defaultdict(list)
for category, title in issues:
    groups[category].append(title)
print(dict(groups))
EXPECTED OUTPUT
{'bug': ['Login', 'Search'], 'feature': ['Export']}

The list factory makes one new list per unseen category; append preserves each group's input order.

03. Break frequency ties deliberately

PYTHON
from collections import Counter

counts = Counter(["pear", "apple", "pear", "apple", "plum"])
ordered = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
print(ordered)
EXPECTED OUTPUT
[('apple', 2), ('pear', 2), ('plum', 1)]

Negated count ranks high frequencies first, and the label provides an explicit alphabetical tie breaker.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Count a cleaned stream of labels

Real input rarely arrives with consistent spelling. A support form may produce Bug, bug, and a label with spaces around it. Counter will correctly count the values it receives, but it does not know that those three spellings mean the same category. Normalize the text first, then hand the resulting stream to Counter. A generator expression makes that transformation explicit without building a second list solely for the count.

A Counter behaves much like a dictionary of integer frequencies. Indexing a missing key returns zero, which is useful when a report asks for a category that did not appear. That does not mean every possible category is stored: a zero lookup does not add the absent label. Run the example and change one Bug to Feature. Predict both counts before running it again.

PYTHON
from collections import Counter

raw = [" Bug ", "feature", "bug", "QUESTION", "Feature"]
counts = Counter(label.strip().lower() for label in raw)
print(dict(counts))
print(counts["bug"])
print(counts["missing"])
print("missing" in counts)
EXPECTED OUTPUT
{'bug': 2, 'feature': 2, 'question': 1}
2
0
False
Follow the reasoning
  1. strip removes surrounding spaces and lower makes the category labels comparable.
  2. Counter consumes the normalized labels and tallies their occurrences.
  3. Reading a missing count gives zero without inserting a new key.
02 / 03

Rank frequent values and make ties predictable

Counter.most_common is ideal for a quick top-N summary, but a tied count does not by itself define a meaningful order. Here apple and pear each occur twice. If an alphabetic tie break matters for a stable published table, sort the pairs with a two-part key. The negative count orders larger frequencies first; the label orders ties alphabetically. This is a small but important difference between a casual inspection and a repeatable report.

Counter can be updated as more values arrive, so the whole dataset need not be available at construction time. In this example a first batch is counted, then a second batch changes the ranking. The result is still a Counter, but sorting its items returns an ordinary list of pairs. For a table, convert those pairs into rows or formatted text rather than relying on Counter's representation.

PYTHON
from collections import Counter

counts = Counter(["pear", "apple", "pear"])
counts.update(["apple", "plum"])
ranking = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
print(ranking)
print(counts.most_common(1))
EXPECTED OUTPUT
[('apple', 2), ('pear', 2), ('plum', 1)]
[('pear', 2)]
Follow the reasoning
  1. update adds the second batch to the existing counts.
  2. The explicit key sorts by descending count and then alphabetically by label.
  3. most_common(1) is convenient but chooses the first encountered item for this tie.
03 / 03

Group records into independent lists

A frequency count cannot answer which titles belong to each category. For that, keep a mapping from category to a list of titles. defaultdict(list) calls list whenever an unseen category is indexed. Each category receives a separate list, so appending a title to bug cannot change feature. This removes the repetitive if category not in groups initialization branch from the loop and keeps the loop focused on the record processing.

There is one side effect to remember: groups['unknown'] creates an empty entry when unknown was absent. For an inspection that must leave the mapping alone, use groups.get('unknown', []). Return or print dict(groups) when an ordinary dictionary is the desired interface. In the practice task, combine this grouping with Counter and use the same normalized category for both, so the counts and lists describe exactly the same records.

PYTHON
from collections import defaultdict

issues = [("bug", "Login"), ("feature", "Export"), ("bug", "Search")]
groups = defaultdict(list)
for category, title in issues:
    groups[category].append(title)
print(dict(groups))
print(groups.get("unknown", []))
print("unknown" in groups)
EXPECTED OUTPUT
{'bug': ['Login', 'Search'], 'feature': ['Export']}
[]
False
Follow the reasoning
  1. Indexing bug on the first record creates its own empty list.
  2. Later bug titles append to that same list in input order.
  3. get reads a missing group without adding it.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnComplete summarize_issues(issues). Normalize each category with strip().lower(), count categories with Counter, and group the original titles with defaultdict(list). Return two ordinary dictionaries: counts and titles_by_category. Keep title order within each group, do not mutate the input, and support an empty list. The prepared example should print {'bug': 2, 'feature': 1} and ['Login fails', 'Button missing'].
Need a hint?

Create labels = [item['category'].strip().lower() for item in issues], then counts = Counter(labels). Build grouped = defaultdict(list) in a loop over issues and normalized labels. Return dict(counts), dict(grouped).

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.What does Counter(['a', 'a'])['missing'] return?

    02.Why pass list rather than list() to defaultdict?

    03.Which operation can create a new key in a defaultdict(list)?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Passing a list instance as the default factory

    COMMON MISTAKE
    groups = defaultdict(list())
    FIX
    groups = defaultdict(list)

    What happens: TypeError: first argument must be callable or None.
    Pass the constructor itself so defaultdict can call it for each missing key.

    Treating Counter as text normalization

    COMMON MISTAKE
    Counter(['Bug', 'bug'])
    FIX
    Counter(label.strip().lower() for label in ['Bug', 'bug'])

    What happens: Bug and bug are counted as two different labels.
    Normalize the data before counting when case and surrounding whitespace should not matter.

    Reading a missing group by indexing

    COMMON MISTAKE
    groups = defaultdict(list)
    print(groups['unknown'])
    FIX
    print(groups.get('unknown', []))

    What happens: The lookup silently adds unknown as an empty group.
    Use get for a read that should leave the mapping unchanged.

    Browse this lesson’s problem fixes

    Sources and further reading