Taming Wide DataFrames with pandas Categoricals: When Integer Codes Beat Strings
Low-cardinality object columns waste memory. pandas Categorical stores integer codes instead of strings, cutting RAM and speeding up groupby—if you avoid high-cardinality traps.
16 Nov 2025, 09:53 UTC

The repeated-string tax
You load a table with a million rows and a status column containing only four values: active, pending, closed, and archived. The DataFrame feels heavy. When you inspect it with df.memory_usage(deep=True), that single object column can consume far more RAM than the numeric columns beside it. The reason is that pandas stores object columns as arrays of Python object pointers. Even when the same short string appears a million times, the column pays the pointer overhead for every row, and the string objects themselves carry Python's per-object memory tax.
How Categorical changes the storage model
The Categorical dtype attacks this by separating the values from the vocabulary. Instead of storing a string for every row, pandas stores a compact array of integer codes—one integer per row—and a separate, small table of unique category labels. Think of it as a lookup table: the integer 0 might mean "active", 1 means "pending", and so on. Because integers are fixed-width and much smaller than string objects, memory often drops sharply for low-cardinality columns.
This representation also changes how pandas executes common operations. Grouping, merging, and value_counts can work on the integer codes rather than repeatedly hashing and comparing full strings. The first conversion to Categorical has a small upfront cost while pandas builds the category table, but subsequent operations on that column tend to run faster.
Categories can be unordered (the default) or ordered. Ordered categories are useful for ordinal data—such as "low", "medium", "high"—because sorting and comparisons respect your defined order instead of falling back to alphabetical order.
A practical conversion workflow
Suppose you have a DataFrame with a low-cardinality text column and you want to reduce its footprint. The following pattern, run in a Python script or notebook with pandas installed, shows the core steps. No special permissions are required.
import pandas as pd
import numpy as np
# Build a representative low-cardinality column
n = 1_000_000
df = pd.DataFrame({
"status": np.random.choice(
["active", "pending", "closed", "archived"], size=n
),
"amount": np.random.randn(n),
})
# 1. Measure the baseline
baseline_mb = df.memory_usage(deep=True).sum() / 1024**2
print(f"Baseline: {baseline_mb:.1f} MB")
# 2. Convert the column
df["status"] = df["status"].astype("category")
# 3. Measure again
optimized_mb = df.memory_usage(deep=True).sum() / 1024**2
print(f"After conversion: {optimized_mb:.1f} MB")
# 4. Grouping now operates on integer codes internally
result = df.groupby("status", observed=True)["amount"].mean()
# 5. For ordinal data, define an explicit order
size_order = ["small", "medium", "large"]
df["size"] = pd.Categorical(
df["status"].map(lambda x: "small"), # replace with real logic
categories=size_order,
ordered=True,
)
Expected checks: After step 3, the status column's memory usage should be a small fraction of its original size. In step 4, the groupby call should complete without error and return one row per category. If you are benchmarking, wrap the groupby call in %timeit (in a Jupyter context) or timeit.repeat(...) to compare against the unconverted object column.
Relevant risks: The conversion in step 2 mutates the column in place. If you need the original strings later for text processing, keep a copy or convert back with .astype(str).
Where Categorical stops helping
Categorical is not a universal optimization. It works best when the number of unique values is small relative to the number of rows. If a column has high cardinality—say, user IDs, timestamps, or free-text notes—the category table grows to hold every unique value. At that point you are storing both the integer codes and a categories array nearly as large as the original strings, so memory savings disappear and you may even use more RAM than before.
String methods are another common stumbling block. Pandas does not route the .str accessor through the category table. If you need .str.contains(), .str.extract(), or similar operations, you must convert back to object strings first:
# This fails or behaves unexpectedly on a categorical column
# df["status"].str.contains("act")
# Convert explicitly
mask = df["status"].astype(str).str.contains("act")
Finally, be careful when assigning new data. If a value was not present when you created the categories, pandas treats it as NaN unless you explicitly add it to the category list with .cat.add_categories([...]).
An actionable rule of thumb
Before converting, check the cardinality ratio: df[col].nunique() / len(df). If the column has only a handful of unique values—typically well under 5–10% of rows, and certainly not approaching the row count—Categorical is a strong candidate. Convert known low-cardinality columns at load time using dtype={"status": "category"} in read_csv so the compact representation is in place before any groupby or merge runs. For ordinal scales, pass ordered=True so sorting behaves predictably. The behavior described here is stable across modern pandas 1.x and 2.x releases, but always verify the win in your own environment with memory_usage(deep=True); exact savings depend on string lengths, row counts, and your pandas version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.