Python Tallying and Grouping

Introduction

Welcome to Practical Data Processing with Python Collections! Earlier, we built solid foundations with strings, lists, and dictionaries one at a time. Now we start combining them to answer the kinds of questions real data brings us, and this first lesson tackles two of the most useful patterns in all of Python.

The first is tallying: How many times does each value appear? Think of counting how often each word shows up in a product review. The second is grouping: Which records share the same attribute? Think of sorting customers into buckets by city. Both patterns reuse dictionary skills we already have, just applied inside a loop, so by the end of this lesson, we will be able to turn a flat list into a summary with only a few lines of code.

Why a Dictionary Is the Right Container

Before writing any code, let us think about why a dictionary fits these two tasks so well. A tally needs to associate each distinct value with a number, and a grouping needs to associate each shared attribute with a collection of records. In both cases, we have a label pointing to something that accumulates, which is exactly what a key-value mapping gives us.

The shape of the result differs, though, and that difference drives everything that follows:

  • A tally produces values that are numbers: {"apple": 3, "banana": 2}
  • A grouping produces values that are lists: {"London": ["Ada", "Sam"]}

The tricky part in both cases is the very first time we meet a key because there is nothing stored yet to add to. Python gives us two clean tools for that situation, and we will meet them in turn.

The Problem: Counting Without a Safe Default

Let us start with a small list of words and an empty dictionary that will hold our counts.

Python
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]

counts = {}
for word in words:
    counts[word] = counts[word] + 1  # This breaks!

The intent looks reasonable: take the current count for word, add one, and store it back. The problem is on the right-hand side. On the very first iteration, counts is still empty, so counts["apple"] asks for a key that does not exist, and Python raises a KeyError. The same failure happens for "banana" and "cherry" the first time each appears.

As we may recall from the dictionaries course, reading a missing key with square brackets is always an error. What we need is a way to say, "Give me the current count, or zero if this word is brand new."

The Tally Pattern with get()

That is precisely what get does. Replacing the bracket lookup with counts.get(word, 0) gives us a safe default and turns the broken line into the classic tally pattern.

Python
counts = {}
for word in words:
    counts[word] = counts.get(word, 0) + 1

That single line performs three steps, right to left:

  1. counts.get(word, 0) looks up the current count, returning 0 when the key is missing instead of raising an error
  2. + 1 adds this occurrence to that value
  3. counts[word] = ... stores the new number back under the same key, either creating the entry or overwriting it

Notice that get itself never changes the dictionary; the assignment on the left does all the writing.

Tracing the Loop

Watching the dictionary grow makes the pattern much easier to trust. The table below follows each iteration over words, showing what get returns and the resulting state of counts.

Iterationwordget(word, 0)counts after the line
1"apple"0{'apple': 1}
2"banana"0{'apple': 1, 'banana': 1}
3"apple"1{'apple': 2, 'banana': 1}
4"cherry"0{'apple': 2, 'banana': 1, 'cherry': 1}
5"banana"1{'apple': 2, 'banana': 2, 'cherry': 1}
6"apple"2{'apple': 3, 'banana': 2, 'cherry': 1}

New words enter with a default of 0 and immediately become 1, while repeated words have their existing number read and bumped up. The last row already holds the finished tally; adding a print after the loop confirms it:

Python
print("Counts:", counts)
text
Counts: {'apple': 3, 'banana': 2, 'cherry': 1}

The keys appear in the order each word was first seen, not in alphabetical or count order, because Python dictionaries preserve insertion order.

Scanning for the Most Frequent Value

A tally is rarely the end goal by itself: usually we want to know which value came out on top. Since counts is already a dictionary of value-to-number pairs, finding that winner just means walking through those pairs once and remembering the best one seen so far — an accumulator pattern similar in spirit to the tally itself.

Python
top_word = ""
top_count = 0
for word, count in counts.items():
    if count > top_count:
        top_word = word
        top_count = count

A few details make this small loop work correctly:

  • top_word and top_count start at "" and 0, a "nobody is winning yet" baseline that any real word and any real count of at least 1 will beat.
  • counts.items() hands us the key and the value together on each pass, so there is no need to look the value up again with counts[word].
  • The comparison uses a strict >. That single detail controls tie-breaking: once a word takes the lead, only a strictly higher count can replace it. A later word with the same count changes nothing, so whichever tied word is checked first while scanning keeps the title.

Tracing the loop against counts = {'apple': 2, 'banana': 2, 'cherry': 1} shows the accumulator settling on its answer, including how the tie between apple and banana is resolved:

wordcountcount > top_count?top_word aftertop_count after
— (start)——""0
apple22 > 0 → yesapple2
banana22 > 2 → noapple2
cherry11 > 2 → noapple2

apple takes the lead first, and even though banana reaches the very same count later, the strict > check means banana cannot take over. apple stays the winner simply because it was checked first.

