Model Versioning with MLflow and DVC
A model version is only useful if you can say what produced it. This guide combines DVC for data with MLflow for models, so each registered version records its data hash and Git commit, then rebuilds an old version from that record alone and checks that the predictions come out identical.
GloryTecks Team
Training & Placement Faculty
Last updated: 8 October 2026
Six months after a model went live, someone asks a reasonable question: why did it make this prediction, and would last quarter's data have changed it? Answering means rebuilding the model exactly as it was, and that is only possible if three things were recorded together: the code, the data and the settings.
Git versions code. MLflow records runs and models. DVC versions data. Each on its own leaves a gap. Linked together, they give every model a complete, rebuildable lineage.
The link
The method is simple. When a model is trained, record in its MLflow run:
- The data version: the hash DVC computed for the dataset.
- The code version: the Git commit.
- The parameters, including the random seed.
MLflow tracks parameters, code versions and metrics for each run, and runs can carry tags (MLflow: tracking). Each registered model version keeps its link to the run that produced it (MLflow: model registry). And because DVC's small pointer files live in Git, a commit pins an exact data version that dvc checkout can restore (DVC: get started).
Proving it works
The script builds a project with two versions of the UCI Bike Sharing data, released under CC BY 4.0 (UCI: Bike Sharing). It trains and registers a model on each, then does what an auditor would: starting from nothing but the registry, it rebuilds version 1 and checks whether it is the same model.
import os
import shutil
import stat
import subprocess
import sys
from pathlib import Path
import mlflow
import numpy as np
import pandas as pd
import yaml
from mlflow import MlflowClient
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error
os.environ["DVC_NO_ANALYTICS"] = "1"
DVC = str(Path(sys.executable).with_name("dvc"))
FEATURES = ["season", "yr", "mnth", "holiday", "weekday", "workingday", "weathersit", "temp", "atemp", "hum", "windspeed"]
day = pd.read_csv("data/day.csv") # UCI Bike Sharing (CC BY 4.0)
holdout = day[day.dteday >= "2012-10-01"] # the last quarter, never used for training
def make_writable(func, path, _):
os.chmod(path, stat.S_IWRITE)
func(path)
for old in ["project", "mlruns"]: # start every run from a clean slate
if Path(old).exists():
shutil.rmtree(old, onerror=make_writable)
Path("lineage.db").unlink(missing_ok=True)
project = Path("project")
project.mkdir()
def run(*cmd):
out = subprocess.run(cmd, cwd=project, capture_output=True, text=True)
if out.returncode:
raise RuntimeError(out.stderr)
return out.stdout
run("git", "init", "-q")
run("git", "config", "user.email", "demo@example.com")
run("git", "config", "user.name", "Demo")
run(DVC, "init", "-q")
(project / "data").mkdir()
mlflow.set_tracking_uri("sqlite:///lineage.db")
mlflow.set_experiment("bike-lineage")
client = MlflowClient()
def train(data):
return RandomForestRegressor(n_estimators=100, random_state=42).fit(data[FEATURES], data.cnt)
# 1. Two data versions, each committed with DVC; each model run records the data hash it used
for until, message in [("2012-04-01", "Data to March 2012"), ("2012-10-01", "Data to September 2012")]:
day[day.dteday < until].to_csv(project / "data/rides.csv", index=False)
run(DVC, "add", "-q", "data/rides.csv")
run("git", "add", "-A")
run("git", "commit", "-q", "-m", message)
data_md5 = yaml.safe_load((project / "data/rides.csv.dvc").read_text())["outs"][0]["md5"]
commit = run("git", "rev-parse", "HEAD").strip()
with mlflow.start_run(run_name=message):
model = train(pd.read_csv(project / "data/rides.csv"))
mlflow.log_metric("holdout_mae", mean_absolute_error(holdout.cnt, model.predict(holdout[FEATURES])))
mlflow.set_tags({"data_md5": data_md5, "git_commit": commit})
info = mlflow.sklearn.log_model(model, name="model", skops_trusted_types=["sklearn.tree._tree.Tree"])
mlflow.register_model(info.model_uri, "bike-lineage")
print(f"{'version':<8} {'trained on':<24} {'data md5':<34} {'holdout MAE':>11}")
for mv in sorted(client.search_model_versions("name='bike-lineage'"), key=lambda m: int(m.version)):
r = client.get_run(mv.run_id)
print(f"v{mv.version:<7} {r.info.run_name:<24} {r.data.tags['data_md5']:<34} {r.data.metrics['holdout_mae']:>11,.0f}")
# 2. Months later: reproduce version 1 from nothing but the registry's record
v1 = client.get_model_version("bike-lineage", "1")
tags = client.get_run(v1.run_id).data.tags
run("git", "checkout", "-q", tags["git_commit"], "--", "data/rides.csv.dvc") # the pointer from that commit
run(DVC, "checkout", "-q", "data/rides.csv.dvc") # the data it points to
restored = pd.read_csv(project / "data/rides.csv")
rebuilt = train(restored)
original = mlflow.sklearn.load_model(f"models:/bike-lineage/1")
same = np.array_equal(rebuilt.predict(holdout[FEATURES]), original.predict(holdout[FEATURES]))
print(f"\nrestored data for v1: {len(restored)} rows, last day {restored.dteday.max()}")
print("retrained model gives identical predictions to the registered v1:", same)version trained on data md5 holdout MAE
v1 Data to March 2012 cd7f00f888f1c654be1b710b6653e94b 1,092
v2 Data to September 2012 f1b4f5cb4cb7f519f46a4441f1f1586a 996
restored data for v1: 456 rows, last day 2012-03-31
retrained model gives identical predictions to the registered v1: TrueWhat the output shows
- Each version names its data. Version 1 was trained on data to March 2012, version 2 on data to September; their data hashes differ, and so do their errors on the final quarter: 1,092 and 996 rentals a day.
- The registry was enough to find the data. From version 1's run, the script read the commit, restored the pointer file from it, and let DVC bring back exactly the 456 rows that trained it.
- The rebuilt model matched. Its predictions were identical to those of the registered version 1. That is the test that matters: not "we think this is the data", but "this data and this code produce this model".
- The seed did its part. The forest was trained with a fixed random state. scikit-learn's guidance is that an integer
random_stategives the same results on every fit, and thatrandom_state=Noneshould be removed when results must be reproducible (scikit-learn: common pitfalls).
What to record for every model version
| Record | Where | Why |
|---|---|---|
| Data hash or data version | MLflow tag, from DVC | Identifies exactly what trained the model |
| Git commit | MLflow tag | Recovers the training code and pipeline definition |
| Parameters and seed | MLflow parameters | Recreates the training configuration |
| Package versions | Logged with the model | The same code can behave differently across library versions |
| Evaluation data and metrics | MLflow metrics and artefacts | Shows how the model was judged |
Questions lineage answers
Once every version carries its data hash and commit, questions that used to take days become queries:
- Which data trained the model that was live on a given date? Find the version the champion alias pointed to, then read its tags.
- Which models used a dataset that turned out to be wrong? Search runs for that data hash, and you have the list of versions to retrain or withdraw.
- What changed between two versions? Compare their data hashes, commits and parameters; if only the data changed, the difference in behaviour came from the data.
- Can we honour a request to remove someone's records? You can identify every model trained on data that included them, which is the first step in deciding what to rebuild.
When identical is not possible
Bit-for-bit reproduction is achievable for many classical models on a CPU, as here. It gets harder with GPU training, multi-threaded data loading or distributed jobs, where tiny numerical differences creep in. In those cases aim for the same evaluation result within a tolerance, and record enough to explain any difference. See reproducibility in ML.
Putting it into practice
Make lineage automatic rather than a matter of discipline. Have the training pipeline read the data hash and commit itself and log them, as the script does, and refuse to train from uncommitted changes. For the tools individually, see the MLflow tutorial and data versioning with DVC. For promotion and rollback of the versions themselves, see the model registry.
Common mistakes
- Versioning models but not data.
- Recording a data path instead of a data version. Paths stay the same while contents change.
- Training from uncommitted code, so the recorded commit is not what ran.
- Unseeded randomness in splits and models.
- Never testing a rebuild until an auditor asks for one.
What is model versioning?
How do MLflow and DVC work together?
How can I reproduce an old model exactly?
Why do retrained models sometimes differ from the original?
Model versioning, MLflow and the registry are covered in the Experiment Tracking and Model Management module of the MLOps course.
Sources
- ML Experiment Tracking (MLflow documentation) — MLflow
- Model Registry (MLflow documentation) — MLflow
- Get Started with DVC (DVC documentation) — DVC
- Common pitfalls and recommended practices (scikit-learn documentation) — scikit-learn
- Bike Sharing dataset (UCI Machine Learning Repository) — UCI Machine Learning Repository
GloryTecks Team
Training & Placement Faculty
The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.
Want to master MLOps?
Join GloryTecks’ expert-led MLOps training in Hyderabad with real-time projects and 100% placement support.
Related articles
MLflow Tutorial: Tracking Experiments and Models
MLflow records what produced each model so you never have to remember. This tutorial trains three models on real bike-sharing data, logs every run, compares them by querying the tracking store, registers the best one, points a champion alias at it and loads it back by that alias, all on a laptop.
Scaling Model Training with Distributed Computing
Distributed training splits the work across machines, but keeping them in step costs time. In real PyTorch data-parallel runs with one, two and four workers, every replica stayed identical, yet small batches trained slower with more workers and only large batches gained, about one and a half times with four.
MLOps Roadmap 2026: Skills, Tools and Career Path
MLOps sits between data science, software engineering and operations, which makes it hard to know where to start. This roadmap orders the skills from foundations to production operations, gives each stage a project that proves the skill, and explains what the MLOps engineer role actually involves.