DA-01-010-P · Primer — Categorical Analysis with value_counts and Bar Charts
The idea in one sentence
When a column holds labels instead of numbers — a channel, a country, a status, a product tier — the first question is always "how often does each label show up, and what share of the whole does it own?", and pandas answers both with one method, value_counts(), which a bar chart then turns into something you can read at a glance.
Why it exists
A mean or a median tells you nothing useful about a column of words: there is no "average" of ["Organic", "Paid", "Email"]. Categorical columns are everywhere in real data — the plan someone signed up for, the city they live in, whether an order succeeded or failed — and the honest way to summarize them is to count the categories and see how the counts stack up. Counting by hand or with a manual loop is tedious and error-prone, so pandas bakes the operation into value_counts(), and because "which is biggest?" is easier to see than to read off a table, matplotlib turns those counts into bars whose heights you can compare instantly. The pairing exists so that the very first pass over any labelled column takes one line and produces both a ranked table and a picture.
How it works
Call value_counts() on a Series (one column) and it returns a new Series: the index is each distinct category, the values are how many times each appeared, and — the part people love — it comes back sorted from most common to least common, so the dominant category is already sitting on top. That gives you raw counts; to answer "what share of the whole?" pass normalize=True, which divides every count by the total so the numbers become proportions that sum to 1.0 (multiply by 100 for percentages). Because the result is itself a Series, you can chain .plot(kind="bar") straight onto it and matplotlib draws one bar per category, tallest first — the shape of the distribution made visual. Reach for counts when you care about volume ("how many paid sessions?") and proportions when you care about mix ("what fraction of traffic is paid?"); they answer different questions off the same data. Here it is on tiny inline data:
import pandas as pd
import matplotlib.pyplot as plt
df = pd.DataFrame({
"channel": ["Organic", "Paid", "Organic", "Email",
"Organic", "Paid", "Email", "Organic",
"Referral", "Paid"]
})
counts = df["channel"].value_counts()
print(counts) # Organic 4, Paid 3, Email 2, Referral 1 (sorted, top first)
shares = df["channel"].value_counts(normalize=True)
print(shares.round(2)) # Organic 0.40, Paid 0.30, Email 0.20, Referral 0.10 -> sums to 1.0
counts.plot(kind="bar") # bar chart: Organic is the tallest bar, Referral the shortest
plt.title("Sessions by acquisition channel")
plt.ylabel("count")
plt.tight_layout()
plt.show() # the picture shows Organic dominating and Referral a thin sliver
The one gotcha
By default value_counts() silently drops missing values (NaN), and normalize=True computes shares over only the rows it kept — so your proportions can quietly answer a different question than you think. If a channel column has 7 rows but 2 are blank, value_counts() reports counts for the 5 known rows and normalize=True divides by 5, not 7 — so "40% Organic" secretly means "40% of the rows where channel was recorded," which overstates every category whenever missingness is common. The fix is to make missing data a visible category with dropna=False, then decide on purpose whether the blanks belong in your denominator:
import pandas as pd
s = pd.Series(["A", "B", "A", None, "B", "A", None])
print(s.value_counts()) # A 3, B 2 (the 2 blanks vanished!)
print(s.value_counts(dropna=False)) # A 3, B 2, NaN 2 (blanks now visible)
print(s.value_counts(normalize=True).round(3)) # A 0.600, B 0.400 -> divided by 5
print(s.value_counts(normalize=True, dropna=False)) # A 0.429, B 0.286, NaN 0.286 -> by 7
Notice the two proportion lines disagree about Organic-vs-anything by a wide margin purely because one hid the blanks. Always eyeball how many rows are missing before you trust a proportion — the number that would fool you is the one whose denominator you never checked.
See it — counts vs proportions from one categorical column
flowchart TD
A["Categorical column<br/>(labels, not numbers)"] --> B{"Any missing<br/>values (NaN)?"}
B -->|"Yes, and they matter"| C["value_counts(<br/>dropna=False)"]
B -->|"No, or ignore them"| D["value_counts()"]
C --> E{"What question<br/>am I asking?"}
D --> E
E -->|"How many? (volume)"| F["Raw counts,<br/>sorted top-first"]
E -->|"What share? (mix)"| G["normalize=True<br/>proportions sum to 1.0"]
F --> H["plot(kind='bar')<br/>tallest bar dominates"]
G --> H
The one-line takeaway
value_counts() ranks a categorical column's labels by frequency, normalize=True turns those counts into shares of the whole, and .plot(kind='bar') makes the winner and the long tail obvious — just remember it hides NaN unless you say dropna=False.
Where you'll meet it
This is the first thing you do to almost any new dataset: run value_counts() on every categorical column to learn what's in it, catch typos ("USA" vs "usa" show up as two separate bars — a data-quality alarm), and spot class imbalance before it wrecks a model. You'll use the counts-vs-proportions choice constantly — a dashboard headline usually wants proportions ("62% of orders shipped on time"), while a capacity plan wants raw counts ("14,000 orders"). Bar charts of value_counts() output are the workhorse visual of exploratory analysis, and the moment you compare two groups you'll reach for the same idea one level up (groupby then counts) to ask "does the channel mix differ between new and returning users?" Get comfortable here and you have the core reflex of reading categorical data: count it, normalize it, plot it, and always check what fell out of the denominator.
Named concepts: value_counts(), normalize for proportions, bar charts for categories
