Skip to main content

MLflow Tutorial for Machine Learning in Python — Part 1: Install MLflow and Track Your First Experiment

· 41 min read
Jagdish Kumawat
Founder @ Dewiride

Every time you train a model, you change something: the number of trees, a setting, the data. A week later nobody remembers which change gave the best result. MLflow remembers it for you. In this first part you install MLflow, start your own tracking server, and log your first machine learning experiment from Python.

This is Part 1 of an 8-part series.

  1. Part 1: Install MLflow and Track Your First Experiment (you are here)
  2. Part 2: Log Metrics Over Time, Artifacts and Datasets (coming soon)
  3. Part 3: Autologging and Hyperparameter Tuning with Nested Runs (coming soon)
  4. Part 4: Log, Load and Validate Models (coming soon)
  5. Part 5: Evaluate Models and Set Quality Gates (coming soon)
  6. Part 6: Register, Version and Promote Models with the Model Registry (coming soon)
  7. Part 7: Serve a Model as a REST API and in Docker (coming soon)
  8. Part 8: Production Checklist and Recap (coming soon)

Every command and output on this page is real, captured while building the project.

What You'll Have at the End of the Series​

Each part covers one piece of MLflow in depth, on real projects you can run on your own computer. By the end of Part 8 you'll know how to:

  • track every training run in your own MLflow server: settings, results and charts (this part);
  • log richer evidence, such as metrics that change during training, files and the exact dataset a run used (Part 2);
  • let MLflow log for you and run a whole search for the best settings as one tidy parent run (Part 3);
  • save a trained model with its input format and environment, and load it back anywhere (Part 4);
  • evaluate models with MLflow's built-in metrics and charts, and refuse models that don't pass (Part 5);
  • register and promote models with versions and aliases in the Model Registry (Part 6);
  • serve a model as a REST API, and package it as a Docker image (Part 7);
  • run all of it safely with a production checklist (Part 8).

A second series, coming later, covers the other half of MLflow: tracing and evaluating apps built on large language models.

What MLflow Is​

MLflow is a free, open-source platform for the machine learning lifecycle. This part uses its oldest and most popular piece, MLflow Tracking: a record of everything you try.

Four words come up again and again:

WordWhat it meansExample in this part
Tracking serverA small web server that stores your records and shows them in a web page, the MLflow UIRuns on your computer at http://127.0.0.1:8080
ExperimentA folder for related runs, usually one per project or questionhouse-price-prediction
RunOne execution of your training codeTraining a forest with 300 trees
Params, metrics, tags, artifactsWhat a run records: its settings, its results, labels, and filesn_estimators=300, rmse=0.504, model_type, a chart

Here is how the pieces fit together:

Your code never writes to the database itself. It sends everything to the tracking server, and the server keeps it in two places: a SQLite database for the numbers and text, and a folder for files.

The Example Project​

This series is about MLflow, not about any one model, so the examples stay simple and are built for you along the way. Part 1 uses a house price project:

  • The data: scikit-learn's California Housing dataset. It has 20,640 rows, one per district from the 1990 U.S. census, with 8 numbers such as median income and house age. The value to predict is the district's median house value, in units of $100,000.
  • The model: a random forest from scikit-learn, one of the most reliable models for this kind of table.
  • What MLflow records: the forest's settings, three error scores, two labels and a chart of predicted against actual prices.

Later parts add two more projects, so you see MLflow handle different problems: predicting whether an online shopper buys, and whether a bank customer signs up for a deposit.

What This Part Costs​

Nothing. MLflow, Python, uv and scikit-learn are free, open-source software, and everything in this part runs on your own computer.

One thing to know before you start: open-source MLflow collects anonymous usage data by default (since MLflow 3.2.0), such as which features you use. It never collects your parameters, data or file names. You can switch it off with one environment variable; How to Turn Off MLflow Telemetry shows how, on macOS, Linux and Windows. It's off for everything on this page.

Prerequisites​

  • Python 3.15 and uv 0.13.8 or newer. uv is a fast tool that creates Python projects and installs packages. Check both:
Terminal
uv --version
uv run --python 3.15 python --version
Output
uv 0.13.8 (3d97a6aad 2026-11-12 aarch64-apple-darwin)
Python 3.15.2
  • VS Code or any editor you like.
  • About 1.5 GB of free disk space for MLflow and its packages, and an internet connection for the downloads.

Every command on this page works the same in Terminal (macOS), PowerShell (Windows) and Linux shells, unless a step shows a tab for each.

Step 1: Create a Folder for the Tracking Server​

The tracking server gets its own folder, separate from any project. Its database and files will live there, and later parts point other projects at the same server, the way a team shares one server.

Open a terminal in the folder where you keep your code, for example a source folder in your home folder, and run:

Terminal
uv init --bare --python 3.15 mlflow-server
Output
Initialized project `mlflow-server` at `/Users/you/source/mlflow-server`

--bare creates only a pyproject.toml file: no sample code, no README, no Git repository. The folder only runs a server, so that's all it needs. --python 3.15 records that it needs Python 3.15 or newer. How to Create a Python Project with uv explains every uv init option.

Move into the folder:

Terminal
cd mlflow-server

Step 2: Install MLflow​

Terminal
uv add mlflow

The output is long. Here is the start and the important lines:

Output
Using CPython 3.15.2 interpreter at: /Library/Frameworks/Python.framework/Versions/3.15/bin/python3
Creating virtual environment at: .venv
Resolved 92 packages in 2.06s
Built pyyaml==6.0.3
Prepared 88 packages in 14.71s
Installed 88 packages in 792ms
+ aiohappyeyeballs==2.7.1
+ aiohttp==3.14.5
...
+ mlflow==3.18.0
+ mlflow-skinny==3.18.0
+ mlflow-tracing==3.18.0
...
+ scikit-learn==1.9.1
...
+ uvicorn==0.54.0
...

