MLOps for ML practitioners · Article 9

Managing model lifecycle with the MLflow Model Registry

Article 8 solved "which run was best." It didn't solve "which model is currently live." That's this article.

Part of the MLOps series · read Article 8 first · next up: orchestration and CI/CD

Look back at main.py from Article 7.5. It loads a model with one line:

model = joblib.load("models/model.pkl")

That line hasn't changed since Article 8 added tracking, and it's worth noticing why. MLflow Tracking gave every experiment a permanent record, but it never touched how the serving app finds a model. main.py still points at a static file path, and the only thing making that path "the good one" is a person who remembers which run produced it. Promote a better model tomorrow, and someone has to manually copy a new file into place, or edit and redeploy the API's code. Nothing enforces that this is safe, auditable, or even correct.

That's the specific gap the MLflow Model Registry closes: not which run was best, Article 8 already answered that, but which model is currently serving real traffic, and how it got there.


What this article covers

#Topic
1The problem: tracking doesn't manage what's live
2What the Model Registry is
3Registering a model from a run
4Stages vs. aliases: a real deprecation, handled honestly
5Promoting a model without touching serving code
6Loading a registered model in main.py
7The full lifecycle, start to finish

Part 1 — The problem, one level up from tracking

Article 8 answered "which of these six runs was actually the best." That's necessary, but it's not the same question a production API needs answered continuously: which model is live right now, has it been validated, and what happens the moment a better one is ready.

Without something managing that, teams reinvent the same fragile pattern every time: a shared drive folder, a filename convention like model_FINAL_v3.pkl, a Slack message saying "hey, the new one's ready, can someone update the server." It's Article 1's version-control nightmare again, just one layer further along, applied to which model is currently trusted with real predictions instead of which file has the right code.


Part 2 — What the Model Registry is

The MLflow Model Registry is a centralized store for named models and their versions. Register a model once, under a name like ChurnModel, and every time you register a new one under that same name, it becomes a new, immutable version, ChurnModel version 1, version 2, version 3, each pointing back to the exact run that produced it.

Registered Model: "ChurnModel" version 1 — from run logreg_default (accuracy 0.8055) version 2 — from run gb_200_lr005 (f1 0.5877) version 3 — from run rf_300_depth6 (accuracy 0.8048)
Fig. 1 — one registered name, many immutable versions, each traceable back to its run

A registered model requires the database-backed tracking store from Article 8. This is the concrete payoff of that detail: because MLflow 3.7 now defaults to SQLite instead of the classic file-only ./mlruns store, the Registry works here without any extra setup. On an older, file-only setup, none of what follows in this article would even be available.


Part 3 — Registering a model from a run

Any run that logged a model, which is every run from Article 8, can be registered.

import mlflow
from mlflow import MlflowClient

client = MlflowClient()

experiment = client.get_experiment_by_name("churn-prediction")
runs = client.search_runs(experiment_ids=[experiment.experiment_id], order_by=["metrics.accuracy DESC"])
best_run = runs[0]

model_uri = f"runs:/{best_run.info.run_id}/model"
registered = mlflow.register_model(model_uri=model_uri, name="ChurnModel")
print(f"Registered {registered.name} version {registered.version}")
Registered ChurnModel version 1

client.search_runs(..., order_by=["metrics.accuracy DESC"]) pulls every run in the experiment sorted by accuracy, so runs[0] is automatically the winner, no manual lookup needed. mlflow.register_model takes that run's model artifact and gives it a name and a version number. The run that started this, logreg_default from Article 7.5 and Article 8 both, just became something with a stable identity independent of which run produced it.


Part 4 — Stages vs. aliases: a real deprecation, handled honestly

Almost every MLflow tutorial you'll find, including plenty written well after this feature changed, teaches a four-stage lifecycle: None, Staging, Production, Archived, moved between with a call like this:

client.transition_model_version_stage(name="ChurnModel", version=1, stage="Staging")

