
When you save a machine learning model with MLflow using mlflow.pyfunc.log_model(), MLflow needs to freeze your entire Python object into a file so it can be loaded later.
It uses a library called cloudpickle to do this.
Think of cloudpickle like taking a photograph of your Python object:
Your Python Model Object
│
▼
cloudpickle
│
▼
Serialized File (.pkl)
┌─────────────────────┐
│ Model weights │
│ Python bytecode │ ← This is the problem
│ Python version info │ ← This too
│ Wrapper logic │
└─────────────────────┘
Python compiles your code into bytecode — a lower-level set of instructions the interpreter executes.
| Python Version | Bytecode Structure |
|---|---|
| Python 3.10 | Format A |
| Python 3.12 | Format B (changed significantly) |
When Python 3.12 tries to read a file serialized with Python 3.10 bytecode, it encounters instructions it cannot parse correctly.
Result:
TypeError: code() argument 13 must be str, not int
Key insight: The model weights themselves are fine. The problem is the wrapper around them was frozen with version-specific bytecode.
Before learning the solution, you need to appreciate the scale constraint that made retraining impractical.
For each product category, five model types run in parallel:
Product Category
│
├── CatBoost (tree-based classifier)
├── TF-IDF Regression (text-based logistic regression)
├── Neural Network (PyTorch Lightning + sentence embeddings)
├── Image Model (PyTorch Lightning + image embeddings)
└── Voting Ensemble (aggregates all above predictions)
| Model Type | Compute Needed |
|---|---|
| Neural Network | GPU cluster |
| Image Model | GPU cluster |
| CatBoost | GPU cluster |
| TF-IDF | CPU cluster |
| Voting Ensemble | CPU cluster |
Training data: Multiple terabytes per run.
Retraining everything across Dev, Staging, and Production environments from scratch would consume enormous time and compute cost.
Key insight: The solution must recover existing trained weights, not discard them.
This is the core technical solution. It has two distinct stages.
The goal here is to read the broken pickle files using the same Python version that created them.
┌─────────────────────────────────────┐
│ Temporary Migration Job │
│ Environment: DBR 14.3 (Python 3.10)│
│ │
│ Old pickle file ──► Load model │
│ │ │
│ ▼ │
│ Extract raw weights │
│ (CatBoost, PyTorch, │
│ sklearn, etc.) │
└─────────────────────────────────────┘
Why this works: You are using the same Python version that created the file, so the bytecode is compatible.
What you extract: The raw model weights, completely separated from the old pyfunc wrapper.
Now you take those extracted weights and wrap them in a new, clean wrapper written in pure Python.
Raw Model Weights
│
▼
New Clean Wrapper (model_wrappers.py)
┌────────────────────────────────┐
│ class CatBoostModelWrapper │
│ (mlflow.pyfunc.PythonModel) │
│ │
│ - Pure Python logic │
│ - No old bytecode │
│ - Explicit dependencies │
└────────────────────────────────┘
│
▼
mlflow.pyfunc.log_model() ← Logged fresh with Python 3.12
│
▼
Promoted to @Production alias
Key actions in Stage 2:
model_wrappers.py@Production aliasKey insight: You preserved the trained weights (expensive to recreate) while replacing only the serialization wrapper (cheap to recreate).
The migration was not just a fix — it prompted two lasting improvements.
Before migration (problematic pattern):
# Ad-hoc, inconsistent per model type
mlflow.catboost.log_model(model, ...) # framework-native flavor
mlflow.pytorch.log_model(model, ...) # different interface
# Each had different predict() behavior
After migration (standardized pattern):
# model_wrappers.py
class CatBoostModelWrapper(mlflow.pyfunc.PythonModel):
def predict(self, context, model_input):
# consistent interface
...
class NNModelWrapper(mlflow.pyfunc.PythonModel):
def predict(self, context, model_input):
# same interface, different internals
...
Benefit: Every model type now has a uniform predict() interface regardless of the underlying framework.
Before migration:
MLflow Artifact
├── model.pkl ← weights + wrapper (tightly coupled)
└── (no explicit deps) ← dependencies assumed from environment
After migration:
MLflow Artifact
├── model weights ← weights only
├── model_wrappers.py ← wrapper code (via code_paths)
├── pyproject.toml ← explicit dependency manifest
└── uv.lock ← pinned, reproducible dependencies
How this is achieved:
mlflow.pyfunc.log_model(
artifact_path="model",
python_model=CatBoostModelWrapper(),
code_paths=["model_wrappers.py"], # embed wrapper code
# MLflow 3.11.1+ auto-detects uv.lock and pyproject.toml
)
Key insight: The artifact now carries everything it needs to run. It does not depend on the environment being configured correctly.
Even after the migration, the new wrappers still use cloudpickle to serialize the wrapper object itself. The next evolution eliminates pickle completely.
Instead of wrapping models in a pyfunc, use MLflow's native flavors that save directly to framework-native formats:
| Model Type | Native Format | MLflow Function |
|---|---|---|
| CatBoost | .cbm binary | mlflow.catboost.log_model() |
| PyTorch | pt2 (torch.export) | mlflow.pytorch.log_model(..., serialization_format="pt2") |
| Sklearn (TF-IDF, Voting) | skops format | mlflow.sklearn.log_model(..., serialization_format="skops") |
These formats store only weights and configuration — no Python bytecode embedded.
This is the most robust approach for maintaining a unified predict() interface:
Traditional approach:
Serialize Python object ──► .pkl file ──► Load object at runtime
Models from Code approach:
Store Python source file ──► .py file ──► Execute source at runtime
+ load native weights separately
How it works conceptually:
# wrapper_script.py (stored as raw source, not pickled)
class MyModel(mlflow.pyfunc.PythonModel):
def load_context(self, context):
# Load native weights at runtime
self.model = catboost.CatBoostClassifier()
self.model.load_model(context.artifacts["model_weights"])
def predict(self, context, model_input):
return self.model.predict(model_input)
At runtime, MLflow executes this file as pure source code. Since there is no pickle involved, Python version changes to bytecode format are completely irrelevant.
These are actionable principles derived from the experience:
Before scheduling a runtime upgrade, review all package dependencies with your team. Major runtime upgrades change foundational libraries underneath you.
# Recommended workflow
uv sync --upgrade # update dependencies
# Review pyproject.toml changes
# Verify Python, PySpark, Delta Lake compatibility
Avoid:
Python Object → cloudpickle → .pkl file
(bytecode-dependent, version-fragile)
Prefer:
Model Weights → native format → .cbm / .pt2 / skops file
(bytecode-independent, version-resilient)
PROBLEM
Python 3.10 → 3.12 upgrade
cloudpickle embeds bytecode → TypeError on load
↓
IMMEDIATE SOLUTION: Load and Repack
Stage 1: Load with old Python 3.10 environment
Extract raw weights from broken pickle
Stage 2: Repack with new Python 3.12 environment
Create clean wrapper → re-log → promote to @Production
↓
ARCHITECTURAL IMPROVEMENTS
1. Standardized wrappers (model_wrappers.py)
2. Self-contained artifacts (code_paths + uv.lock)
↓
LONG-TERM GOAL: Eliminate Pickle
Option A: Native formats (.cbm, pt2, skops)
Option B: Models from Code (store source, not objects)
Core principle: The closer your ML artifacts stay to pure weights and configuration (rather than serialized Python objects), the more resilient they are to environment changes.