uv add did three things:

  1. It created a virtual environment in .venv, a private copy of Python for this folder, so nothing is installed system-wide.
  2. It installed MLflow 3.18.0 and the 87 packages it needs, such as pandas, scikit-learn and the uvicorn web server.
  3. It wrote the versions it chose into uv.lock, so the same install can be repeated exactly.

The line Built pyyaml==6.0.3 is normal on Python 3.15: PyYAML has no ready-made package for Python 3.15 yet, so uv built it from its source code in a second.

Check the version:

Terminal
uv run mlflow --version
Output
mlflow, version 3.18.0

How to Install MLflow in a Python Project with uv covers the three MLflow packages and the other ways to install it.

Step 3: Start the Tracking Server​

Terminal
uv run mlflow server --port 8080
Output
Backend store URI not provided. Using sqlite:///mlflow.db
Registry store URI not provided. Using backend store URI.
2026/11/13 17:19:14 INFO mlflow.store.db.utils: Creating initial MLflow database tables...
2026/11/13 17:19:14 INFO mlflow.store.db.utils: Updating database tables
[MLflow] Security middleware enabled with default settings (localhost-only). To allow connections from other hosts, use --host 0.0.0.0 and configure --allowed-hosts and --cors-allowed-origins.
2026/11/13 17:19:15 INFO mlflow.server.jobs.executor_registry: Available backends after discovery: docker, local
2026/11/13 17:19:15 INFO mlflow.server.jobs.executor_registry: Job executor backend 'local' passed requirements check
2026/11/13 17:19:15 INFO: Uvicorn running on http://127.0.0.1:8080 (Press CTRL+C to quit)
2026/11/13 17:19:15 INFO: Started parent process [93838]
/Users/you/source/mlflow-server/.venv/lib/python3.15/site-packages/mlflow/server/fastapi_app.py:24: StarletteDeprecationWarning: starlette.middleware.wsgi is deprecated and will be removed in a future release. Please refer to https://github.com/abersheeran/a2wsgi as a replacement.
from starlette.middleware.wsgi import WSGIResponder, build_environ
/Users/you/source/mlflow-server/.venv/lib/python3.15/site-packages/mlflow/server/fastapi_app.py:24: StarletteDeprecationWarning: starlette.middleware.wsgi is deprecated and will be removed in a future release. Please refer to https://github.com/abersheeran/a2wsgi as a replacement.
from starlette.middleware.wsgi import WSGIResponder, build_environ
/Users/you/source/mlflow-server/.venv/lib/python3.15/site-packages/mlflow/server/fastapi_app.py:24: StarletteDeprecationWarning: starlette.middleware.wsgi is deprecated and will be removed in a future release. Please refer to https://github.com/abersheeran/a2wsgi as a replacement.
from starlette.middleware.wsgi import WSGIResponder, build_environ
/Users/you/source/mlflow-server/.venv/lib/python3.15/site-packages/mlflow/server/fastapi_app.py:24: StarletteDeprecationWarning: starlette.middleware.wsgi is deprecated and will be removed in a future release. Please refer to https://github.com/abersheeran/a2wsgi as a replacement.
from starlette.middleware.wsgi import WSGIResponder, build_environ
2026/11/13 17:19:18 INFO: Started server process [93843]
2026/11/13 17:19:18 INFO: Waiting for application startup.
2026/11/13 17:19:18 INFO: Started server process [93844]
2026/11/13 17:19:18 INFO: Waiting for application startup.
2026/11/13 17:19:18 INFO: Started server process [93842]
2026/11/13 17:19:18 INFO: Waiting for application startup.
2026/11/13 17:19:18 INFO: Started server process [93841]
2026/11/13 17:19:18 INFO: Waiting for application startup.
2026/11/13 17:19:18 INFO: Application startup complete.
2026/11/13 17:19:19 INFO: Application startup complete.
2026/11/13 17:19:19 INFO: Application startup complete.
2026/11/13 17:19:19 INFO: Application startup complete.

What the important lines mean:

  • Using sqlite:///mlflow.db: since MLflow 3.7.0, the server stores its records in a SQLite database file, mlflow.db, in the folder you start it from. The first start creates the tables.
  • Security middleware enabled … (localhost-only): the server only accepts requests from this computer. That's what you want on a laptop.
  • Uvicorn running on http://127.0.0.1:8080: the address of your server. 127.0.0.1 means "this computer".
  • Application startup complete appears four times because the server runs 4 worker processes by default. Wait for all four before you open the UI.
  • StarletteDeprecationWarning comes from MLflow's own code, once per worker. It's harmless; Fixed: "StarletteDeprecationWarning: starlette.middleware.wsgi is deprecated" When You Start the MLflow Server shows where it comes from.

A few seconds later the server's job runner adds four lines about scheduled tasks. They're for MLflow's AI features, and you can ignore them for now:

Output
2026/11/13 17:19:21 INFO mlflow.server.jobs.utils: Registered online_scoring_scheduler periodic task (runs every 1 minute)
2026/11/13 17:19:21 INFO mlflow.server.jobs.utils: Registered trace_archival_scheduler periodic task (polls every 1 minute and no-ops when trace archival is disabled or unconfigured)
2026/11/13 17:19:21 INFO mlflow.server.jobs.utils: Registered issue_detection_scheduler periodic task (polls every 15 minutes and no-ops when no experiment has a detection schedule)
2026/11/13 17:19:21 INFO mlflow.server.jobs.utils: SQL trace rollup scheduler was not registered because rollups are disabled

Why port 8080 and not MLflow's usual 5000? On a Mac, port 5000 belongs to the AirPlay Receiver. The server starts anyway, but the address in MLflow's own quickstart, http://localhost:5000, opens an "Access to localhost was denied" page instead of MLflow. Fixed: "Access to localhost was denied" HTTP ERROR 403 When You Open the MLflow UI on a Mac shows exactly why. Port 8080 works the same on macOS, Windows and Linux; How to Change the MLflow Server Port covers other ports.

