All articles

KajoData / python en

Counting list elements in Python: dict, Counter and hashing

I turn a list into a frequency table and check what the numbers represent, including missing categories, ties and the surprising equality of True and 1.

Kajo RudzińskiPublished Updated 12 min read

A sorter separates geometric objects into stacks of different heights, leaving one container empty.

“How many elements are in this list?” can mean several things. Sometimes I need its length. Sometimes I want the occurrences of one value. In other cases, I need a complete frequency table: each category and the number of records belonging to it. Before choosing a tool, I establish that question. Otherwise, it is remarkably easy to calculate the wrong quantity perfectly.

I will use an ordinary dictionary and Counter from Python’s standard library. The examples are synthetic, checked with Python 3.14.4 and require no pandas installation. Each block defines its own data and runs independently. We will count contact channels, then examine missing values, composite keys, ties and cases where apparently different elements become one category.

Length, one value’s frequency and distinct values

Start with six records: three contacts by email, two by chat and one by phone. This list represents contacts, not people. Someone who sent three messages contributes three records. Counting unique people would require a person identifier, which the list does not contain. No counting method can reconstruct that information from a channel name.

# example: questions
from collections import Counter

channels = ["email", "chat", "email", "phone", "email", "chat"]
counts = Counter(channels)
print(len(channels), channels.count("email"), len(counts))
print(dict(counts), counts.total())

The first result is 6 3 3: six elements, three occurrences of email and three stored keys. The second is {'email': 3, 'chat': 2, 'phone': 1} with a total of six. Counter is a class derived from dict, rather than a built-in function available without importing anything. It creates an object designed for working with counts.

list.count is clear when you ask about one particular value. Repeatedly scanning the whole list for every category does unnecessary repeated work. A dictionary or Counter can build all counts in one pass. I am not promising a specific speedup: measured performance depends on the input and environment, and readability often matters most for a small collection.

Build the counter with an ordinary dictionary first

Writing the counting loop once makes the mechanism concrete. A key represents the category; its value records how often that category has appeared. Each new occurrence adds one. When a key is absent, I start from zero. This also helps distinguish adding more occurrences from replacing a previously calculated count with a new value.

# example: dictionary
channels = ["email", "chat", "email", "phone", "email", "chat"]
counts = {}
for channel in channels:
    counts[channel] = counts.get(channel, 0) + 1
print(counts)
print(sum(counts.values()) == len(channels))

The dictionary contains the same three frequencies, and the check returns True. get provides a default zero when the key is absent. Calling get does not insert the key: insertion or updating happens in the assignment. An explicit if channel in counts version is equivalent, but this shorter expression still makes the operation understandable.

The sum check works because each input element must belong to exactly one category. Do not copy that rule blindly to records with several tags. Counting tags can legitimately produce more occurrences than input records. A useful check follows the definition of the unit being counted, rather than merely comparing two convenient numbers that happen to match in one demonstration.

Counter accepts elements or already aggregated counts

The constructor can receive an iterable of elements or a mapping from keys to counts. Those are different forms of input. A list of strings describes individual occurrences. A dictionary with numeric values supplies existing aggregates. Passing a string by itself counts its characters because strings are iterable too. That behavior is useful when it matches the question you intended.

# example: inputs
from collections import Counter

print(dict(Counter("aba")))
print(dict(Counter(["aba"])))
counts = Counter({"email": 3, "chat": 2})
counts.update(["email", "phone"])
counts.update({"chat": 4})
print(dict(counts))

The results are {'a': 2, 'b': 1}, {'aba': 1} and {'email': 4, 'chat': 6, 'phone': 1}. Counter.update adds counts. It differs from ordinary dict.update, which would replace an existing value. Here the four additional chat contacts were added to the two already present, producing six rather than four.

The Counter reference describes the constructor and methods. In an import workflow, I check whether the input contains individual events or previously calculated totals. Confusing those forms can produce an object of the right type with the wrong meaning. Similar variable names make this particularly easy, so I name the input according to its actual level of aggregation.

What a hashable key means

Dictionary keys need a stable hash and compatible equality rules. Built-in strings and numbers fit that role. Built-in lists and dictionaries are unhashable. A tuple works only when its elements are hashable too. Turning a list into a tuple therefore does not solve the problem if it still contains another list inside.

# example: keys
from collections import Counter

pairs = [("email", "open"), ("chat", "closed"), ("email", "open")]
print(dict(Counter(pairs)))
for value in ([1, 2], {"id": 1}, ([1, 2],)):
    try:
        hash(value)
    except TypeError:
        print("unhashable")

The first pair occurs twice and the second once. The next three checks print unhashable. A hash is not a unique record identifier: different keys can share a hash, and dictionaries also check equality. I do not replace identifiers with hash() results or treat those results as durable identifiers across independent program runs.

