EDA and Feature Engineeringpandas 3.0 · scikit-learn 1.9 · seaborn 0.13 · Python 3.12+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
72 small wins to finish your pathNext lesson →

Text columns and the str dtype

The str dtype is the storage pandas gives a column of text, so the old test df[col].dtypes == 'O' is False for every text column and the code that splits a table into categorical and numerical features silently splits it the wrong way.

Last updated: 12 Oct, 2026 · pandas 3.0 · scikit-learn 1.9

Copy-on-write and chained assignment was the first of the two changes that break old code without raising. This is the second, and it is harder to spot, because the failure arrives several steps after the line that caused it.

Syntax: the two ways to ask which columns hold text, and which hold numbers.

python
df.select_dtypes(include='str')        # the text columns as a frame
df.select_dtypes(include='number')     # the numeric columns as a frame

pd.api.types.is_string_dtype(df[col])  # True for one text column
pd.api.types.is_numeric_dtype(df[col]) # True for one numeric column

What a text column is stored as

Up to pandas 2, a column of strings was stored with the dtype object, whose one letter code is 'O'. An object column is an array of pointers to arbitrary Python objects, so a column of names, a column of lists and a column of mixed types all looked identical. pandas 3 gives text its own dtype.

ExampleThe zoning column of the Ames house price table
import pandas as pd

AMES = ("https://raw.githubusercontent.com/krishnaik06/"
        "Advanced-House-Price-Prediction-/master/train.csv")
ames = pd.read_csv(AMES)

print("dtype of MSZoning   :", ames['MSZoning'].dtype)
print("holds               :", ames['MSZoning'].unique()[:3].tolist())
print("dtype == 'O'        :", ames['MSZoning'].dtype == 'O')
print("dtype == 'str'      :", ames['MSZoning'].dtype == 'str')
  • str is the dtype now. The column holds zoning codes, 'RL' for low density residential and 'RM' for medium density.
  • dtype == 'O' is False. Nothing raised. The comparison is a valid question with a correct answer, and the answer is no.
  • dtype == 'str' is True. The test still works if you change the letter, but a test that has to change with the pandas version is not the one to write.

Splitting a table the wrong way

Almost every older notebook starts a feature engineering pass with two list comprehensions like the ones below: one keeps the columns whose dtype is 'O', the other keeps the rest. They are the lines that decide which columns get encoded and which get scaled. On this 81 column table the first list used to hold 43 columns and the second 38.

ExampleThe two lines older notebooks open with, on pandas 3.0.6
categorical = [f for f in ames.columns if ames[f].dtypes == 'O']
numerical = [f for f in ames.columns if ames[f].dtypes != 'O']

print("categorical columns:", len(categorical))
print("numerical columns  :", len(numerical), "of", ames.shape[1])

What the two counts mean

  • 0 categorical columns. The list is empty, so every encoder, every rare label grouping and every ordinal mapping that loops over it does nothing at all.
  • 81 numerical columns. That is the whole table, text columns included. Everything meant for numbers is now pointed at zoning codes, street names and roof styles.
  • No error, no warning. Both comprehensions are valid Python and valid pandas. The run continues with two wrong lists.

Where the run finally fails

The failure arrives at the first step that does arithmetic. MinMaxScaler rescales each column into the range 0 to 1, which means it subtracts a minimum and divides by a range, and a zoning code cannot be subtracted.

ExampleThe error, several steps after the line that caused it
import pandas as pd
from sklearn.preprocessing import MinMaxScaler

AMES = ("https://raw.githubusercontent.com/krishnaik06/"
        "Advanced-House-Price-Prediction-/master/train.csv")
ames = pd.read_csv(AMES)
numerical = [f for f in ames.columns if ames[f].dtypes != 'O']
print("columns handed to the scaler:", len(numerical))

MinMaxScaler().fit(ames[numerical])
A chain of five steps with no error in it. read_csv stores MSZoning as text, the test df[c].dtypes == 'O' is False for every text column, so the categorical list falls from 43 columns to none and the numeric list grows from 38 to all 81, and only when MinMaxScaler is fitted does a ValueError appear saying it could not convert the string 'RL' to a float. The two tests to use instead are select_dtypes with include 'str' and is_string_dtype.

could not convert string to float: 'RL' names a value, not a column and not a line in your own code. Between the dtype test and this traceback there may be thirty cells of imputation and grouping, all of them silently applied to the wrong columns. That distance is what makes this worth a lesson.

Asking the question the right way

select_dtypes selects columns by their type and returns a frame, so .columns.tolist() turns it into the list the loops want. pd.api.types.is_string_dtype answers the same question for a single column and does not care which pandas version is installed, which makes it the safer one inside a comprehension.

ExampleBoth forms find the 43 text columns and the 38 numeric ones
categorical = ames.select_dtypes(include='str').columns.tolist()
numerical = ames.select_dtypes(include='number').columns.tolist()
print("select_dtypes      :", len(categorical), "categorical,", len(numerical), "numerical")

is_text = pd.api.types.is_string_dtype
categorical = [c for c in ames.columns if is_text(ames[c])]
numerical = [c for c in ames.columns if pd.api.types.is_numeric_dtype(ames[c])]
print("is_string_dtype    :", len(categorical), "categorical,", len(numerical), "numerical")
print("first four         :", categorical[:4])
  • 43 categorical and 38 numerical from both forms, and 43 plus 38 is the 81 columns in the table.
  • include='str' is the pandas 3 spelling. include='object' still returns the same 43 columns and adds a deprecation warning, so it works today and will not keep working.
  • is_string_dtype is the version-proof test. It answers True for str and for the older object columns, so the same comprehension runs on either pandas.
  • A column stored as category behaves differently. select_dtypes(include='str') leaves it out, so pass include=['str', 'category'] when your table has one. is_string_dtype does count it, as long as its categories are text.

object vs str vs category

objectstrcategory
What it holdsAny Python objectsTextText from a fixed set of values
When pandas picks itMixed types in one columnEvery text column, from pandas 3Only when you ask for it
dtype == 'O'TrueFalseFalse
select_dtypes(include='str')Included, with a warningIncludedNot included
is_string_dtypeTrueTrueTrue when its categories are text
Memory on a repeated columnHighLowerLowest

Where the dtype audit is used

  • The first pass over any new table. How many columns hold numbers and how many hold text is the first question in the EDA checklist, and the answer picks the tools for everything after it.
  • Building a ColumnTransformer. One list of columns goes to a scaler and another to an encoder, and the lists come from exactly this test.
  • Running someone else's notebook. A 2020 notebook that produces empty category lists and a late ValueError has this cause more often than any other.
Watch out. The empty list is the symptom to watch for. When a loop over your categorical features prints nothing and the code carries on, do not read past it: print len(categorical) straight after the test and check the number against the table's shape.
Try it yourself
  • Run ames.dtypes.value_counts() and check that the str count matches the 43 the fixed test found.
  • Change include='str' to include='object' and read the deprecation warning pandas prints.
  • Convert one column with ames['MSZoning'] = ames['MSZoning'].astype('category') and re-run both tests. Which of the two still counts it?

Every expert started right here.