Leave this terminal open. The server runs as long as the terminal does. How to Start an MLflow Tracking Server on Your Computer explains every option of mlflow server.

Step 4: Open the MLflow UI​

Open http://127.0.0.1:8080 in your browser. On the first visit, MLflow opens in GenAI mode with the MLflow Assistant panel on the right:

The MLflow 3.18.0 home page on the first visit, in GenAI mode, with the Welcome to MLflow page, a Default experiment under Recent Experiments, and the MLflow Assistant Beta panel open on the right.

This series is about classic machine learning, so make two changes:

  1. Click Model training at the top of the sidebar. The menu changes to Home, Experiments and Model registry.
  2. Close the MLflow Assistant panel with the X in its top-right corner.

The browser remembers both choices. This is what it looks like now:

The MLflow home page in Model training mode without the Assistant panel, showing the Default experiment under Recent Experiments.

The only experiment so far is Default, which every MLflow server creates. How to Open the MLflow UI and Find Your Runs tours every page you'll use in this series.

Step 5: Create the Project​

Open a second terminal, so the server keeps running in the first one, and go back to the folder that holds mlflow-server:

Terminal 2
cd ..
uv init --package --name house-price-prediction --python 3.15 dewiride-house-price-prediction
Output
Initialized project `house-price-prediction` at `/Users/you/source/dewiride-house-price-prediction`
Part of the commandWhat it does
--packageCreates an installable package with a src/ folder, the layout production projects use
--name house-price-predictionThe project's name. Python code imports it as house_price_prediction
--python 3.15Python 3.15 or newer
dewiride-house-price-predictionThe folder to create

uv also turned the folder into a Git repository. Move into it:

Terminal 2
cd dewiride-house-price-prediction

Step 6: Add the Packages​

Terminal 2
uv add mlflow scikit-learn pandas matplotlib pydantic-settings
Output
Using CPython 3.15.2 interpreter at: /Library/Frameworks/Python.framework/Versions/3.15/bin/python3.15
Creating virtual environment at: .venv
Resolved 92 packages in 494ms
Built house-price-prediction @ file:///Users/you/source/dewiride-house-price-prediction
Prepared 5 packages in 482ms
Installed 90 packages in 1.05s
...
+ matplotlib==3.11.2
+ mlflow==3.18.0
...
+ pandas==3.1.1
...
+ pydantic-settings==2.16.3
...
+ scikit-learn==1.9.1
...

This time uv prepared only 5 packages: the rest came from its cache, filled in Step 2.

PackageWhy the project needs it
mlflowSends params, metrics, tags and charts to the tracking server
scikit-learnThe dataset, the random forest and the error scores
pandasThe data arrives as a pandas table
matplotlibDraws the chart
pydantic-settingsReads the server address from a settings file

MLflow already depends on pandas, matplotlib and scikit-learn, but your code imports them directly, so they belong in your own list too. Your pyproject.toml now has:

dewiride-house-price-prediction/pyproject.toml (excerpt)
dependencies = [
"matplotlib>=3.11.2",
"mlflow>=3.18.0",
"pandas>=3.1.1",
"pydantic-settings>=2.16.3",
"scikit-learn>=1.9.1",
]

How to Add a Package to a Python Project with uv explains these version ranges and uv.lock.

Step 7: Point the Project at the Tracking Server​

The training code needs two settings: where the server is, and which experiment to use. They differ from one computer to another, so they live in a settings file, not in the code.

Create .env.example in the project folder. It's the template you commit to Git:

dewiride-house-price-prediction/.env.example
# The MLflow tracking server this project sends its runs to.
MLFLOW_TRACKING_URI=http://127.0.0.1:8080
# The MLflow experiment that groups this project's runs.
MLFLOW_EXPERIMENT_NAME=house-price-prediction

Copy it to .env, your own working copy:

Terminal 2
cp .env.example .env

MLFLOW_TRACKING_URI and MLFLOW_EXPERIMENT_NAME are MLflow's own standard names, so you never have to remember two names for the same setting.

.env holds settings for your computer only, so keep it out of Git. Add two lines at the end of .gitignore:

dewiride-house-price-prediction/.gitignore (add at the end)

# Local settings (copy .env.example to .env)
.env

Now the code that reads the file. Create src/house_price_prediction/config.py:

src/house_price_prediction/config.py
"""Settings, read from environment variables and the .env file."""

from functools import lru_cache

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
"""Where the project sends its MLflow runs."""