The older article broadly classified “class objects” as unhashable. That statement was too broad. Instances of user-defined classes may be hashable, depending on their equality and __hash__ definitions. Python’s data model explains the rules, and the glossary defines the concept. For an ordinary report, I usually choose an explicit text key or a tuple of fields instead of inventing a custom object protocol.

Decide which values represent the same category

"Email", "email" and " email " are different strings. If the source uses them for one channel, I need a normalization rule. If capitalization is part of an identifier, changing it could destroy information. I therefore establish the field’s meaning before cleaning it and retain the raw version for comparison and diagnosis.

# example: normalization
from collections import Counter

raw = ["Email", "email", " email ", "Chat", None, ""]
normalized = []
missing = 0
for value in raw:
    if value is None or value.strip() == "":
        missing += 1
    else:
        normalized.append(value.strip().casefold())
counts = Counter(normalized)
print(dict(counts), missing)
print(counts.total() + missing == len(raw))

The result is {'email': 3, 'chat': 1} 2, and the reconciliation check returns True. In this sample, I explicitly treat None and an empty string as missing. They do not enter the counts of known channels, but their number remains visible. The report still accounts for all six input records. casefold supports caseless text comparison; it does not recognize synonyms or correct spelling mistakes.

The example assumes nonmissing values are strings. If other types are possible, validate them instead of converting everything with str(value). A blanket conversion could turn a genuine missing value into a string category. For more involved category corrections, pandas replace may be useful, but a library still cannot decide which meanings should be merged on your behalf.

A lens compares scattered square tiles with an orderly stack, while a round tile remains separate.

True and 1 can share a key

Python compares True, 1 and 1.0 as equal, and their hashes agree. They can therefore represent the same dictionary key. When a list mixes types, its frequency table may be surprising. This is a reason to validate input, rather than assuming a counter provides a neutral description of every possible collection.

# example: mixed-types
from collections import Counter

values = [True, 1, 1.0, False, 0, "1"]
counts = Counter(values)
print(counts[True], counts[False], counts["1"], len(counts))
typed = Counter((type(value).__name__, value) for value in values)
print(len(typed), typed[("bool", True)], typed[("int", 1)])

The first result is 3 2 1 3; the second is 6 1 1. Including the type name in a composite key separates all six positions in this particular sample. That is not a universal strategy for comparing every kind of Python object. For a simple reporting field, requiring one input type is usually clearer than later explaining separate categories for numeric one and Boolean truth.

None is also hashable and does not disappear from Counter automatically. Decide whether to keep a missing category or report missing values beside the counts. Numerical libraries introduce other representations of missing data with less intuitive equality behavior. Normalize those representations according to the input contract before counting, rather than trying to repair an unexplained frequency table afterward.

Ties and absent categories need reporting rules

most_common returns the most frequent elements. For equal counts, it preserves their first-encounter order. That is deterministic for a given input order, but sources may deliver the same events in different orders. If the report needs alphabetical tie-breaking, I encode that separately in the sorting rule rather than assuming frequency ranking already provides it.

# example: ranking
from collections import Counter

counts = Counter(["phone", "email", "chat", "email", "chat"])
print(counts.most_common(2))
print(sorted(counts.items(), key=lambda item: (-item[1], item[0])))
print(counts["web"], "web" in counts)
counts["web"] = 0
print(len(counts), dict(+counts))

The top two are [('email', 2), ('chat', 2)]. Explicit sorting puts chat first, followed by email and then phone. Reading the absent key web returns zero without inserting it, so membership testing returns False. Assigning zero explicitly inserts the key and increases the number of stored keys to four.

Unary plus creates a counter containing only positive counts. It can clean a result, but it also removes a category deliberately shown as zero. For a fixed reporting universe, I prefer a separate list of expected names and look up each name’s count. A channel with no contacts then appears in the output even if it never occurred in the input.

Subtraction can preserve or discard negative results

A counter can hold zero and negative counts. They may describe differences between expected and observed quantities, but they no longer represent ordinary occurrences in a raw list. Also distinguish the subtract method from the subtraction operator between two counters. The two operations do not preserve nonpositive results in the same way.

# example: subtraction
from collections import Counter

expected = Counter({"email": 3, "chat": 1})
observed = Counter({"email": 1, "chat": 2})
signed = expected.copy()
signed.subtract(observed)
print(dict(signed), signed.total())
print(dict(expected - observed))
print(dict(+signed))

The signed result is {'email': 2, 'chat': -1}, with a total of one. The subtraction operator retains only {'email': 2}, as does unary plus on the signed result. If negative one communicates an excess relative to a plan, removing it changes the report. Decide whether you are showing a signed difference or only positive shortages.

