- What is MLflow?
- Installation and Server Setup
- The First Five Minutes: Start the Server, Log Your First Run
- Experiment Tracking
- What a Single Run Actually Records
- Model Registry
- Model Serving
- Experiment Comparison and Analysis
- Search Syntax: Where Almost Everyone Gets Stuck Once
- Production Checklist
- Failure Cases and Traps
- When Not to Use MLflow
- References
- Quiz
What is MLflow?
MLflow is an open-source platform for managing the ML lifecycle. It consists of four core components:
- MLflow Tracking: Records experiment parameters, metrics, and artifacts
- MLflow Projects: Packages ML code for reproducibility
- MLflow Models: Packages models from various frameworks in a unified format
- MLflow Model Registry: Model version management and deployment workflows
Installation and Server Setup
Basic Installation
# pip installation
pip install mlflow
# Additional framework support
pip install mlflow[extras] # sklearn, tensorflow, pytorch, etc.
# Start server (local)
mlflow server --host 0.0.0.0 --port 5000
# Production server with PostgreSQL + S3 backend
mlflow server \
--backend-store-uri postgresql://mlflow:password@localhost:5432/mlflow \
--default-artifact-root s3://mlflow-artifacts/ \
--host 0.0.0.0 --port 5000
Deployment with Docker Compose
# docker-compose.yml
services:
mlflow:
image: ghcr.io/mlflow/mlflow:v3.15.1
ports:
- '5000:5000'
environment:
- MLFLOW_BACKEND_STORE_URI=postgresql://mlflow:password@postgres:5432/mlflow
- MLFLOW_DEFAULT_ARTIFACT_ROOT=s3://mlflow-artifacts/
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
command: >
mlflow server
--backend-store-uri postgresql://mlflow:password@postgres:5432/mlflow
--default-artifact-root s3://mlflow-artifacts/
--host 0.0.0.0 --port 5000
depends_on:
- postgres
postgres:
image: postgres:16
environment:
POSTGRES_USER: mlflow
POSTGRES_PASSWORD: password
POSTGRES_DB: mlflow
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
The First Five Minutes: Start the Server, Log Your First Run
The code in this post was checked against MLflow 3.15.1. A few argument names changed between 2.x and 3.x, so it is faster to pin the version first.
Typing mlflow server with no arguments is enough to bring the server up. Since MLflow 3.7.0 SQLite has been the default backend store, so with no arguments at all it auto-creates sqlite:///mlflow.db in the directory you ran it from. Passing --backend-store-uri is no longer mandatory. The official docs do say that for production deployments with high concurrency you should consider PostgreSQL or MySQL. The backend store supports four dialects: sqlite, postgresql, mysql, mssql.
The current CLI docs list only mlflow server. The mlflow ui command that shows up constantly in older posts is not in the command index. No removal version is documented, so I will not claim one — but if that command is in your muscle memory, moving to mlflow server is the safer bet.
# No arguments — on MLflow 3.7.0+ this auto-creates ./mlflow.db
mlflow server
# When you want the storage locations to be explicit
mlflow server \
--backend-store-uri sqlite:///mlflow.db \
--artifacts-destination ./mlartifacts \
--host 127.0.0.1 --port 5000
# Client side (the shell that runs your script)
export MLFLOW_TRACKING_URI=http://127.0.0.1:5000
export MLFLOW_EXPERIMENT_NAME=iris-classification
Open port 5000 in a browser and the UI appears. At first there is a single Default experiment and the run list is empty. If you run your script and the list stays empty, the client is almost certainly not pointed at the server. The client learns the server address from a mlflow.set_tracking_uri() call or the MLFLOW_TRACKING_URI environment variable, and that variable defaults to None. Configure nothing and your records never reach the server. The experiment name and registry address are set separately, via MLFLOW_EXPERIMENT_NAME and MLFLOW_REGISTRY_URI.
Artifact paths confuse people on day one too. --serve-artifacts is on by default. That means the client goes through the tracking server instead of talking to storage directly, which is why artifact URIs in the UI start with mlflow-artifacts:/. The real storage location is set by --artifacts-destination. To let clients reach storage directly use --no-serve-artifacts, and to stand up a dedicated artifact proxy instead use --artifacts-only. Keeping those three straight narrows the search fast when training logs land but model files do not.
Experiment Tracking
Basic Usage
import mlflow
import mlflow.sklearn
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, f1_score, precision_score
# Configure tracking server
mlflow.set_tracking_uri("http://localhost:5000")
# Create/set experiment
mlflow.set_experiment("iris-classification")
# Prepare data
X, y = load_iris(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# Run experiment
with mlflow.start_run(run_name="rf-baseline"):
# Log parameters
params = {
"n_estimators": 100,
"max_depth": 5,
"min_samples_split": 2,
"random_state": 42
}
mlflow.log_params(params)
# Train model
model = RandomForestClassifier(**params)
model.fit(X_train, y_train)
# Predictions and metrics
y_pred = model.predict(X_test)
metrics = {
"accuracy": accuracy_score(y_test, y_pred),
"f1_macro": f1_score(y_test, y_pred, average="macro"),
"precision_macro": precision_score(y_test, y_pred, average="macro")
}
mlflow.log_metrics(metrics)
# Tags
mlflow.set_tag("model_type", "random_forest")
mlflow.set_tag("dataset", "iris")
# Save model (MLflow 3 uses name= instead of artifact_path=)
mlflow.sklearn.log_model(
model,
name="model",
registered_model_name="iris-classifier"
)
# Custom artifacts (plots, reports, etc.)
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
cm = confusion_matrix(y_test, y_pred)
fig, ax = plt.subplots()
ConfusionMatrixDisplay(cm).plot(ax=ax)
fig.savefig("confusion_matrix.png")
mlflow.log_artifact("confusion_matrix.png")
print(f"Run ID: {mlflow.active_run().info.run_id}")
print(f"Metrics: {metrics}")
Hyperparameter Tuning Tracking
import optuna
import mlflow
def objective(trial):
params = {
"n_estimators": trial.suggest_int("n_estimators", 50, 500),
"max_depth": trial.suggest_int("max_depth", 2, 20),
"min_samples_split": trial.suggest_int("min_samples_split", 2, 10),
"min_samples_leaf": trial.suggest_int("min_samples_leaf", 1, 5),
}
with mlflow.start_run(nested=True, run_name=f"trial-{trial.number}"):
mlflow.log_params(params)
model = RandomForestClassifier(**params, random_state=42)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
mlflow.log_metric("accuracy", accuracy)
return accuracy
# Run Optuna study
with mlflow.start_run(run_name="hyperparameter-tuning"):
study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=50)
# Log best results
mlflow.log_params(study.best_params)
mlflow.log_metric("best_accuracy", study.best_value)
mlflow.set_tag("best_trial", study.best_trial.number)
PyTorch Model Tracking
import torch
import torch.nn as nn
import mlflow.pytorch
class SimpleNet(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
return self.fc2(self.relu(self.fc1(x)))
with mlflow.start_run(run_name="pytorch-model"):
model = SimpleNet(4, 32, 3)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
mlflow.log_params({
"hidden_dim": 32,
"learning_rate": 0.001,
"optimizer": "Adam",
"epochs": 100
})
for epoch in range(100):
# Training logic...
loss = criterion(model(X_tensor), y_tensor)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# Log per-epoch metrics
mlflow.log_metric("train_loss", loss.item(), step=epoch)
# Save PyTorch model
mlflow.pytorch.log_model(model, "model")
What a Single Run Actually Records
A run opened by mlflow.start_run() holds four kinds of records: parameters, metrics, tags, and artifacts. Parameters are stored as strings; metrics are numbers, and passing step alongside them turns a metric into a curve over time.
start_run() takes run_id, experiment_id, run_name, nested, parent_run_id, tags, description, and log_system_metrics. The nested=True used in the tuning code above is the switch that creates child runs inside a parent, and children automatically receive an mlflow.parentRunId system tag. That tag is why the UI can fold them into a tree.
Model logging is where MLflow 3 visibly changed. First, the argument to mlflow.sklearn.log_model() and mlflow.pyfunc.log_model() is now name=. The docs state that artifact_path= is deprecated and that name should be used instead, and the change landed in MLflow 3.0. Second, you can call log_model() without an mlflow.start_run() context. The returned ModelInfo carries a model_id, and you can build a models:/ URI from it to load the model back. Third, the default serialization_format for mlflow.sklearn.log_model() is now skops. If your loading environment assumes cloudpickle, check it.
import mlflow
from mlflow.models import infer_signature
signature = infer_signature(model_input=X_train, model_output=model.predict(X_train))
# MLflow 3: logs without start_run() and hands back a ModelInfo
info = mlflow.sklearn.log_model(
model,
name="model", # artifact_path= is deprecated
signature=signature,
input_example=X_train[:2],
registered_model_name="iris-classifier",
)
print(info.model_id)
loaded = mlflow.pyfunc.load_model(f"models:/{info.model_id}")
infer_signature(model_input=None, model_output=None, params=None) infers the input/output schema and stores it alongside the model. With a signature attached, a wrong column count or type is rejected at request time during serving instead of quietly skewing predictions.
Once records pile up, mlflow.search_runs() pulls them out. The function returns a pandas DataFrame, which means you get results as a table you can sort and group by directly. Column names follow the storage-layer prefixes.
run_id status start_time params.n_estimators params.max_depth metrics.accuracy tags.model_type
Call it once with no filter and print .columns first, then pick the columns you want — the exact set depends on what you logged.
Model Registry
Model Registration and Version Management
from mlflow import MlflowClient
client = MlflowClient()
# Register model (auto-registered when using registered_model_name in log_model)
# Or manually register:
result = client.create_registered_model(
name="iris-classifier",
description="Iris flower classification model"
)
# Register a specific run's model as a version
model_version = client.create_model_version(
name="iris-classifier",
source=f"runs:/{run_id}/model",
run_id=run_id,
description="RandomForest baseline v1"
)
print(f"Model Version: {model_version.version}")
Deployment Management with Aliases
# MLflow 2.x uses Aliases (Stage is deprecated)
client = MlflowClient()
# Set production alias
client.set_registered_model_alias(
name="iris-classifier",
alias="champion",
version=3
)
# Set challenger model
client.set_registered_model_alias(
name="iris-classifier",
alias="challenger",
version=5
)
# Load model by alias
champion_model = mlflow.pyfunc.load_model("models:/iris-classifier@champion")
challenger_model = mlflow.pyfunc.load_model("models:/iris-classifier@challenger")
# A/B testing
champion_pred = champion_model.predict(X_test)
challenger_pred = challenger_model.predict(X_test)
print(f"Champion accuracy: {accuracy_score(y_test, champion_pred)}")
print(f"Challenger accuracy: {accuracy_score(y_test, challenger_pred)}")
Using Model Tags
# Add tags to model version
client.set_model_version_tag(
name="iris-classifier",
version=3,
key="validation_status",
value="approved"
)
client.set_model_version_tag(
name="iris-classifier",
version=3,
key="approved_by",
value="data-science-lead"
)
# Search models by tag
from mlflow import search_model_versions
approved_versions = search_model_versions(
"name='iris-classifier' AND tag.validation_status='approved'"
)
Model Serving
Built-in MLflow Serving
# Local REST API serving
mlflow models serve \
-m "models:/iris-classifier@champion" \
--port 8080 \
--env-manager local
# Test request
curl -X POST http://localhost:8080/invocations \
-H "Content-Type: application/json" \
-d '{"inputs": [[5.1, 3.5, 1.4, 0.2]]}'
Defaults for the Serving Command
mlflow models serve has a surprising number of defaults that bite. -p/--port is 5000, -h/--host is 127.0.0.1, -w/--workers is 1, and -t/--timeout is 60 seconds. The loopback host default means a container started as-is is unreachable from outside, and a single worker makes load-test numbers come in lower than expected.
The environment manager is chosen with --env-manager. Valid values are local, virtualenv, uv, and conda, and the default is virtualenv. A virtualenv default means a fresh isolated environment is built every time serving starts, so the first boot takes longer than you expect. Inside an image where dependencies are already pinned, --env-manager local is fastest; if you want reproducibility without giving up speed, uv is worth a look.
The /invocations endpoint accepts five payload keys: dataframe_split, dataframe_records, instances, inputs, and params. All five are currently valid and none carry a deprecation marker.
mlflow models serve \
-m "models:/iris-classifier@champion" \
--host 0.0.0.0 --port 8080 \
--workers 4 \
--env-manager local
Custom Serving with FastAPI
from fastapi import FastAPI
import mlflow.pyfunc
import numpy as np
app = FastAPI()
# Load model (once at server startup)
model = mlflow.pyfunc.load_model("models:/iris-classifier@champion")
@app.post("/predict")
async def predict(features: list[list[float]]):
predictions = model.predict(np.array(features))
return {
"predictions": predictions.tolist(),
"model_version": "champion"
}
@app.get("/health")
async def health():
return {"status": "healthy", "model": "iris-classifier@champion"}
Experiment Comparison and Analysis
Comparing in MLflow UI
# Search experiments (CLI)
mlflow runs list --experiment-id 1
# Search by metrics
mlflow runs list \
--experiment-id 1 \
--filter "metrics.accuracy > 0.95" \
--order-by "metrics.accuracy DESC"
Analysis with Python API
import mlflow
import pandas as pd
# Query all runs in an experiment
runs = mlflow.search_runs(
experiment_ids=["1"],
filter_string="metrics.accuracy > 0.9",
order_by=["metrics.accuracy DESC"],
max_results=10
)
# Analyze as DataFrame
print(runs[["run_id", "params.n_estimators", "params.max_depth", "metrics.accuracy"]])
# Find the best run
best_run = runs.iloc[0]
print(f"Best run: {best_run.run_id}, Accuracy: {best_run['metrics.accuracy']}")
Search Syntax: Where Almost Everyone Gets Stuck Once
mlflow.search_runs() and the UI search box use the same filter syntax. It is a short syntax, but a few rules catch you on the first attempt.
| Prefix | Target | Example |
|---|---|---|
metrics. | Numeric metrics | metrics.accuracy > 0.72 |
params. | Hyperparameters (stored as strings) | params.n_estimators = "100" |
tags. | User and system tags | tags.environment IS NOT NULL |
datasets. | Dataset information | datasets.name = "iris" |
attributes. | Attributes of the run itself | attributes.status = "FINISHED" |
Through attributes. you can reach status, user_id, run_name, run_id, start_time, and end_time.
Three things trip people up. First, AND is supported but OR is not — to or two conditions together you have to issue two queries and merge at the DataFrame level. Second, parameters are all stored as strings, so numeric-looking values still need double quotes. Third, LIKE is case-sensitive while ILIKE is not. IS NULL and IS NOT NULL work only on parameters and tags.
runs = mlflow.search_runs(
experiment_ids=["1"],
filter_string='metrics.accuracy > 0.72 AND metrics.loss <= 0.15',
order_by=["metrics.accuracy DESC"],
)
# Params are strings — without the quotes nothing matches
exact = mlflow.search_runs(filter_string='params.n_estimators = "100"')
# No OR, so query twice and stitch the results together
import pandas as pd
merged = pd.concat([
mlflow.search_runs(filter_string='tags.model_type = "random_forest"'),
mlflow.search_runs(filter_string='tags.model_type = "xgboost"'),
]).drop_duplicates(subset="run_id")
Production Checklist
□ Set backend store to PostgreSQL/MySQL
□ Set artifact store to S3/GCS/MinIO
□ Configure authentication/authorization (OIDC, Basic Auth)
□ Set up automatic experiment logging (autolog)
□ Establish Model Registry alias conventions
□ Automate model validation in CI/CD
□ Configure model serving health checks
□ Define experiment cleanup policies (archive old runs)
Failure Cases and Traps
Symptoms first, because that is the order you meet them in.
Symptom: training finished but the run is still RUNNING.
Diagnosis: autolog collided with a manual run. When no active run exists, mlflow.autolog() creates one itself and ends it once training finishes. But when a run is already open, the docs describe it as logging to that run while not automatically ending it after training. If you called start_run() without a with block, you have to call mlflow.end_run() yourself.
Symptom: you ran a 50-trial parameter search and got 5 child runs.
Diagnosis: sklearn autolog creates a single parent run and nested child runs for parameter search estimators, and the child count is capped by max_tuning_runs, which defaults to 5. The remaining trials are never recorded as individual runs at all. Raise the value if you want them all. The flavors autolog supports are Keras/TensorFlow, LightGBM, Paddle, PySpark, PyTorch, scikit-learn, Spark, statsmodels, and XGBoost.
Symptom: log_model() emits a deprecation warning.
Diagnosis: you are passing artifact_path=. It became name= in MLflow 3.0, and the docs say outright that artifact_path= is deprecated and name should be used instead. It is only a warning, so nothing breaks today, but new code should standardize on name=.
Symptom: a mlflow.register_model() call does not return for minutes.
Diagnosis: the function takes await_registration_for, which defaults to 300 seconds. It waits up to five minutes for the model version to become ready. When a CI pipeline gains five minutes for no visible reason, this is usually where it went.
Symptom: Stage code from an old tutorial warns or does not work.
Diagnosis: the docs state that model stages are deprecated and will be removed in a future major release. The API in question is transition_model_version_stage(). No removal version has been announced, but new code should move to aliases and tags. The name the docs offer as the rough equivalent of the old Production stage is champion. Use client.set_registered_model_alias() to attach one, client.get_model_version_by_alias() to read it, and client.delete_registered_model_alias() to remove it. Loading through a models:/iris-classifier@champion URI keeps version numbers out of your code.
Symptom: records fail intermittently once several people train at the same time. Diagnosis: check whether the backend store is SQLite. SQLite is file-lock based, so concurrent writes queue up or fail. The official guidance is clear: for production deployments with high concurrency, consider PostgreSQL or MySQL. SQLite is fine on a laptop you use alone; once a team joins, it is time to move.
# When autolog closes the run for you, and when it does not
mlflow.autolog()
model.fit(X_train, y_train) # no active run -> created and closed for you
with mlflow.start_run(run_name="manual"):
model.fit(X_train, y_train) # logged here, closed on block exit
run = mlflow.start_run(run_name="leaky")
model.fit(X_train, y_train) # logged, but never closed
mlflow.end_run() # you have to close it yourself
When Not to Use MLflow
Intro posts rarely write this part down, so it gets its own section.
For a one-off analysis that fits in a single notebook, MLflow is overkill. Here is one rule of thumb: if you have no intention of running the same code three or more times with different parameters, it is still early. Conversely, the first time someone asks what settings last week's run used, that is the moment to adopt it.
It is worth being explicit about what MLflow does not do.
- It does not orchestrate. Scheduling, retries, and dependency graphs belong to tools like Airflow or Argo.
- It is not a feature store. It neither computes features nor guarantees train/serve consistency.
- It is not a data versioning tool. You can attach dataset information to a run, but it does not snapshot the data itself.
- It is not a production monitoring tool. Drift and latency for deployed models need a separate observability stack.
In short, MLflow is a ledger of what you ran, with which settings, and how it turned out.
References
Links checked 2026-08-16. The arguments and defaults in this post follow the MLflow 3.15.1 docs and may differ on other versions. Confirm the exact API in the docs for the version you are running.
- MLflow 3 overview: https://mlflow.org/docs/latest/ml/mlflow-3/
- mlflow.sklearn API: https://mlflow.org/docs/latest/api_reference/python_api/mlflow.sklearn.html
- Model Registry: https://mlflow.org/docs/latest/ml/model-registry/
- Model Registry workflow: https://mlflow.org/docs/latest/ml/model-registry/workflow/
- Autologging: https://mlflow.org/docs/latest/ml/tracking/autolog/
- Search runs syntax: https://mlflow.org/docs/latest/ml/search/search-runs/
- CLI reference: https://mlflow.org/docs/latest/api_reference/cli.html
- Deploy a model locally: https://mlflow.org/docs/latest/ml/deployment/deploy-model-locally/
- Tracking server architecture: https://mlflow.org/docs/latest/self-hosting/architecture/tracking-server/
- Backend store: https://mlflow.org/docs/latest/self-hosting/backend-store/
Review Quiz (6 Questions)
Q1. What are the four core components of MLflow?
Tracking, Projects, Models, Model Registry
Q2. What is the difference between mlflow.log_params and mlflow.log_metrics?
log_params records training hyperparameters (strings), while log_metrics records performance metrics (numbers). Metrics support per-epoch tracking with the step parameter.
Q3. What concept is used for model deployment management in MLflow 2.x?
Aliases (e.g., @champion, @challenger). Stage has been deprecated.
Q4. When is the nested=True parameter used?
It is used when recording multiple child runs inside a parent run, such as during hyperparameter tuning.
Q5. Why use S3 as the artifact store?
It stores large artifacts like model files and plots in scalable object storage, making it easy to share across teams and manage versions.
Q6. What are the pros and cons of mlflow.autolog()?
Pros: Automatically records parameters/metrics/models without code changes. Cons: May record unnecessary information, and custom metrics still need to be logged separately.
Quiz
Q1: What is the main topic covered in "The Complete MLflow Guide: From Experiment Tracking to
Model Registry and Production Deployment"?
A hands-on walkthrough of the entire ML experiment management workflow with MLflow. Covers recording experiments with Tracking, version management with Model Registry, and production deployment.
Q2: What is MLflow??
MLflow is an open-source platform for managing the ML lifecycle. It consists of four core
components: MLflow Tracking: Records experiment parameters, metrics, and artifacts MLflow
Projects: Packages ML code for reproducibility MLflow Models: Packages models from various
framework...
Q3: What are the key steps for Installation and Server Setup?
Basic Installation Deployment with Docker Compose
Q4: What are the key aspects of Experiment Tracking?
Basic Usage Hyperparameter Tuning Tracking PyTorch Model Tracking
Q5: How does Model Registry work?
Model Registration and Version Management Deployment Management with Aliases Using Model Tags