Skip to content

Fix XGBoost "DataFrame.dtypes for data must be int, float, bool or category"

Tested with: XGBoost 3.4.1, pandas 3.0.6 and 2.2.3, numpy 2.5.3, scikit-learn 1.9.1, Python 3.12.3, Linux, CPU only. Category behaviour also checked on XGBoost 2.1.4 with pandas 2.2.3. Last run 2026-09-27.

TL;DR: XGBoost only accepts numeric, bool and pandas category columns. The end of the message lists each rejected column with its dtype (Invalid columns:city: str).
  1. Text categories: X[col] = X[col].astype('category'). On XGBoost 3.x that is enough (enable_categorical now defaults to True); on 2.x also pass enable_categorical=True. Or one-hot with pd.get_dummies.
  2. Numbers stored as text ("$1,200", "N/A"): pd.to_numeric(X[col].str.replace(r'[$,]', '', regex=True), errors='coerce').
  3. Datetime or timedelta columns: derive numeric features (.dt.year, .dt.days) and drop the original.
  4. ID columns ("u0001"): drop them.
  5. Nullable Int64/Float64/boolean with pd.NA are fine. An object column of numbers with pd.NA is 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 runExact error / outputFix that worked
Text columns city, plan_typeSame ValueError, Invalid columns:city: str, plan_type: str (pandas 2.2: object)astype('category') or pd.get_dummies
Same text columns with enable_categorical=TrueSame ValueError, unchangedThe flag does not convert text; cast to category first
category columns, no flag, XGBoost 3.4.1Trains; enable_categorical defaults to TrueNone needed
category columns, no flag, XGBoost 2.1.4Same ValueError, Invalid columns:city: category, plan_type: categoryenable_categorical=True
category + tree_method='exact', XGBoost 3.4.1XGBoostError: ... 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.4ValueError: 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: strpd.to_numeric(..., errors='coerce') after stripping $ and ,
datetime64 columnSame 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: strpd.to_datetime, then derive features
timedelta64 columnSame ValueError, Invalid columns:dur: timedelta64[s].dt.days or .dt.total_seconds()
ID column "u0000"...Same ValueError, Invalid columns:user_id: strdrop(columns=['user_id'])
Int64, boolean, Float64 columns holding pd.NATrains, no errorNone needed
object column [1, 2, pd.NA, 3]Same ValueError, Invalid columns:visits: objectpd.to_numeric(X['visits']) or astype('Int64')
Model trained on category, predict() on raw textSame ValueError, city: str, plan_type: strApply 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 predictValueError: Found unknown categories ['Santiago'] in column 0 during transformOneHotEncoder(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_categorical defaults to True on both XGBClassifier and xgb.DMatrix, so category columns train without the flag. Passing it explicitly does no harm.
  • XGBoost 2.1.4: the default is False, and category columns raise the same ValueError (Invalid columns:city: category) until you pass enable_categorical=True. With the native API that means xgb.DMatrix(X, y, enable_categorical=True).
  • tree_method: the default (None, which uses hist) 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=True on a str/object column still raises the same ValueError. The astype('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.