At this stage, len(counter) is not an event count. It counts stored keys, including keys whose values are zero or negative. total() sums the values and can therefore include negative contributions. Both operations remain valid, but their labels must follow what the object now represents, rather than borrowing the interpretation from the original raw-input example.

Count records using selected fields

A list of dictionaries cannot be used directly as Counter keys. Rather than converting whole records to strings, I select fields defining the grouping. For synthetic requests, that might mean channel and status. Leaving out the identifier means different requests in one group should count together. Including it asks a different question and usually creates more groups.

# example: records
from collections import Counter

rows = [
    {"id": "A", "channel": "email", "status": "open"},
    {"id": "B", "channel": "email", "status": "open"},
    {"id": "C", "channel": "chat", "status": "closed"},
]
counts = Counter((row["channel"], row["status"]) for row in rows)
print(dict(counts))
print(counts.total() == len(rows))

The result contains two groups with counts two and one, followed by True. A missing required field raises KeyError instead of silently assigning an arbitrary category. If absence is allowed, handle it deliberately. Using get with a default label makes sense only after checking that the label cannot collide with a legitimate input value.

For many columns, numerical aggregates and subsequent joins, pandas or SQL may be more convenient. Counter is especially clear when an iterable contains the items to count and frequency is the main result needed. Tool choice does not remove the need to define the data’s level. Before counting requests, check that the rows are not actually a history of status changes for those requests.

Reconcile the result and understand memory use

For a plain input list without filtering, the sum of counts should equal its length. After excluding missing values, I compare the counted total plus rejected records with the input count. For rankings, I reconcile the full result before choosing the top categories. The top three usually do not sum to the whole dataset, which matters when deciding the denominator of a percentage chart.

A counter can be updated in batches without retaining every event. It still stores a key for each distinct value. If almost every input record has a different identifier, the counter will also be large. Streaming reduces input storage but does not eliminate the cost of retaining every distinct category. A sufficiently large task may be better aggregated in a database.

I also check whether successive batches overlap. update will add everything it receives, including events imported twice. It does not detect a repeated import or make a workflow idempotent. The same applies when merging already aggregated counters: compatible keys and correct addition do not prove that the aggregates describe disjoint events.

Keep the processing order explicit: validation and agreed normalization, counting, then ranking and percentages. Changing normalization after ranking may merge categories and alter the order. If a report compares periods, apply consistent rules across them or explain the difference. Otherwise, a change in category definitions can masquerade as a change in the underlying activity.

An iterator can be consumed only once

A list allows repeated visits to its elements, while an iterator advances during reading. After Counter consumes a generator, another count of that same generator sees an empty stream. The first calculation did not delete records from a file; the object tracking traversal has reached its end. This frequently explains different results from two apparently identical counting expressions.

# example: iterator
from collections import Counter

events = (value for value in ["email", "chat", "email"])
first = Counter(events)
second = Counter(events)
print(first.total(), second.total())

The result is 3 0. For a second pass, create a new iterator from the source or deliberately materialize the input as a list. Materialization needs memory, so it is not an automatic choice for a large export. Another option is to calculate the required validation totals during the first pass and avoid rereading entirely.

When debugging, inspect the collection type and earlier operations as well as the values. Previewing a few elements of a generator consumes them too. I teach the counting operation with a list first, where comparison is easy, then change how input is supplied. That separates a counting mistake from a mistake in the flow of input data.

Exercise: a report with zeros and missing values

Use the normalization example to report the expected channels email, chat and phone. Their counts should be three, one and zero, with two missing values reported separately. Email represents three quarters of known channels, or 75%. It represents 50% of all six records. Write down which denominator answers the reporting question instead of silently selecting the more flattering percentage.

Next, add one phone contact, one empty string and a new category, web. Check whether displaying only expected channels hides the new category. You can reject unexpected values as a contract violation or display them separately; they should not vanish without explanation. This checks whether the process accounts for the whole input or merely produces a tidy-looking table.

Finally, run the solution on an empty list and a list containing only missing values. Counts can be zero, but a percentage with a zero denominator requires an explicit unavailable result. Combine this exercise with CSV reading and list selection. You will have a small workflow in which every number and every excluded observation can be explained.

Key takeaways

How does len differ from Counter?

len gives the number of elements in a collection. Counter stores frequencies of hashable values; len of the Counter itself counts stored keys.

Why cannot a list be a Counter key?

Built-in lists are unhashable. Select a stable key describing what you want to count, such as a tuple of two text fields, instead of using an entire mutable record.

Does Counter automatically ignore None?

No. None is a valid key and will be counted. Preserve, filter or report missing values separately according to the data definition.

Kajo Rudziński

Newsletter KajoData

Let’s keep in touch.

I write about data analysis, learning and changing careers. My newsletter and the sign-up form are in Polish. If that works for you, I’d be glad to see you there.