model_config = SettingsConfigDict(
env_prefix="HOUSE_PRICES_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)

mlflow_tracking_uri: str = Field(
default="http://127.0.0.1:8080",
validation_alias="MLFLOW_TRACKING_URI",
description="The address of the MLflow tracking server.",
)
mlflow_experiment_name: str = Field(
default="house-price-prediction",
min_length=1,
validation_alias="MLFLOW_EXPERIMENT_NAME",
description="The MLflow experiment that groups this project's runs.",
)


@lru_cache
def get_settings() -> Settings:
"""Read the settings once, then reuse the same object everywhere."""
return Settings()
  • BaseSettings fills each field from an environment variable or from .env. An environment variable wins over the file, and the file wins over the default.
  • validation_alias reads each field from MLflow's standard name, such as MLFLOW_TRACKING_URI.
  • env_prefix="HOUSE_PRICES_" applies to any setting without an alias, so a setting the project adds later can't clash with a variable another tool sets.
  • min_length=1 refuses an empty experiment name.
  • get_settings() reads the settings once and reuses them.

How to Load Settings from Environment Variables and a .env File with pydantic-settings shows each rule live, including the errors you get for bad values.

Step 8: Load and Split the Data​

Create src/house_price_prediction/data.py:

src/house_price_prediction/data.py
"""Load the California Housing data and split it for training and testing."""

from dataclasses import dataclass

import pandas as pd
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split

TARGET = "MedHouseVal"


@dataclass(frozen=True)
class DataSplit:
"""The features and target values for training and for testing."""

X_train: pd.DataFrame
X_test: pd.DataFrame
y_train: pd.Series
y_test: pd.Series


def load_housing_data() -> pd.DataFrame:
"""Return the dataset as one table. The first call downloads it; later calls read a cache."""
return fetch_california_housing(as_frame=True).frame


def split_data(frame: pd.DataFrame, test_size: float, random_state: int) -> DataSplit:
"""Hold back `test_size` of the rows to measure the model on data it never saw."""
features = frame.drop(columns=TARGET)
target = frame[TARGET]
X_train, X_test, y_train, y_test = train_test_split(
features, target, test_size=test_size, random_state=random_state
)
return DataSplit(X_train, X_test, y_train, y_test)

load_housing_data() returns the whole dataset as one table. split_data() keeps a share of the rows aside as a test set: the model never sees them while training, so its score on them is honest. The same random_state always picks the same rows. How to Load the California Housing Dataset in Python with scikit-learn and How to Split Data into Training and Test Sets with scikit-learn cover both in detail.

Step 9: Draw the Predicted vs Actual Chart​

Numbers tell you how wrong a model is on average. A chart shows where it's wrong. Create src/house_price_prediction/plots.py:

src/house_price_prediction/plots.py
"""Charts that show how good the model's predictions are."""

import numpy as np
from matplotlib.figure import Figure


def predicted_vs_actual(actual: np.ndarray, predicted: np.ndarray) -> Figure:
"""Plot each prediction against the true value. Perfect predictions sit on the dashed line."""
figure = Figure(figsize=(6, 6), layout="constrained")
axes = figure.subplots()
axes.scatter(actual, predicted, s=4, alpha=0.3)
low = min(np.min(actual), np.min(predicted))
high = max(np.max(actual), np.max(predicted))
axes.plot([low, high], [low, high], linestyle="--", color="gray", label="Perfect prediction")
axes.set_xlabel("Actual median house value ($100,000s)")
axes.set_ylabel("Predicted median house value ($100,000s)")
axes.set_title("Predicted vs actual house values")
axes.legend(loc="upper left")
return figure

The function builds a Figure directly instead of using pyplot, so it never needs a window or a display, which matters on servers. How to Plot Predicted vs Actual Values with Matplotlib explains how to read the chart.

Step 10: Track the Training Run with MLflow​

This is the file where MLflow does its work. Create src/house_price_prediction/train.py and build it up piece by piece.

The imports and a logger. Add:

src/house_price_prediction/train.py
"""Train a house price model and record the run in MLflow."""

import argparse
import logging
from collections.abc import Sequence

import mlflow
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error, r2_score, root_mean_squared_error

from house_price_prediction.config import get_settings
from house_price_prediction.data import load_housing_data, split_data
from house_price_prediction.plots import predicted_vs_actual

logger = logging.getLogger("house_price_prediction")

The command-line options. Each run should be able to try different settings without editing the code, so they come from the command line. Add below the logger:

src/house_price_prediction/train.py (add below the logger)
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""Read the training options from the command line."""
parser = argparse.ArgumentParser(
prog="train",
description="Train a model that predicts house prices, and track the run in MLflow.",
)
parser.add_argument(
"--n-estimators", type=int, default=100, help="number of trees in the forest (default: 100)"
)
parser.add_argument(
"--max-depth",
type=int,
default=None,
help="how deep each tree may grow (default: no limit)",
)
parser.add_argument(
"--test-size",
type=float,
default=0.2,
help="share of the rows kept back for testing (default: 0.2)",
)
parser.add_argument(
"--random-state",
type=int,
default=42,
help="seed that makes the split and the model repeatable (default: 42)",
)
parser.add_argument(
"--run-name", default=None, help="name shown in the MLflow UI (default: MLflow picks one)"
)
args = parser.parse_args(argv)
if args.n_estimators < 1:
parser.error("--n-estimators must be 1 or more")
if args.max_depth is not None and args.max_depth < 1:
parser.error("--max-depth must be 1 or more")
if not 0 < args.test_size < 1:
parser.error("--test-size must be between 0 and 1")
return args

The last six lines refuse impossible values, such as zero trees, before anything is trained or logged.

Connect to the server and choose the experiment. Add the start of main():

src/house_price_prediction/train.py (add at the end)
def main(argv: Sequence[str] | None = None) -> int:
"""Train one model, measure it on the test rows and log everything to MLflow."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%Y/%m/%d %H:%M:%S",
)
args = parse_args(argv)
settings = get_settings()

mlflow.set_tracking_uri(settings.mlflow_tracking_uri)
mlflow.set_experiment(settings.mlflow_experiment_name)

logger.info("Loading the California Housing data")
data = split_data(load_housing_data(), args.test_size, args.random_state)
  • mlflow.set_tracking_uri() tells MLflow where to send everything: your server. Without it, MLflow would quietly create a separate mlflow.db file in the folder you run from, and nothing would appear in your UI. How to Set the MLflow Tracking URI in Python shows that trap live.
  • mlflow.set_experiment() makes house-price-prediction the active experiment, and creates it on the first run. How to Create and Set an MLflow Experiment covers the other ways to create one.
  • The log format matches MLflow's own, so all messages line up.

Open a run and record the settings. Add inside main():