That code still runs. It also produces this, confirmed by actually triggering it:

FutureWarning: mlflow.tracking.client.MlflowClient.transition_model_version_stage
is deprecated since 2.9.0. Model registry stages will be removed in a future
major release.

Stages are deprecated, not hypothetically, MLflow's own documentation states plainly that they'll be removed in a future major release.1 The replacement is aliases: flexible, named references you assign to any version, with no fixed state machine forcing a version through exactly four steps in exactly one order.

client.set_registered_model_alias(name="ChurnModel", alias="champion", version=1)

The conceptual mapping is straightforward if you want to keep thinking in the original four stages: staging for a version being tested, champion for the version serving real traffic (the conventional alias name for what used to be called Production), archived for a retired version. Nothing stops you from naming an alias literally production if that reads more naturally for your team, aliases are just strings.

Stages — deprecated since 2.9.0 None Staging Production Archived a version is in exactly one of these at a time Aliases — current version 1 @staging @champion @archived any number of aliases, on any version, at once This article uses aliases throughout — it's the API that still works next release
Fig. 2 — the old system forces one state at a time; aliases don't

Aliases are also genuinely more capable than stages were, not just a rename. A single version can hold several aliases at once, confirmed directly:

client.set_registered_model_alias("ChurnModel", "staging", 1)
client.set_registered_model_alias("ChurnModel", "archived", 1)

print(client.get_model_version("ChurnModel", "1").aliases)
['archived', 'staging']

Version 1 is simultaneously tagged staging and archived, something the old rigid stage system couldn't express at all, a version could only ever be in one stage. This article uses aliases throughout, since it's the API that will still work the next time MLflow ships a major version.


Part 5 — Promoting a model without touching serving code

Here's the actual point of all of this. Write the serving function once, pointed at an alias, not a hardcoded version number:

def load_production_model():
    return mlflow.sklearn.load_model("models:/ChurnModel@champion")

Assign champion to version 1, and this function loads version 1. Register a second, better model, and promoting it is a single line, no code change, no redeploy:

# a second, competing model gets registered — e.g. the tuned gradient boosting run
v2 = mlflow.register_model(model_uri=f"runs:/{gb_run.info.run_id}/model", name="ChurnModel")

model_before = load_production_model()
print("Currently serving version:", client.get_model_version_by_alias("ChurnModel", "champion").version)

# the entire promotion step
client.set_registered_model_alias(name="ChurnModel", alias="champion", version=v2.version)

model_after = load_production_model()
print("Now serving version:", client.get_model_version_by_alias("ChurnModel", "champion").version)
Currently serving version: 1
Now serving version: 2
load_production_model() @champion → version 1 load_production_model() @champion → version 2 before promotion after promotion returns version 1's model returns version 2's model
Fig. 3 — the identical function call, before and after one alias reassignment

Confirmed by actually running this: load_production_model() is the exact same function, called twice, returning two different models, because the alias it points at moved. That's the whole value proposition of the Registry in one demonstration. The serving code never knew a promotion happened. It just asked for @champion and got whatever @champion currently means.

Tags and descriptions attach useful context to a version too:

client.set_model_version_tag("ChurnModel", "1", "validated_by", "data-science-team")
client.update_model_version(
    name="ChurnModel", version="1",
    description="Logistic regression baseline, acc=0.8055, first model to clear the bar for production."
)

Anyone looking at version 1 later, in the UI or through the API, sees exactly why it was promoted and by what standard, not just a version number.


Part 6 — Loading a registered model in main.py

This is the update Article 7.5's serving app was always going to need. Before, main.py loaded a static file:

model = joblib.load("models/model.pkl")

Now it loads whatever the registry currently says is live:

"""main.py — FastAPI app serving the current @champion churn model."""
import mlflow
import pandas as pd
from fastapi import FastAPI
from schemas import Customer, ChurnPrediction

app = FastAPI(
    title="Customer Churn Prediction API",
    description="Predicts whether a telecom customer is likely to churn.",
    version="1.0.0",
)

