Fix XGBoost "DataFrame.dtypes for data must be int, float, bool or category"
category columns. The end of the message lists each rejected column with its dtype (Invalid columns:city: str).
- Text categories:
X[col] = X[col].astype('category'). On XGBoost 3.x that is enough (enable_categoricalnow defaults toTrue); on 2.x also passenable_categorical=True. Or one-hot withpd.get_dummies. - Numbers stored as text (
"$1,200","N/A"):pd.to_numeric(X[col].str.replace(r'[$,]', '', regex=True), errors='coerce'). - Datetime or timedelta columns: derive numeric features (
.dt.year,.dt.days) and drop the original. - ID columns (
"u0001"): drop them. - Nullable
Int64/Float64/booleanwithpd.NAare fine. Anobjectcolumn of numbers withpd.NAis not:pd.to_numeric(X[col]).
You call .fit() on an XGBoost model with a pandas DataFrame and get this before training even starts (XGBoost 3.4.1, pandas 3.0.6; the spacing is XGBoost's own):
ValueError: DataFrame.dtypes for data must be int, float, bool or category. When categorical type is supplied, the experimental DMatrix parameter`enable_categorical` must be set to `True`. Invalid columns:city: str, plan_type: str
On pandas 2.2 the same data ends in Invalid columns:city: object, plan_type: object, because pandas 3 reads text into the new str dtype instead of object. The same ValueError comes from XGBClassifier.fit(), xgb.DMatrix() and xgb.QuantileDMatrix().
The message names every offending column and its dtype at the end, so read the last line first. It's still easy to fix those columns, rerun, and hit it again on data from another source, or only at predict() time on new data.
Reproduced: each error and the fix that worked
Every row was run on the versions in the header. "Same ValueError" means the message above, ending with the column and dtype shown.
| What was run | Exact error / output | Fix that worked |
|---|---|---|
Text columns city, plan_type | Same ValueError, Invalid columns:city: str, plan_type: str (pandas 2.2: object) | astype('category') or pd.get_dummies |
Same text columns with enable_categorical=True | Same ValueError, unchanged | The flag does not convert text; cast to category first |
category columns, no flag, XGBoost 3.4.1 | Trains; enable_categorical defaults to True | None needed |
category columns, no flag, XGBoost 2.1.4 | Same ValueError, Invalid columns:city: category, plan_type: category | enable_categorical=True |
category + tree_method='exact', XGBoost 3.4.1 | XGBoostError: ... Updater `grow_colmaker` or `exact` tree method doesn't support categorical features. | Leave tree_method at its default, or 'hist'/'approx' (all three trained) |
| Same on XGBoost 2.1.4 | ValueError: Experimental support for categorical data is not implemented for current tree method yet. | Same |
Price column with "$1,200", "950", "N/A" | Same ValueError, Invalid columns:price: str | pd.to_numeric(..., errors='coerce') after stripping $ and , |
datetime64 column | Same ValueError, Invalid columns:signup_date: datetime64[us] (pandas 2.2: [ns]) | Derive year, month, days; drop the column |
Date kept as text "2024-01-01" | Same ValueError, signup_date: str | pd.to_datetime, then derive features |
timedelta64 column | Same ValueError, Invalid columns:dur: timedelta64[s] | .dt.days or .dt.total_seconds() |
ID column "u0000"... | Same ValueError, Invalid columns:user_id: str | drop(columns=['user_id']) |
Int64, boolean, Float64 columns holding pd.NA | Trains, no error | None needed |
object column [1, 2, pd.NA, 3] | Same ValueError, Invalid columns:visits: object | pd.to_numeric(X['visits']) or astype('Int64') |
Model trained on category, predict() on raw text | Same ValueError, city: str, plan_type: str | Apply the same cast before predict, or use a Pipeline |
Model trained on category, new rows cast with plain astype('category') (different codes) | 3.4.1: re-mapped, correct probabilities. 2.1.4: no error, wrong probabilities ([0.005, 0.005] instead of [0.991, 0.005]) | pd.Categorical(x, categories=train_categories) |
Pipeline with OneHotEncoder(), unseen city at predict | ValueError: Found unknown categories ['Santiago'] in column 0 during transform | OneHotEncoder(handle_unknown='ignore') |
Why XGBoost rejects the column
Before training, XGBoost converts your DataFrame into an internal matrix (DMatrix; the sklearn wrapper builds one for you). That conversion needs every column to be numeric (including the nullable Int64 and Float64), bool (including nullable boolean), or pandas category. Text in a str or object column has no numeric value, and XGBoost does not convert datetime64 or timedelta64 either, so it refuses rather than guessing.
The check happens before any tree is built, so the fix is always on the DataFrame side.
Step 1: list the rejected columns
Check the DataFrame directly, especially the one you pass to predict(). On both pandas 3.0.6 and 2.2.3 this lists str/object, datetime64 and timedelta64 columns and leaves out nullable Int64/boolean and category, which XGBoost accepts:
import pandas as pd
# List every column that isn't numeric, bool, or category
bad_cols = X.select_dtypes(exclude=['number', 'bool', 'category']).columns.tolist()
print(bad_cols)
# See what's actually in them
for col in bad_cols:
print(col, X[col].dtype, X[col].unique()[:5])
The fix depends on what kind of column it is: a genuine categorical feature (city, plan type, product category), a string that should have been numeric (a price column with a stray "$" or "," in some rows; pd.read_csv read "$1,200" and 950 into one str column on pandas 3, object on 2.2), an ID, or a date.
Step 2: pick the fix for each column
Genuine categorical feature, low cardinality: encode it
For columns with a small number of distinct values where order doesn't matter, one-hot encoding is the simplest correct fix:
X = pd.get_dummies(X, columns=['city', 'plan_type'], drop_first=False)
drop_first=False is deliberate. Trees are not affected by the collinearity that makes linear models drop a level. On pandas 3.0.6 the dummy columns come out as bool, which XGBoost accepts.
Genuine categorical feature, high cardinality: use XGBoost's native category support
One-hot encoding a column with hundreds of unique values (product SKU, zip code, user ID) adds one feature per value. Cast to pandas' category dtype instead and let XGBoost handle it natively:
for col in ['city', 'plan_type']:
X[col] = X[col].astype('category')
model = xgb.XGBClassifier(enable_categorical=True) # needed on 2.x, default on 3.x
model.fit(X, y)
What changed between versions, checked on both:
- XGBoost 3.4.1:
enable_categoricaldefaults toTrueon bothXGBClassifierandxgb.DMatrix, socategorycolumns train without the flag. Passing it explicitly does no harm. - XGBoost 2.1.4: the default is
False, andcategorycolumns raise the same ValueError (Invalid columns:city: category) until you passenable_categorical=True. With the native API that meansxgb.DMatrix(X, y, enable_categorical=True). tree_method: the default (None, which useshist) handles categories on both versions, as do'hist'and'approx'. Only'exact'fails (XGBoostError: ... `exact` tree method doesn't support categorical features.on 3.4.1). You don't need to set it.- The flag does not convert text.
enable_categorical=Trueon astr/objectcolumn still raises the same ValueError. Theastype('category')is the fix.
Should be numeric but got parsed as a string
Look at what fails to parse before casting, so a coerce doesn't quietly turn real values into NaN:
# Values that don't parse as numbers
bad = X[pd.to_numeric(X['price'], errors='coerce').isna() & X['price'].notna()]
print(bad['price'].unique())
# pandas 3.0.6: <StringArray> ['$1,200', 'N/A'] ('950' parses fine)
# pandas 2.2.3: ['$1,200' 'N/A']
# Strip the formatting, then let the leftovers ('N/A') become NaN
X['price'] = pd.to_numeric(
X['price'].str.replace(r'[$,]', '', regex=True),
errors='coerce',
)
print(X['price'].dtype) # float64
XGBoost treats NaN as missing, so the resulting float64 column trains directly. A common variant, .replace('N/A', pd.NA).str.replace(...).astype('float64'), worked on pandas 3.0.6 but raised TypeError: float() argument must be a string or a real number, not 'NAType' on pandas 2.2.3. pd.to_numeric worked on both.
Datetime column never parsed
X['signup_date'] = pd.to_datetime(X['signup_date'])
# XGBoost rejects datetime64 too ("Invalid columns:signup_date: datetime64[us]"),
# so derive numeric features and drop the original
ref = pd.Timestamp('2026-09-27') # fixed reference date, not now(), so the feature is reproducible
X['signup_year'] = X['signup_date'].dt.year
X['signup_month'] = X['signup_date'].dt.month
X['days_since_signup'] = (ref - X['signup_date']).dt.days
X = X.drop(columns=['signup_date'])
A timedelta64 column fails the same way (Invalid columns:dur: timedelta64[s]); use X['dur'].dt.days or .dt.total_seconds().
ID columns
A user_id like "u0001" shows up as Invalid columns:user_id: str. It carries no signal a tree can generalize from, so drop it (X = X.drop(columns=['user_id'])) rather than encoding it.
Nullable dtypes and pd.NA
Nullable Int64, Float64 and boolean columns containing pd.NA trained without error on XGBoost 3.4.1 with both pandas versions. The problem case is an object column holding numbers and pd.NA (for example built from a Python list), which fails with Invalid columns:visits: object. pd.to_numeric(X['visits']) (gives float64) or X['visits'].astype('Int64') both fixed it.
Same error at predict() time
If you fixed dtypes on your training set and training succeeded, but the same error shows up later on model.predict(X_new), the usual cause is that the encoding you applied to training data was a one-off cell, not something reapplied to new data. Fix this by wrapping the encoding into something that runs identically both times:
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
categorical_cols = ['city', 'plan_type']
numeric_cols = [c for c in X.columns if c not in categorical_cols]
preprocessor = ColumnTransformer([
('cat', OneHotEncoder(handle_unknown='ignore'), categorical_cols),
], remainder='passthrough')
pipeline = Pipeline([
('preprocess', preprocessor),
('model', xgb.XGBClassifier()),
])
pipeline.fit(X_train, y_train)
pipeline.predict(X_new) # the encoder fitted on X_train is reused here
If you use category dtype instead, build the new data's categories from the training ones: X_new[col] = pd.Categorical(X_new[col], categories=X_train[col].cat.categories). In a test where the label depended only on city, new rows ["Quito", "Lima"] cast with a plain astype('category') got categories ['Lima', 'Quito'] with different codes than training. XGBoost 3.4.1 re-mapped them and gave probabilities [0.991, 0.005], the same as the aligned version. XGBoost 2.1.4 did not: it silently predicted [0.005, 0.005], with no error. Aligning the categories gives the right answer on both.
handle_unknown='ignore' matters specifically for this failure mode: without it, a category value that shows up in new data but wasn't present in training raises a separate error at predict time (ValueError: Found unknown categories ['Santiago'] in column 0 during transform). That is an unseen category, not a dtype problem.
Confirming it's fixed
# Should be an empty list once every column is numeric, bool, or category
remaining = X.select_dtypes(exclude=['number', 'bool', 'category']).columns.tolist()
assert not remaining, f"Still non-numeric: {remaining}"
# And confirm predict-time data goes through the same check, not just training data
remaining_new = X_new.select_dtypes(exclude=['number', 'bool', 'category']).columns.tolist()
assert not remaining_new, f"New data still non-numeric: {remaining_new}"
Run the check on the prediction data too. New data from another export or service is where text and date columns come back.