src/house_price_prediction/train.py (add inside main)
with mlflow.start_run(run_name=args.run_name) as run:
mlflow.log_params(
{
"n_estimators": args.n_estimators,
"max_depth": args.max_depth,
"test_size": args.test_size,
"random_state": args.random_state,
}
)
mlflow.set_tags({"model_type": "random_forest", "dataset": "california_housing"})
  • mlflow.start_run() starts a run. Everything logged inside the with block belongs to it. When the block ends, MLflow marks the run Finished; if your code fails inside it, the run is marked Failed. With no run_name, MLflow invents a name such as smiling-moose-157. See How to Start, Name and End an MLflow Run.
  • mlflow.log_params() records the settings that shape the result. MLflow stores params as text and never lets you change one after it's logged. See How to Log Parameters in MLflow.
  • mlflow.set_tags() adds labels you can search and filter by. See How to Add Tags to an MLflow Run.

Train the model, then record the results. Add below the tags, still inside the with block:

src/house_price_prediction/train.py (add inside the with block)
model = RandomForestRegressor(
n_estimators=args.n_estimators,
max_depth=args.max_depth,
random_state=args.random_state,
n_jobs=-1,
)
logger.info("Training on %d rows", len(data.X_train))
model.fit(data.X_train, data.y_train)
predictions = model.predict(data.X_test)

metrics = {
"mae": mean_absolute_error(data.y_test, predictions),
"rmse": root_mean_squared_error(data.y_test, predictions),
"r2": r2_score(data.y_test, predictions),
}
mlflow.log_metrics(metrics)
figure = predicted_vs_actual(data.y_test.to_numpy(), predictions)
mlflow.log_figure(figure, "plots/predicted_vs_actual.png")

logger.info(
"Run %s: MAE %.3f, RMSE %.3f, R2 %.3f",
run.info.run_name,
metrics["mae"],
metrics["rmse"],
metrics["r2"],
)
return 0

The complete file:

The complete train.py in a macOS-style code window: the command-line options, then main(), which sets the MLflow tracking URI and experiment, starts a run, logs four params and two tags, trains a random forest, and logs three metrics and the predicted vs actual chart.

src/house_price_prediction/train.py
"""Train a house price model and record the run in MLflow."""

import argparse
import logging
from collections.abc import Sequence

import mlflow
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error, r2_score, root_mean_squared_error

from house_price_prediction.config import get_settings
from house_price_prediction.data import load_housing_data, split_data
from house_price_prediction.plots import predicted_vs_actual

logger = logging.getLogger("house_price_prediction")


def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""Read the training options from the command line."""
parser = argparse.ArgumentParser(
prog="train",
description="Train a model that predicts house prices, and track the run in MLflow.",
)
parser.add_argument(
"--n-estimators", type=int, default=100, help="number of trees in the forest (default: 100)"
)
parser.add_argument(
"--max-depth",
type=int,
default=None,
help="how deep each tree may grow (default: no limit)",
)
parser.add_argument(
"--test-size",
type=float,
default=0.2,
help="share of the rows kept back for testing (default: 0.2)",
)
parser.add_argument(
"--random-state",
type=int,
default=42,
help="seed that makes the split and the model repeatable (default: 42)",
)
parser.add_argument(
"--run-name", default=None, help="name shown in the MLflow UI (default: MLflow picks one)"
)
args = parser.parse_args(argv)
if args.n_estimators < 1:
parser.error("--n-estimators must be 1 or more")
if args.max_depth is not None and args.max_depth < 1:
parser.error("--max-depth must be 1 or more")
if not 0 < args.test_size < 1:
parser.error("--test-size must be between 0 and 1")
return args


def main(argv: Sequence[str] | None = None) -> int:
"""Train one model, measure it on the test rows and log everything to MLflow."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%Y/%m/%d %H:%M:%S",
)
args = parse_args(argv)
settings = get_settings()

mlflow.set_tracking_uri(settings.mlflow_tracking_uri)
mlflow.set_experiment(settings.mlflow_experiment_name)

logger.info("Loading the California Housing data")
data = split_data(load_housing_data(), args.test_size, args.random_state)

with mlflow.start_run(run_name=args.run_name) as run:
mlflow.log_params(
{
"n_estimators": args.n_estimators,
"max_depth": args.max_depth,
"test_size": args.test_size,
"random_state": args.random_state,
}
)
mlflow.set_tags({"model_type": "random_forest", "dataset": "california_housing"})

model = RandomForestRegressor(
n_estimators=args.n_estimators,
max_depth=args.max_depth,
random_state=args.random_state,
n_jobs=-1,
)
logger.info("Training on %d rows", len(data.X_train))
model.fit(data.X_train, data.y_train)
predictions = model.predict(data.X_test)

metrics = {
"mae": mean_absolute_error(data.y_test, predictions),
"rmse": root_mean_squared_error(data.y_test, predictions),
"r2": r2_score(data.y_test, predictions),
}
mlflow.log_metrics(metrics)
figure = predicted_vs_actual(data.y_test.to_numpy(), predictions)
mlflow.log_figure(figure, "plots/predicted_vs_actual.png")

logger.info(
"Run %s: MAE %.3f, RMSE %.3f, R2 %.3f",
run.info.run_name,
metrics["mae"],
metrics["rmse"],
metrics["r2"],
)
return 0

Step 11: Add the train Command​

