Use value_counts() to count values in a column or combinations of columns. Choose a missing-value policy, state the denominator for percentages and normalise labels before building a frequency report.
Use Series.value_counts() for one column and DataFrame.value_counts(subset=...) for combinations. Both return frequencies. The method name is value_counts(). count() counts non-missing entries, while unique() returns distinct values without frequencies. Only the Series version accepts bins.
The synthetic table contains Three three times, Four twice and Five once. The result has values as its index and counts as its data.
import pandas as pd
import numpy as np
def student_data():
return pd.DataFrame({"NAME": ["Ravi", "Raju", "Alex", "Ron", "King", "Jack"], "ID": [1, 2, 3, 4, 5, 6], "MATH": [80, 40, 70, 70, 60, 30], "CLASS1": ["Four", "Three", "Three", "Four", "Five", "Three"]})
df = student_data()
counts = df["CLASS1"].value_counts()
print(counts)
assert counts.to_dict() == {"Three": 3, "Four": 2, "Five": 1}Expected output
CLASS1
Three 3
Four 2
Five 1
Name: count, dtype: int64normalize=True divides by the number of included observations. Multiply by 100 for percentages. Rounding displayed percentages can make their sum differ slightly from 100.
classes = student_data()["CLASS1"]
proportions = classes.value_counts(normalize=True)
print(proportions)
print("Percentages:")
print(proportions.mul(100).round(2))
assert np.isclose(proportions.sum(), 1)
assert proportions.loc["Three"] == 0.5Expected output
CLASS1
Three 0.500000
Four 0.333333
Five 0.166667
Name: proportion, dtype: float64
Percentages:
CLASS1
Three 50.00
Four 33.33
Five 16.67
Name: proportion, dtype: float64sort=False avoids sorting by frequency. For a deliberate alphabetical report, use sort_index(). ascending=True sorts frequencies from smallest to largest. Do not rely on ties to communicate a business priority.
classes = student_data()["CLASS1"]
print("No frequency sort:")
print(classes.value_counts(sort=False))
print("Alphabetical:")
print(classes.value_counts().sort_index())
print("Lowest frequency first:")
print(classes.value_counts(ascending=True))
assert classes.value_counts(ascending=True).tolist() == [1, 2, 3]Expected output
No frequency sort:
CLASS1
Four 2
Three 3
Five 1
Name: count, dtype: int64
Alphabetical:
CLASS1
Five 1
Four 2
Three 3
Name: count, dtype: int64
Lowest frequency first:
CLASS1
Five 1
Four 2
Three 3
Name: count, dtype: int64dropna=True excludes missing values by default. dropna=False counts them. With normalization the choice changes the denominator, so state whether a percentage is of known values or all records.
values = pd.Series(["paid", "paid", "test", pd.NA], dtype="string")
known = values.value_counts()
all_rows = values.value_counts(dropna=False)
print("Known values:")
print(known)
print("Include missing:")
print(all_rows)
print("Known proportion:", values.value_counts(normalize=True).loc["paid"])
print("All-row proportion:", values.value_counts(normalize=True, dropna=False).loc["paid"])
assert known.sum() == 3 and all_rows.sum() == 4
assert values.value_counts(normalize=True, dropna=False).loc["paid"] == 0.5Expected output
Known values:
paid 2
test 1
Name: count, dtype: Int64
Include missing:
paid 2
test 1
<NA> 1
Name: count, dtype: Int64
Known proportion: 0.6666666666666666
All-row proportion: 0.5Use a fresh student table and make King’s mark missing. The repeated score 70 occurs twice. Equal-frequency scores may have a different display order across versions; check the counts rather than assuming a tie order.
df = student_data()
df["MATH"] = df["MATH"].astype("Float64")
df.loc[4, "MATH"] = pd.NA
print(df["MATH"].value_counts(dropna=False))
print("Exclude missing:")
print(df["MATH"].value_counts(dropna=True))
assert df["MATH"].value_counts().loc[70] == 2
assert df["MATH"].value_counts(dropna=False).sum() == 6
assert df["MATH"].value_counts().sum() == 5Expected output
MATH
70.0 2
80.0 1
40.0 1
30.0 1
<NA> 1
Name: count, dtype: Int64
Exclude missing:
MATH
70.0 2
80.0 1
40.0 1
30.0 1
Name: count, dtype: Int64The Series bins option groups numeric observations into intervals. Integer bins create equal-width intervals from the data range. An edge sequence creates specified widths. For labels and explicit boundary rules use pd.cut() followed by value_counts().
marks = student_data()["MATH"]
print("Three equal-width bins:")
print(marks.value_counts(bins=3).sort_index())
print("Specified edges:")
fixed = marks.value_counts(bins=[1, 50, 70, 90]).sort_index()
print(fixed)
assert fixed.tolist() == [2, 3, 1]
assert fixed.sum() == 6Expected output
Three equal-width bins:
(29.948999999999998, 46.667] 2
(46.667, 63.333] 1
(63.333, 80.0] 3
Name: count, dtype: int64
Specified edges:
(0.999, 50.0] 2
(50.0, 70.0] 3
(70.0, 90.0] 1
Name: count, dtype: int64Select the fields that define a combination. Including an order ID that is unique on every row would usually make each full row appear only once. The output for multiple columns uses a MultiIndex.
orders = pd.DataFrame({"order_id": [1, 2, 3, 4, 5], "product": ["pen", "pen", "book", "pen", "book"], "status": ["paid", "paid", "paid", "test", "paid"]})
counts = orders.value_counts(subset=["product", "status"])
print(counts)
print("Flat table:")
print(counts.rename("orders").reset_index())
assert counts.loc[("pen", "paid")] == 2
assert counts.sum() == len(orders)Expected output
product status
pen paid 2
book paid 2
pen test 1
Name: count, dtype: int64
Flat table:
product status orders
0 pen paid 2
1 book paid 2
2 pen test 1DataFrame.value_counts() normally omits rows missing any selected field. Unselected fields do not affect that omission. dropna=False includes missing combinations. Pandas 3 preserves data order with sort=False; older versions sorted combination labels.
orders = pd.DataFrame({"product": ["pen", "pen", None], "status": ["paid", None, "paid"], "note": [None, None, None]})
known = orders.value_counts(subset=["product", "status"])
all_rows = orders.value_counts(subset=["product", "status"], dropna=False, sort=False)
print("Complete combinations:")
print(known)
print("Include missing combinations:")
print(all_rows)
assert known.sum() == 1
assert all_rows.sum() == 3Expected output
Complete combinations:
product status
pen paid 1
Name: count, dtype: int64
Include missing combinations:
product status
pen paid 1
NaN 1
NaN paid 1
Name: count, dtype: int64Use a categorical dtype or reindex to a reporting list when a category must appear even if absent. Check unexpected labels first: converting them to a fixed categorical dtype can turn them into missing values.
values = pd.Series(["Low", "High", "Low"], dtype=pd.CategoricalDtype(["Low", "Medium", "High"], ordered=True))
counts = values.value_counts(sort=False)
print(counts)
assert counts.to_dict() == {"Low": 2, "Medium": 0, "High": 1}
assert counts.index.tolist() == ["Low", "Medium", "High"]Expected output
Low 2
Medium 0
High 1
Name: count, dtype: int64This synthetic extract includes spelling case, surrounding spaces, a blank product and missing values. Keep raw labels. Trim text and use casefold() as a comparison key before counting. This combines text variants only; it does not prove two differently named products are the same product.
raw = pd.DataFrame({"order_id": [101, 102, 103, 104, 105, 106], "product": [" Pen ", "PEN", "Book", " ", None, "pen"], "status": ["paid", "paid", "paid", "paid", "paid", "test"], "amount": [5, 7, 10, 3, 4, 2]})
cleaned = raw.copy()
product = raw["product"].astype("string").str.strip()
cleaned["product_key"] = product.mask(product.eq("")).str.casefold()
paid = cleaned.loc[cleaned["status"].eq("paid")].copy()
counts = paid["product_key"].value_counts(dropna=False)
print("Paid orders, including missing products:")
print(counts)
print("Review original labels:")
print(raw.loc[cleaned["product_key"].isna()])
assert counts.loc["pen"] == 2
assert counts.sum() == 5
assert cleaned["product_key"].isna().sum() == 2Expected output
Paid orders, including missing products:
product_key
pen 2
<NA> 2
book 1
Name: count, dtype: Int64
Review original labels:
order_id product status amount
3 104 paid 3
4 105 NaN paid 4value_counts() counts occurrences, not revenue. Use groupby() for amount aggregation. This report uses only known product keys; its percentages therefore describe those orders, not all paid orders. Missing products remain in the review count.
known_paid = paid.loc[paid["product_key"].notna()].copy()
summary = known_paid["product_key"].value_counts().rename("orders").to_frame()
summary["percent_of_known"] = summary["orders"].div(summary["orders"].sum()).mul(100).round(2)
summary["revenue"] = known_paid.groupby("product_key")["amount"].sum()
print(summary)
print("Paid orders needing product review:", int(paid["product_key"].isna().sum()))
print(summary.reset_index().to_csv(index=False).rstrip())
assert summary.loc["pen", "orders"] == 2
assert summary.loc["pen", "revenue"] == 12
assert summary["orders"].sum() == 3
# Optional Colab export:
# summary.reset_index().to_csv("product_frequency_report.csv", index=False)Expected output
orders percent_of_known revenue
product_key
pen 2 66.67 12
book 1 33.33 10
Paid orders needing product review: 2
product_key,orders,percent_of_known,revenue
pen,2,66.67,12
book,1,33.33,10Why are blank strings counted? They are values until you turn blanks into missing values. Why do percentages change with dropna? The denominator changes. Why are row combinations all counted once? You may have included a unique ID. Does normalization count unique customers? It counts included observations; define and deduplicate customer records first when needed. Does counting correct spelling? No; define a normalisation or mapping rule. Does bins count missing amounts? Audit missing and out-of-range values separately; use cut() when you need explicit band rules. Continue with case conversion, duplicate removal and bar charts.
For statuses paid, test, paid and a missing value, include missing values in the count. Calculate paid as a percentage of all four observations, then compare the percentage of known observations.
The paid share is 50% of all observations and about 66.67% of known observations.
status = pd.Series(["paid", "test", "paid", pd.NA], dtype="string")
counts = status.value_counts(dropna=False)
all_percent = status.value_counts(normalize=True, dropna=False).loc["paid"] * 100
known_percent = status.value_counts(normalize=True).loc["paid"] * 100
print(counts)
print("All observations:", round(all_percent, 2))
print("Known observations:", round(known_percent, 2))
assert all_percent == 50
assert np.isclose(known_percent, 200 / 3)Expected output
paid 2
test 1
<NA> 1
Name: count, dtype: Int64
All observations: 50.0
Known observations: 66.67Open in Google Colab View on GitHub
Run the examples in order, change categories and missing-value settings, then compare frequencies and percentages. Save your own copy to keep edits. All sample tables are included in the notebook.
Continue with the Data Cleaning hub, where(), loc, at, NumPy where(), fillna() and iloc.
Reference: Pandas DataFrame.value_counts documentation.
Author & Instructor at plus2net
I write and maintain practical tutorials on Python, PHP, SQL, JavaScript, HTML, jQuery, and web development at plus2net. The tutorials focus on clear explanations, working examples, and code that readers can test and adapt while learning.