The frequency table that should be your first command on any new column — counts, shares, missing data, and instant histograms in one call.
Every dataset lies by omission — value_counts() is the five-second audit that catches it.
value_counts() returns a Series mapping each distinct value to how often it appears, most frequent first. NaNs are excluded by default — flip dropna=False to count them. normalize=True gives proportions instead of raw counts, and bins=N buckets a numeric column into a quick histogram. The DataFrame version counts whole-row combinations (pass subset= to limit columns) — a groupby-size report in one line. Since pandas 2.0 the returned Series is named 'count'; since 3.0 ties keep a stable order and sort=False preserves first-appearance order instead of sorting by column values.
Real columns are messy, and value_counts() is the fastest honest survey of the mess: which category dominates, whether 'web ' and 'web' are both in there, how many rows are missing entirely. It drives everyday decisions — is this dataset imbalanced, does a 'rare' category even have enough rows to model, how big is the queue by status? Before you filter, group, or model anything, spend five seconds with value_counts(); it routinely kills assumptions you were about to build on.
import pandas as pd
tickets = pd.DataFrame({
"ticket": range(1, 9),
"status": ["open", "closed", "open", "pending",
"open", "closed", "open", None],
})
print(tickets["status"].value_counts())status open 4 closed 2 pending 1 Name: count, dtype: int64
The support-queue triage: 4 open, 2 closed, 1 pending — and the 8th ticket's missing status silently vanished. dropna=True is the default; the missing row only shows up if you ask (next example).
survey = pd.DataFrame({
"respondent": range(1, 6),
"plan": ["pro", "free", None, "pro", "free"],
})
print(survey["plan"].value_counts(dropna=False))plan pro 2 free 2 NaN 1 Name: count, dtype: int64
dropna=False surfaces the skipped answer. For surveys and form data this is the difference between 'our two plans split 50/50' and 'a third of respondents never answered'.
orders = pd.DataFrame({
"channel": ["web", "app", "web", "partner",
"app", "web", "web", "app"],
})
print((orders["channel"].value_counts(normalize=True) * 100).round(1).to_string())channel web 50.0 app 37.5 partner 12.5
normalize=True returns proportions; multiply by 100 and round for a stakeholder-ready share column. No numpy, no loops, no pivot table.
errors = pd.Series(
["timeout", "auth", "timeout", "dns", "auth", "timeout"],
name="error",
)
report = errors.value_counts().reset_index()
print(report.to_string(index=False)) error count
timeout 3
auth 2
dns 1
value_counts() returns a Series, not a DataFrame — .reset_index() converts it for CSV exports and reports. Since pandas 2.0 the count column arrives named 'count', so the header is already right.
| Flag | Meaning |
|---|---|
normalize | return proportions (sum to 1.0) instead of raw counts — multiply by 100 for percentages |
sort | True (default) orders by frequency; False keeps first-appearance order (behavior fixed and made stable in pandas 3.0) |
ascending | True flips to rarest-first — the fastest way to find typos and one-off categories |
bins | instead of counting values, cut a numeric column into N half-open bins — a one-line histogram |
dropna | True (default) silently excludes NaN; False counts missing as its own row — flip it on survey data |
subset | DataFrame version only: which columns define the combination being counted, e.g. subset=['region', 'device'] |
.reset_index() | the standard idiom to turn the result back into a DataFrame with columns 'value-name' and 'count' for export |
value_counts() is absent from the pandas 0.4.x sources but present in the v0.5.0 tag — released October 24, 2011 — as a hand-written defaultdict loop in series.py returning counts in descending order. The convenience knobs trickled in over the next years: normalize= around 0.11 (2013), sort/ascending/bins by 0.13 (2013).
Counting row combinations was GitHub issue #5377, opened in 2013, and it sat gathering upvotes until pandas 1.1.0 shipped DataFrame.value_counts() in July 2020. Pandas 3.0 then fixed the semantics: ties in the frequency sort are now stable, and sort=False preserves appearance order — before 3.0 it sorted by column values, and the tie order was unstable.
The 2011 original was pure Python: a defaultdict loop over dropna().values, wrapped in a Series and sorted. Today Series.value_counts() dispatches into pandas' Cython layer — the khash hash table computes unique values and counts in one pass over the array, and the same machinery serves Series, Index, and the DataFrame row-combination version. Practical consequence: value_counts() is O(n) and fast, but if you only need the distinct values without counts, .unique() does strictly less work. On duplicate-heavy columns (few distinct values), a groupby-based count can also beat it, since groupby uses dictionary encoding that thrives on repetition.