Saving and loading XGBoost models sounds trivial — and mostly it is — but there are enough format options, version compatibility nuances, and post-load gotchas (particularly around early stopping) to make it worth covering carefully. This guide covers every serialisation format XGBoost supports, when to use each, and what to watch out for.
Format Overview
XGBoost supports four serialisation approaches, each with different trade-offs. The native JSON format (.json) is the recommended default — human-readable, stable across versions, and includes all model metadata. The binary format (.ubj in recent versions) is smaller and faster to load. Joblib/pickle serialisation works for sklearn-API models but is Python-only and can break across scikit-learn or XGBoost version changes. ONNX export enables deployment outside Python. For most production use cases, JSON is the right choice.
Saving and Loading: Native JSON (Recommended)
import xgboost as xgb
# Train
model = xgb.XGBClassifier(n_estimators=500, random_state=42)
model.fit(X_train, y_train)
# Save — native JSON format
model.save_model('model.json')
# Load
loaded = xgb.XGBClassifier()
loaded.load_model('model.json')
# Predict — works immediately
preds = loaded.predict(X_val)
probas = loaded.predict_proba(X_val)[:, 1]
The JSON format stores everything: tree structure, feature names, objective, number of classes, and all hyperparameters. You do not need to recreate the model with matching constructor arguments before loading — load_model() restores the full configuration from the file.
Saving and Loading: Native API (Booster)
# Native API — works with xgb.train() output
dtrain = xgb.DMatrix(X_train, label=y_train)
booster = xgb.train({'objective':'binary:logistic','eval_metric':'logloss'}, dtrain, 300)
booster.save_model('booster.json')
# Load back as Booster
loaded_booster = xgb.Booster()
loaded_booster.load_model('booster.json')
dval = xgb.DMatrix(X_val)
probas = loaded_booster.predict(dval)
Joblib Serialisation (sklearn API)
import joblib
# Save
joblib.dump(model, 'model.pkl')
# Load
loaded = joblib.load('model.pkl')
preds = loaded.predict(X_val)
Joblib is convenient for sklearn pipelines (you can pickle the entire pipeline including preprocessors) but has two risks: pickle files can break across XGBoost or Python version upgrades, and they are a security risk if loaded from untrusted sources — a malicious pickle file can execute arbitrary code on load. Use JSON for cross-environment deployment; use joblib only for short-term model storage within a controlled environment.
Saving with Early Stopping: The Gotcha
If you trained with early stopping, the model uses model.best_iteration trees during prediction in the same Python session. But when you save and reload, the loaded model defaults to using ALL trees. Fix by storing the best iteration alongside the model file:
import json
# Train with early stopping
model = xgb.XGBClassifier(n_estimators=2000, early_stopping_rounds=100, verbosity=0)
model.fit(X_train, y_train, eval_set=[(X_val, y_val)])
# Save model + metadata
model.save_model('model.json')
meta = {'best_iteration': model.best_iteration, 'best_score': float(model.best_score)}
with open('model_meta.json', 'w') as f:
json.dump(meta, f)
# Load and predict correctly
loaded = xgb.XGBClassifier()
loaded.load_model('model.json')
with open('model_meta.json') as f:
meta = json.load(f)
best_iter = meta['best_iteration']
preds = loaded.predict(X_val, iteration_range=(0, best_iter + 1))
probas = loaded.predict_proba(X_val, iteration_range=(0, best_iter + 1))[:, 1]
print(f"Using {best_iter} trees (of {loaded.n_estimators} total)")
ONNX Export for Non-Python Deployment
pip install onnxmltools skl2onnx
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType
import onnxruntime as rt
# Convert to ONNX
initial_type = [('float_input', FloatTensorType([None, X_train.shape[1]]))]
onnx_model = convert_sklearn(model, initial_types=initial_type)
with open('model.onnx', 'wb') as f:
f.write(onnx_model.SerializeToString())
# Run inference with ONNX Runtime (no XGBoost or Python required)
sess = rt.InferenceSession('model.onnx')
input_name = sess.get_inputs()[0].name
pred_onx = sess.run(None, {input_name: X_val.values.astype('float32')})
ONNX Runtime runs on Linux, Windows, macOS, iOS, Android, and embedded targets without a Python runtime. It is the right choice when you need to embed XGBoost predictions in a Java, C#, Go, or Rust service, or when you want inference without the XGBoost library dependency.
Versioning Models in Production
Always include version metadata with saved models. At minimum: training date, XGBoost version, Python version, training dataset hash or description, and key metrics. Store this in a sidecar JSON file or embed it in MLflow/Weights & Biases as run metadata. Without versioning, it becomes impossible to know which model is in production, what data it was trained on, or whether a performance regression is caused by data drift or a model change.
import xgboost as xgb, json, datetime, hashlib, numpy as np
def save_model_with_meta(model, X_train, y_train, X_val, y_val, path_prefix):
model.save_model(f'{path_prefix}.json')
# Hash training data for traceability
data_hash = hashlib.md5(X_train.values.tobytes()).hexdigest()[:8]
meta = {
'saved_at': datetime.datetime.now().isoformat(),
'xgboost_version': xgb.__version__,
'best_iteration': getattr(model, 'best_iteration', model.n_estimators),
'best_score': getattr(model, 'best_score', None),
'n_features': X_train.shape[1],
'feature_names': list(X_train.columns),
'train_rows': len(X_train),
'data_hash': data_hash
}
with open(f'{path_prefix}_meta.json', 'w') as f:
json.dump(meta, f, indent=2)
print(f"Saved model and metadata to {path_prefix}.*")
return meta
Loading Models in a Production Service
import xgboost as xgb, json
from functools import lru_cache
@lru_cache(maxsize=1)
def get_model(model_path='model.json', meta_path='model_meta.json'):
"""Load model once and cache in memory."""
model = xgb.XGBClassifier()
model.load_model(model_path)
with open(meta_path) as f:
meta = json.load(f)
return model, meta
def predict(features_df):
model, meta = get_model()
best_iter = meta.get('best_iteration', model.n_estimators)
return model.predict_proba(features_df,
iteration_range=(0, best_iter + 1))[:, 1]
The lru_cache pattern loads the model once at first inference and serves all subsequent requests from memory — essential for low-latency APIs where loading a model file on every request would add hundreds of milliseconds. For FastAPI or Flask services, initialise the model at startup using a lifespan handler or the before_first_request decorator rather than relying on lazy initialisation.
Comparing Formats at a Glance
JSON is the default for all new models — stable, human-readable, compatible with future XGBoost versions. Use joblib when you need to serialise a full sklearn pipeline including preprocessors within a Python-only environment. Use ONNX when deploying to non-Python runtimes or when you want inference independent of the XGBoost library. The binary UBJ format is worth considering for very large models (thousands of trees) where load time matters, but JSON is fast enough for most use cases. Whatever format you choose, always save a sidecar metadata file and always test that the loaded model produces identical predictions to the in-memory model before deploying.
For MLflow users, the mlflow.xgboost.log_model() and mlflow.xgboost.load_model() functions handle serialisation, versioning, and the model registry in one integrated workflow — worth adopting if your team is already using MLflow for experiment tracking.
The save/load workflow for XGBoost is straightforward once you know the two main pitfalls: the early stopping iteration_range issue on reload, and the version sensitivity of pickle-based formats. Use JSON, store metadata, test predictions before deploying, and you will never be surprised by a model that silently uses the wrong number of trees or breaks after a library upgrade. A saved model file is only as reliable as the discipline around it — the format, the metadata, the version pins, and the smoke test are what turn a pickle file into a production artifact you can trust. That combination of good habits costs five minutes per training run and saves hours of debugging in production.
Testing a Saved Model Before Deployment
Before deploying a saved model to production, always run a smoke test that compares predictions from the loaded model against predictions computed in the same session immediately after training. Any discrepancy — even a single different prediction — indicates a problem with the serialisation, the iteration_range, or the preprocessing pipeline. Store a small representative batch of inputs and their expected outputs as a test fixture alongside the model file, and run this fixture automatically as part of your deployment CI.
import numpy as np
def verify_saved_model(model, model_path, X_sample, meta_path=None):
"""Verify loaded model matches in-memory model predictions."""
best_iter = getattr(model, 'best_iteration', model.n_estimators)
expected = model.predict_proba(X_sample, iteration_range=(0, best_iter+1))[:,1]
loaded = type(model)()
loaded.load_model(model_path)
actual = loaded.predict_proba(X_sample, iteration_range=(0, best_iter+1))[:,1]
max_diff = np.abs(expected - actual).max()
assert max_diff < 1e-5, f"Prediction mismatch: max diff = {max_diff}"
print(f"✅ Model verified — max prediction diff: {max_diff:.2e}")
verify_saved_model(model, 'model.json', X_val.iloc[:20])
Model Registry Patterns
For teams running many experiments, a model registry — MLflow, Weights & Biases, or even a simple directory convention — keeps saved models organised and reproducible. A minimal convention: save each trained model to a directory named by date and run ID, always include the metadata JSON, and maintain a current.json symlink pointing to the production model. This makes it easy to roll back to a previous model if a deployment causes a regression: delete the symlink, recreate it pointing to the previous directory, and redeploy without retraining. The metadata JSON makes it trivial to check exactly which training data and hyperparameters produced the current production model, which is invaluable during incident investigations.
Saving Pipelines That Include XGBoost
When XGBoost is part of a sklearn pipeline — with a preprocessor, feature selector, or other transformers — save the entire pipeline, not just the model. Joblib handles this naturally. The advantage is that inference on new data goes through the same pipeline steps automatically, eliminating training-serving skew where the model is loaded correctly but preprocessing is applied differently at inference time.
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
import joblib
pipeline = Pipeline([
('scaler', StandardScaler()),
('model', xgb.XGBClassifier(n_estimators=500, verbosity=0))
])
pipeline.fit(X_train, y_train)
# Save entire pipeline — scaler parameters included
joblib.dump(pipeline, 'pipeline.pkl')
# Load and predict — preprocessing applied automatically
loaded_pipeline = joblib.load('pipeline.pkl')
preds = loaded_pipeline.predict(X_val) # no manual scaling needed
Version-pin your scikit-learn in the deployment environment — sklearn transformer parameters can shift across minor versions in ways that affect output without raising an error. Always test the loaded pipeline on a held-out sample before deploying.
Incremental Checkpointing During Long Training
For long training runs, XGBoost supports saving checkpoints via a callback. If training crashes, you resume from the last checkpoint rather than restarting:
from xgboost.callback import TrainingCheckPoint
checkpoint_cb = TrainingCheckPoint(
directory='./checkpoints/', iterations=100, name='ckpt'
)
model = xgb.XGBClassifier(
n_estimators=5000, learning_rate=0.01,
callbacks=[checkpoint_cb], verbosity=0
)
model.fit(X_train, y_train, eval_set=[(X_val, y_val)])
# Resume: load the most recent checkpoint
model_resumed = xgb.XGBClassifier()
model_resumed.load_model('./checkpoints/ckpt_0004900.json')
Cross-Version Compatibility
XGBoost's JSON format is designed for forward compatibility — models saved with older versions load in newer ones. The reverse is not guaranteed. Always record the XGBoost version in your metadata file and pin it in your deployment requirements. When upgrading XGBoost, retrain models on the new version rather than migrating saved files — this is safer than relying on backward compatibility for production models.
Testing a Saved Model Before Deployment
Before promoting a saved model to production, run a smoke test comparing predictions from the loaded model against predictions from the in-memory model on a small fixed sample. Any discrepancy indicates a problem with serialisation, iteration_range, or pipeline steps.
import numpy as np
def verify_model(original, path, X_sample):
best_iter = getattr(original, 'best_iteration', original.n_estimators)
expected = original.predict_proba(X_sample, iteration_range=(0, best_iter+1))[:,1]
loaded = type(original)()
loaded.load_model(path)
actual = loaded.predict_proba(X_sample, iteration_range=(0, best_iter+1))[:,1]
diff = np.abs(expected - actual).max()
assert diff < 1e-5, f"Mismatch: {diff:.2e}"
print(f"✅ Verified — max diff: {diff:.2e}")
verify_model(model, 'model.json', X_val.iloc[:20])
Model Registry Patterns
For teams running many experiments, a model registry keeps saved models organised. A minimal directory convention: save each model to a folder named by date and run ID, always include the metadata JSON, and maintain a current/ symlink pointing to the active production model. This makes rollback straightforward — repoint the symlink to the previous directory and redeploy without retraining. The metadata JSON tells you exactly which training data and hyperparameters produced the current production model, which is invaluable during incident investigations when you need to know whether a performance regression was caused by data drift or a model change. Even a simple folder-and-symlink convention beats storing model files with no provenance — and it adds almost no overhead to the training workflow.
The save/load workflow for XGBoost is straightforward once you know the two main pitfalls: the early stopping iteration_range issue on reload, and the version sensitivity of pickle-based formats. Use JSON, store metadata, test predictions before deploying, and you will never be surprised by a model that silently uses the wrong number of trees or breaks after a library upgrade.