Two small edits make the code easy to run. First, replace everything in src/house_price_prediction/__init__.py (uv's sample main() function) with one line that describes the package:

src/house_price_prediction/__init__.py
"""Predict California house prices and track every training run with MLflow."""

Then open pyproject.toml and change two things: the placeholder description, and the [project.scripts] entry, so the command is called train and runs main() in train.py:

dewiride-house-price-prediction/pyproject.toml (changed lines)
description = "Predict California house prices with scikit-learn and track every training run in MLflow."

[project.scripts]
train = "house_price_prediction.train:main"

Check the command:

Terminal 2
uv run train --help
Output
usage: train [-h] [--n-estimators N_ESTIMATORS] [--max-depth MAX_DEPTH] [--test-size TEST_SIZE]
[--random-state RANDOM_STATE] [--run-name RUN_NAME]

Train a model that predicts house prices, and track the run in MLflow.

options:
-h, --help show this help message and exit
--n-estimators N_ESTIMATORS
number of trees in the forest (default: 100)
--max-depth MAX_DEPTH
how deep each tree may grow (default: no limit)
--test-size TEST_SIZE
share of the rows kept back for testing (default: 0.2)
--random-state RANDOM_STATE
seed that makes the split and the model repeatable (default: 42)
--run-name RUN_NAME name shown in the MLflow UI (default: MLflow picks one)

How to Run a Python Script with uv run explains how uv run finds the train command.

Step 12: Run Your First Experiment​

Make sure the server is still running in the first terminal, then:

Terminal 2
uv run train
Output
Built house-price-prediction @ file:///Users/you/source/dewiride-house-price-prediction
Uninstalled 1 package in 2ms
Installed 1 package in 3ms
2026/11/13 17:20:59 INFO mlflow.tracking.fluent: Experiment with name 'house-price-prediction' does not exist. Creating a new experiment.
2026/11/13 17:20:59 INFO house_price_prediction: Loading the California Housing data
2026/11/13 17:20:59 INFO sklearn.datasets._california_housing: Downloading Cal. housing from https://ndownloader.figshare.com/files/5976036 to /Users/you/scikit_learn_data
2026/11/13 17:21:01 INFO house_price_prediction: Training on 16512 rows
2026/11/13 17:21:03 INFO house_price_prediction: Run smiling-moose-157: MAE 0.328, RMSE 0.506, R2 0.804
🏃 View run smiling-moose-157 at: http://127.0.0.1:8080/#/experiments/1/runs/8dbe08fc91434c5b9b22456dcd740ebf
🧪 View experiment at: http://127.0.0.1:8080/#/experiments/1

Line by line:

  1. Built, Uninstalled, Installed: you changed pyproject.toml, so uv reinstalled your project before running it. That's how the new train command appeared.
  2. Creating a new experiment: set_experiment() didn't find house-price-prediction, so it created it, with ID 1.
  3. Downloading Cal. housing: scikit-learn downloaded the dataset once and cached it in your home folder. Later runs skip this line.
  4. Training on 16512 rows: 80% of the 20,640 rows. The other 4,128 are the test set.
  5. The scores: on houses it never saw, the model misses by about 0.328 (×$100,000), so about $32,800 on average, and explains about 80% of the variation.
  6. The two links: MLflow prints them when a run ends. Your run ID will differ.

Open the first link. This is the run you just recorded:

The MLflow run page for smiling-moose-157: Metrics (3) with mae 0.3278, rmse 0.5062 and r2 0.8044, Parameters (4) with n_estimators 100, max_depth None, test_size 0.2 and random_state 42, and About this run with Created by you, Status Finished, Source train and the tags model_type random_forest and dataset california_housing.

Step 13: Look Around the Run​

Everything on that page came from your code:

SectionWhere it came from
Metrics (3)mlflow.log_metrics()
Parameters (4)mlflow.log_params(). max_depth shows None because you didn't limit the depth
Tagsmlflow.set_tags()
Created byYour computer's user name, which MLflow records by itself (you on this page)
SourceThe command that ran: train
StatusFinished, because the with block ended without an error

Click the Artifacts tab, open the plots folder and click predicted_vs_actual.png:

The Artifacts tab of the smiling-moose-157 run, showing the plots folder with predicted_vs_actual.png selected, its mlflow-artifacts path, and the scatter chart of predicted against actual house values with a dashed perfect-prediction line.

Most dots sit near the dashed line, so most predictions are close. The column of dots at the right edge is a quirk of the data: the dataset caps house values at 5.0 ($500,000), so every expensive district has the same "actual" value.

Step 14: Run More Experiments and Compare Them​

One run tells you little. Try three more forests, each with a name you'll recognize later:

Terminal 2
uv run train --n-estimators 50 --max-depth 8 --run-name 50-trees-depth-8
uv run train --n-estimators 200 --max-depth 16 --run-name 200-trees-depth-16
uv run train --n-estimators 300 --run-name 300-trees
Output
2026/11/13 17:21:07 INFO house_price_prediction: Loading the California Housing data
2026/11/13 17:21:07 INFO house_price_prediction: Training on 16512 rows
2026/11/13 17:21:08 INFO house_price_prediction: Run 50-trees-depth-8: MAE 0.403, RMSE 0.585, R2 0.739
🏃 View run 50-trees-depth-8 at: http://127.0.0.1:8080/#/experiments/1/runs/ec7ccb942d66407490b3aee0cb4d9b62
🧪 View experiment at: http://127.0.0.1:8080/#/experiments/1
2026/11/13 17:21:11 INFO house_price_prediction: Loading the California Housing data
2026/11/13 17:21:11 INFO house_price_prediction: Training on 16512 rows
2026/11/13 17:21:15 INFO house_price_prediction: Run 200-trees-depth-16: MAE 0.330, RMSE 0.508, R2 0.803
🏃 View run 200-trees-depth-16 at: http://127.0.0.1:8080/#/experiments/1/runs/09c7c9012ca74eafaf16c8766226d6b7
🧪 View experiment at: http://127.0.0.1:8080/#/experiments/1
2026/11/13 17:21:19 INFO house_price_prediction: Loading the California Housing data
2026/11/13 17:21:19 INFO house_price_prediction: Training on 16512 rows
2026/11/13 17:21:23 INFO house_price_prediction: Run 300-trees: MAE 0.327, RMSE 0.504, R2 0.806
🏃 View run 300-trees at: http://127.0.0.1:8080/#/experiments/1/runs/7cb465b94f2e426f9af5a12c55326cbe
🧪 View experiment at: http://127.0.0.1:8080/#/experiments/1

There's no download line any more: the dataset came from the cache.

Now compare them. Open the house-price-prediction experiment. By default the runs table shows names and times, not results, so add the result columns: click Columns, tick mae, r2 and rmse under Metrics and max_depth and n_estimators under Parameters, and untick Dataset, Source and Models to make room:

The house-price-prediction runs table with columns for Duration, the metrics mae, r2 and rmse, and the parameters max_depth and n_estimators: 300-trees has r2 0.8062, 200-trees-depth-16 0.8033, 50-trees-depth-8 0.7388 and smiling-moose-157 0.8044.

For a picture of the same results, click the chart icon, the second button above the table:

The chart view of the runs page with bar charts for mae, r2 and rmse, one bar per run, where 50-trees-depth-8 has the longest error bars and the shortest r2 bar.

To see one setting's effect, tick all four runs, click Compare, open the Scatter Plot tab, and choose n_estimators for the X-axis and rmse for the Y-axis:

The Comparing 4 Runs page on the Scatter Plot tab with n_estimators on the X-axis and rmse on the Y-axis: 50 trees at about 0.585, and 100, 200 and 300 trees all near 0.505.

What the comparison tells you:

  • The shallow forest is clearly worse. 50-trees-depth-8 has the highest errors and explains only 74% of the variation.
  • More trees barely help after 100. 100, 200 and 300 trees all land near 0.505 RMSE, but the 300-tree run took 4.1 seconds against 1.6 seconds for 100 trees (the Duration column).
  • So smiling-moose-157, the default 100-tree forest, is a fair choice: nearly the best score at a fraction of the time.

That's the point of tracking: the decision comes from recorded evidence, not from memory. How to Compare Runs in the MLflow UI covers every comparison view, including search filters such as metrics.r2 > 0.8.

Step 15: See Where MLflow Stored Everything​

The first terminal is busy running the server, so open a third terminal in the mlflow-server folder:

Terminal 3 (in mlflow-server)
ls -F
find mlartifacts -type f
Output
mlartifacts/ mlflow.db pyproject.toml uv.lock
mlartifacts/1/09c7c9012ca74eafaf16c8766226d6b7/artifacts/plots/predicted_vs_actual.png
mlartifacts/1/7cb465b94f2e426f9af5a12c55326cbe/artifacts/plots/predicted_vs_actual.png
mlartifacts/1/8dbe08fc91434c5b9b22456dcd740ebf/artifacts/plots/predicted_vs_actual.png
mlartifacts/1/ec7ccb942d66407490b3aee0cb4d9b62/artifacts/plots/predicted_vs_actual.png

The server keeps files in mlartifacts/<experiment ID>/<run ID>/artifacts/, one folder per run. Everything else is in mlflow.db. Ask the database for the 300-trees run's params, metrics and tags with Python's built-in SQLite tool, which works the same in every shell:

Terminal 3 (in mlflow-server)
uv run python -m sqlite3 mlflow.db "SELECT key, value FROM params WHERE run_uuid = '7cb465b94f2e426f9af5a12c55326cbe';"
uv run python -m sqlite3 mlflow.db "SELECT key, value, step FROM metrics WHERE run_uuid = '7cb465b94f2e426f9af5a12c55326cbe';"
uv run python -m sqlite3 mlflow.db "SELECT key, value FROM tags WHERE run_uuid = '7cb465b94f2e426f9af5a12c55326cbe';"
Output
('n_estimators', '300')
('max_depth', 'None')
('test_size', '0.2')
('random_state', '42')
('mae', 0.3267739603762923, 0)
('rmse', 0.503821166676639, 0)
('r2', 0.8062927130336361, 0)
('mlflow.user', 'you')
('mlflow.source.name', '/Users/you/source/dewiride-house-price-prediction/.venv/bin/train')
('mlflow.source.type', 'LOCAL')
('mlflow.runName', '300-trees')
('model_type', 'random_forest')
('dataset', 'california_housing')

Use your own run ID from Step 14. Three things to notice:

  • Params are stored as text: '300', even 'None'. Metrics are real numbers with a step (0 here; Part 2 logs metrics at many steps).
  • MLflow added its own tags, which start with mlflow.: who ran it, the command and the run name.
  • Reading is safe; writing isn't. Never edit mlflow.db by hand. Change runs through MLflow.

How to See Where MLflow Stores Runs and Artifacts goes table by table.

Step 16: Save Your Work with Git​

Back in the project terminal:

Terminal 2
git status --short --untracked-files=all
Output
?? .env.example
?? .gitignore
?? .python-version
?? README.md
?? pyproject.toml
?? src/house_price_prediction/__init__.py
?? src/house_price_prediction/config.py
?? src/house_price_prediction/data.py
?? src/house_price_prediction/plots.py
?? src/house_price_prediction/train.py
?? uv.lock

.env and .venv are missing from the list, as they should be: .gitignore keeps them out. Commit the rest:

Terminal 2
git add .
git commit -m "Track house price training runs with MLflow"
Output
[master (root-commit) 0369d78] Track house price training runs with MLflow
11 files changed, 1887 insertions(+)
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 .python-version
create mode 100644 README.md
create mode 100644 pyproject.toml
create mode 100644 src/house_price_prediction/__init__.py
create mode 100644 src/house_price_prediction/config.py
create mode 100644 src/house_price_prediction/data.py
create mode 100644 src/house_price_prediction/plots.py
create mode 100644 src/house_price_prediction/train.py
create mode 100644 uv.lock

New to Git? How to Create a Git Repository and Make Your First Commit and How to See What Changed in a Git Repository Before You Commit explain these commands. The tracking server's folder isn't a Git repository at all, and if MLflow ever writes its files inside a project, How to Keep MLflow Files Out of Git with .gitignore has the lines to add.

Your Folders Now Look Like This​

source/
source/
├── mlflow-server/
│ ├── .venv/
│ ├── mlartifacts/
│ ├── mlflow.db
│ ├── pyproject.toml
│ └── uv.lock
└── dewiride-house-price-prediction/
├── .venv/
├── src/
│ └── house_price_prediction/
│ ├── __init__.py
│ ├── config.py
│ ├── data.py
│ ├── plots.py
│ └── train.py
├── .env
├── .env.example
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── uv.lock

Do It with the CLI​

The MLflow UI was the main route in this part, but the same records are a command away. The mlflow command reads the server address from the MLFLOW_TRACKING_URI environment variable:

Terminal 2
export MLFLOW_TRACKING_URI=http://127.0.0.1:8080
uv run mlflow experiments search
uv run mlflow runs list --experiment-id 1
Output
Experiment Id Name Artifact Location
--------------- ---------------------- -------------------
0 Default mlflow-artifacts:/0
1 house-price-prediction mlflow-artifacts:/1
Date Name ID
----------------------- ------------------ --------------------------------
2026-11-13 17:21:19 IST 300-trees 7cb465b94f2e426f9af5a12c55326cbe
2026-11-13 17:21:11 IST 200-trees-depth-16 09c7c9012ca74eafaf16c8766226d6b7
2026-11-13 17:21:07 IST 50-trees-depth-8 ec7ccb942d66407490b3aee0cb4d9b62
2026-11-13 17:21:01 IST smiling-moose-157 8dbe08fc91434c5b9b22456dcd740ebf

The dates show your own time zone.

Stop the Server When You're Done​

Go to the first terminal and press Ctrl+C. The server stops all four workers and exits. You may see a KeyboardInterrupt traceback that starts with Exception ignored on threading shutdown; the server still stopped cleanly, and Fixed: "Exception ignored on threading shutdown" KeyboardInterrupt When You Stop the MLflow Server explains it.

Your runs are safe in mlflow.db and mlartifacts/. Start the server again any time from the mlflow-server folder with uv run mlflow server --port 8080, and everything is still there.

Keep both folders for Part 2. If you ever want to remove everything this part created, stop the server first, then delete:

  • the mlflow-server folder (the server, its database and its files);
  • the dewiride-house-price-prediction folder (the project);
  • the scikit_learn_data folder in your home folder (the cached dataset).

Common Mistakes​

MistakeWhat happensDo this instead
Opening http://localhost:5000 on a Mac"Access to localhost was denied" (HTTP ERROR 403) from the AirPlay ReceiverRun the server on port 8080 and open http://127.0.0.1:8080
Running uv run train while the server is stoppedMLflow keeps retrying for about four minutes, then fails with MlflowException: API request to … failed … Max retries exceededStart the server first, and wait for Application startup complete: Fixed: "MlflowException: API request to … failed with exception … Max retries exceeded" Connection Refused in MLflow
Forgetting mlflow.set_tracking_uri()Runs go to a new mlflow.db in your project folder and never show in the UISet the tracking URI, as config.py and train.py do here
Running the file with your system's python3 instead of uv run trainModuleNotFoundError: No module named 'mlflow'Use uv run, which uses the project's own environment
Logging a param twice with different valuesMLflow refuses: params can't change once loggedStart a new run for new settings
Committing .envYour computer's settings land in GitKeep .env in .gitignore and commit .env.example

Troubleshooting​

SymptomLikely causeFix
[Errno 48] Address already in use when starting the serverAnother program, or another MLflow server, already uses port 8080Stop it, or pick another port: Fixed: "[Errno 48] Address already in use" When You Start the MLflow Server
The UI shows only the Default experiment after a runThe run went to a different tracking URICheck MLFLOW_TRACKING_URI in .env: How to Set the MLflow Tracking URI in Python
The UI looks different from the screenshots, with Traces and Evaluation menusIt's in GenAI modeClick Model training at the top of the sidebar
A StarletteDeprecationWarning at every startMLflow 3.18.0 uses a Starlette module that Starlette marked as deprecatedNothing to fix in your project: see the Fixed post linked in Step 3

FAQ​

Do I need a tracking server, or can MLflow just write to a file?​

MLflow can write straight to a mlflow.db file without a server, and that's what happens when you set no tracking URI at all. A server is better even on one computer: every project sends its runs to one place, the UI shows all of them, and your project folders stay free of MLflow's files.

Is mlflow ui the same as mlflow server?​

In MLflow 3.18.0, yes: mlflow ui still works and takes exactly the same options. This series uses mlflow server, the name MLflow's documentation uses.

Why 127.0.0.1 and not localhost?​

The server listens on 127.0.0.1, the IPv4 address of your own computer. localhost can mean the IPv6 address ::1 first, and on a Mac with AirPlay Receiver on, localhost:5000 reaches AirPlay instead of MLflow. Using 127.0.0.1 everywhere avoids any doubt.

Can other people on my network see my runs?​

No. The server listens on 127.0.0.1 and only accepts connections from this computer (localhost-only). Part 8 covers the server's security settings.

Where's the trained model?​

This part records the training runs, not the model itself. Part 4 saves the model in MLflow, ready to load and serve.

Does this work on Windows?​

Yes. Every command is the same, except where a step shows a PowerShell tab.

Conclusion​

You have your own MLflow tracking server running on http://127.0.0.1:8080, storing its records in mlflow.db and its files in mlartifacts/. Your training code connects to it with set_tracking_uri(), groups its runs in an experiment with set_experiment(), and records each run's settings, scores, labels and chart with log_params(), log_metrics(), set_tags() and log_figure(). Four runs later, the UI showed you which forest is worth keeping, with evidence instead of memory.

In Part 2 you'll log metrics that change during training, attach whole files and the exact dataset each run used, and search runs from Python.

Additional Resources​


Next → Part 2: Log Metrics Over Time, Artifacts and Datasets (coming soon)