mlflow.set_tracking_uri("sqlite:///mlflow.db")

# loaded once at startup, same rule as Article 6 — just a different source
model = mlflow.sklearn.load_model("models:/ChurnModel@champion")


@app.get("/health")
def health():
    return {"status": "healthy"}


@app.post("/predict", response_model=ChurnPrediction)
def predict(customer: Customer):
    data = customer.model_dump()
    input_df = pd.DataFrame([data])
    prediction = int(model.predict(input_df)[0])
    probability = float(model.predict_proba(input_df)[0][1])
    return ChurnPrediction(
        prediction=prediction,
        probability=round(probability, 4),
        interpretation="Likely to churn" if prediction == 1 else "Likely to stay",
    )

Exactly one line changed, the model source. Every rule from Article 6 about loading once at startup still applies unchanged, it's just loading from the registry instead of a file path now. Restart the API after a promotion, and it picks up whichever model @champion currently points to, automatically, with no code to review or redeploy beyond that restart.


Part 7 — The full lifecycle, start to finish

Putting every piece from this article in the order you'd actually use them:

import mlflow
from mlflow import MlflowClient

mlflow.set_tracking_uri("sqlite:///mlflow.db")
client = MlflowClient()

# 1. Find the best run from Article 8's tracked experiments
experiment = client.get_experiment_by_name("churn-prediction")
runs = client.search_runs(experiment_ids=[experiment.experiment_id], order_by=["metrics.accuracy DESC"])
best_run = runs[0]

# 2. Register it
v1 = mlflow.register_model(model_uri=f"runs:/{best_run.info.run_id}/model", name="ChurnModel")

# 3. Mark it as a staging candidate first, not live yet
client.set_registered_model_alias("ChurnModel", "staging", v1.version)

# 4. After validation, promote it to serve real traffic
client.set_registered_model_alias("ChurnModel", "champion", v1.version)
client.update_model_version("ChurnModel", v1.version, description="First production churn model.")

# 5. Weeks later, a better run comes along (from a future Article 8-style session)
# v2 = mlflow.register_model(model_uri=f"runs:/{new_run.info.run_id}/model", name="ChurnModel")
# client.set_registered_model_alias("ChurnModel", "staging", v2.version)
# ... validate v2 ...
# client.set_registered_model_alias("ChurnModel", "champion", v2.version)  # v1 quietly stops serving

# 6. Retire the old version's role explicitly, without deleting its history
# client.set_registered_model_alias("ChurnModel", "archived", v1.version)

Every version stays permanently in the registry, retired or not, each one still traceable back to the exact run, the exact parameters, and the exact metrics that produced it, courtesy of Article 8. Nothing gets overwritten. Nothing depends on someone's memory of which .pkl file was the good one.


Summary

The gap Article 8 left open is closed. A model isn't just a well-tracked run anymore, it's a named, versioned entity with a clear answer to "what's live right now," changeable with one alias reassignment and zero redeployment. The classic four-stage lifecycle, Staging, Production, Archived, is genuinely deprecated since MLflow 2.9, and this article used the current replacement, aliases, throughout, which are more flexible besides, a single version can carry multiple aliases at once, something the old system couldn't do. main.py now loads models:/ChurnModel@champion instead of a static file path, which means promoting a better model is a data operation, not a code change.

Zoom out, and Phase 2 of this series is done. Git versions code. DVC versions data and models. Docker guarantees an environment. FastAPI validates and serves. MLflow Tracking records every experiment that led to a decision. The Model Registry manages which decision is currently live. Every piece from Article 7.5's chaos now has a tool responsible for it.

What's still manual is the connective tissue between these pieces: someone still has to run track_experiments.py, decide when to promote a model, rebuild the Docker image, and redeploy. That's exactly where the series goes next, orchestration and CI/CD, turning this sequence of deliberate manual steps into something that runs on its own.