One edge case is worth naming directly: if counts were empty, perhaps because the original list of words was empty, the loop body would never execute at all. top_word would stay "" and top_count would stay 0, untouched defaults rather than real answers. Real programs typically check whether the dictionary has entries before trusting this kind of result.

This scanning pattern is exactly what an upcoming practice asks us to build ourselves, right after the tally.

From Counting to Grouping: A Different Kind of Value

Now let us switch to the second pattern with a more realistic dataset: a list of records, where each record is a small dictionary describing one person.

Python
people = [
    {"name": "Ada", "city": "London"},
    {"name": "Max", "city": "Paris"},
    {"name": "Sam", "city": "London"},
]

Our goal is a dictionary that maps each city to the list of names of the people who live there, so "London" should end up holding two names. The shift in thinking is this: Instead of storing a number per key, we now store a list per key. That changes what a new key needs. A brand-new city cannot start at 0; it must start as an empty list [] so that we have something to call append on.

The Grouping Pattern with setdefault()

Here a one-line get falls short. Since get never modifies the dictionary, appending to the list it returns for a missing key would append to a temporary list that is thrown away immediately. We can make get work by storing the list first and appending second:

Python
by_city = {}
for person in people:
    city = person["city"]
    by_city[city] = by_city.get(city, [])
    by_city[city].append(person["name"])

That is two steps per record: the first line makes sure a list is stored under the city, and the second appends to the stored list. Python offers a method that does both in one call. setdefault(key, default) returns the existing value when the key is present and otherwise inserts the default into the dictionary and returns that stored default, so the same grouping loop shrinks to a single line in its body:

Python
by_city = {}
for person in people:
    city = person["city"]
    by_city.setdefault(city, []).append(person["name"])

Reading the loop body one piece at a time:

  • city = person["city"] pulls out the attribute we are grouping by, keeping the next line readable
  • by_city.setdefault(city, []) guarantees a list is stored under that city, whether it was already there or created just now
  • .append(person["name"]) adds the name to that stored list, so the change is visible in by_city

The chained call is the whole point: Because setdefault hands back the list that lives inside the dictionary, appending to it updates our grouping directly.

Seeing the Groups

Printing the result shows how the three records were distributed across two cities.

Python
print("Grouped by city:", by_city)
text
Grouped by city: {'London': ['Ada', 'Sam'], 'Paris': ['Max']}

Following the loop: "London" was new, so an empty list was inserted and "Ada" was appended; "Paris" was also new and collected "Max"; then "London" appeared again, setdefault returned the existing ['Ada'], and "Sam" joined it. The order inside each list matches the order in which the records appeared, which is often useful when the source data is already sorted.

Putting It Together and Choosing the Right Tool

Combining both halves gives us the complete program, with each pattern applied to its own dataset.

Python
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]

# Tally pattern: count occurrences by incrementing dict values
counts = {}
for word in words:
    counts[word] = counts.get(word, 0) + 1
print("Counts:", counts)

# Grouping pattern: collect items into lists keyed by an attribute
people = [
    {"name": "Ada", "city": "London"},
    {"name": "Max", "city": "Paris"},
    {"name": "Sam", "city": "London"},
]
by_city = {}
for person in people:
    city = person["city"]
    by_city.setdefault(city, []).append(person["name"])
print("Grouped by city:", by_city)

Each loop builds its own summary dictionary and prints it, producing two lines of output:

text
Counts: {'apple': 3, 'banana': 2, 'cherry': 1}
Grouped by city: {'London': ['Ada', 'Sam'], 'Paris': ['Max']}

The decision rule is short enough to memorize, as summarized here.

GoalMethodDefaultValue type
Accumulate numbersget0int
Collect itemssetdefault[]list

Three pitfalls are worth guarding against: omitting the default argument, which brings back None and errors; using get where setdefault is needed, which silently discards appended items; and creating one shared list outside the loop, which mixes every group together.

Conclusion and Next Steps

We now have two core patterns that appear in almost every data-processing task, plus a simple scanning trick for pulling a standout value out of a finished tally. The tally pattern with counts[key] = counts.get(key, 0) + 1 converts a list of values into a frequency map, and the grouping pattern with groups.setdefault(key, []).append(item) reorganizes flat records into labeled buckets. Scanning a dictionary with a "remember the best so far" accumulator, as we just did to find the most frequent word, is the same idea applied to values instead of keys. All of this works because a dictionary lets us look up a key and update it in a single pass through the data, and it all hinges on choosing a sensible default for keys we have not seen yet.

In the practices ahead, we will build tallies from scratch, dig out the most frequent item, group records by a shared attribute, and report how large each group turned out to be. Roll up your sleeves and try these patterns yourself: They will be a foundation for the list comprehensions that pair naturally with this kind of data work.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal