You call .fit() on an XGBoost model with a pandas DataFrame and get this before training even starts:
ValueError: DataFrame.dtypes for data must be int, float, bool or category.
When categorical type is supplied, DMatrix parameter `enable_categorical` must be set to `True`. Invalid columns: city, plan_type
The message is more useful than most XGBoost errors, it actually names the offending columns at the end. But it's easy to fix the two named columns, rerun, and hit the same error again on a different column three cells later, or have it resurface only at predict() time on new data. Here's what's actually going on and the fix that matches your situation.
Before training, XGBoost converts your DataFrame into an internal DMatrix, a dense numeric buffer it can iterate over fast in C++. That conversion needs every column to already be one of: a numeric dtype (int64, float64, etc.), bool, or pandas' category dtype. Anything else, most commonly plain strings sitting in an object-dtype column, has no defined numeric representation, so the conversion fails outright rather than guessing.
This is different from scikit-learn's behavior with some estimators that silently error later or coerce oddly. XGBoost checks dtypes upfront and tells you exactly which columns failed, which is useful once you know to read the end of the message, not just the first line.
Don't trust memory, the error message can be truncated in some environments or you may have added columns since you last looked. Check directly:
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])
This matters because the fix depends entirely 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 that got read in with a stray "$" or "," in some rows, forcing the whole column to object), or a datetime that was never parsed.
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 here, tree-based models like XGBoost don't have the multicollinearity concerns that linear models do, so there's no reason to drop a level.
One-hot encoding a column with hundreds of unique values (product SKU, zip code, user ID) explodes your feature count and usually hurts tree-based models rather than helping them. 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, tree_method='hist')
model.fit(X, y)
enable_categorical=True is required on both the constructor (sklearn API) or the DMatrix call (native API) -- setting the dtype alone is not enough, XGBoost still needs to be told it's allowed to interpret category columns directly rather than rejecting them. tree_method='hist' is required for native categorical splitting in most XGBoost versions; the default exact method doesn't support it.
Check for the actual cause before blindly casting, a forced cast can silently turn bad data into NaN instead of erroring where you'd notice:
# Find rows that don't parse cleanly, before forcing the cast
non_numeric = X[pd.to_numeric(X['price'], errors='coerce').isna() & X['price'].notna()]
print(non_numeric['price'].unique())
# e.g. array(['$1,200', '$950', 'N/A'], dtype=object)
# Now clean and cast, informed by what you actually found
X['price'] = (
X['price']
.replace('N/A', pd.NA)
.str.replace(r'[$,]', '', regex=True)
.astype('float64')
)
X['signup_date'] = pd.to_datetime(X['signup_date'])
# XGBoost still can't consume a datetime64 column directly --
# derive numeric features from it instead of passing the datetime itself
X['signup_year'] = X['signup_date'].dt.year
X['signup_month'] = X['signup_date'].dt.month
X['days_since_signup'] = (pd.Timestamp.now() - X['signup_date']).dt.days
X = X.drop(columns=['signup_date'])
If you fixed dtypes on your training set and training succeeded, but the same error shows up later on model.predict(X_new), the cause is almost always 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) # same encoding applied automatically, no drift between fit and predict
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, which looks similar but has a different cause (unseen category, not a dtype problem) and needs a different fix.
# 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}"
Running both assertions, not just the training-set one, is the part that's easy to skip and is exactly what causes this error to reappear weeks later on a batch of new data that came from a different source (a fresh CSV export, a different upstream service) with slightly different formatting than whatever you originally fixed by hand.