How to Use MLflow 3: Tracking, Model Registry and GenAI Tracing (2026)

Oct 20, 2026 · 12 min · Ajay Kumar

This guide is for engineers who need a shared MLflow server their team can trust, not a laptop demo. By the end you will have MLflow 3.17.1 running against PostgreSQL with model artifacts in S3, a training run that produces a LoggedModel, a registered model promoted with an alias, a traced and evaluated GenAI function, and a REST endpoint serving whichever version currently holds the champion alias. I ran every Python snippet below against MLflow 3.17.1 (released 13 October 2026), scikit-learn 1.9.1 and PostgreSQL 16 on macOS, and the outputs shown are the real ones. You need Python 3.10 or newer (MLflow's current minimum on PyPI), a PostgreSQL database, and an S3 bucket plus credentials for the server. If you are still deciding where MLflow fits among MLOps, LLMOps and AgentOps, read the companion explainer MLOps vs LLMOps vs AgentOps in 2026 first.

What changed in MLflow 3 (read this before copying old tutorials)

Most MLflow tutorials on the first page of Google were written for 2.x, and three changes break them.

  1. LoggedModel is a first-class entity. Since MLflow 3 (June 2025), a logged model has its own ID (m-...), its own metrics and a URI of the form models:/<model_id>. It no longer has to live inside a run's artifact folder.
  2. artifact_path is deprecated in log_model; use name. The MLflow 3 migration guide covers it, and 3.17.1 logs "artifact_path is deprecated. Please use name instead." if you use the old argument.
  3. Stages are deprecated; use aliases. transition_model_version_stage has carried @deprecated(since="2.9.0") for a long time (see client.py). The model registry docs describe aliases such as champion, loaded as models:/name@champion.

Two newer changes matter for self-hosting: since 3.5.0 the server has host-header security middleware, and 3.17.0 requires a coordinated database upgrade with all writers stopped.

Classical ML path (Steps 3 to 5, 8) Training runlog_params, log_metric LoggedModelmodels:/m-<id> Model versiondiabetes-rf v1, v2 Alias championmovable pointer Serve or loadmodels:/name@champion GenAI path (Steps 6 and 7) Traced app@mlflow.trace Tracesspans per call genai.evaluatedataset + scorers Gate passes?then move the alias yes: set alias no: alias stays put Tracking server (Step 2): PostgreSQL holds runs, registry, prompts and traces S3 holds model artifacts, proxied through the server so clients need no bucket credentials
The workflow built in this guide. Every name and URI in the boxes is one used in the steps below, run against MLflow 3.17.1.

Step 1: Install MLflow 3.17.1 in a virtual environment

Pin the version. MLflow ships a minor release roughly monthly (3.16.0 on 4 September, 3.17.0 on 7 October 2026, per the releases page), and client and server should match.

python3.12 -m venv .venv && source .venv/bin/activate
pip install "mlflow==3.17.1" "scikit-learn==1.9.1"
mlflow --version
# mlflow, version 3.17.1

# Optional: silence the hint MLflow 3.17 prints about its bundled agent skill
export MLFLOW_DISABLE_AGENT_HINT=1

For a quick local try, mlflow server with no arguments now defaults to a SQLite database (sqlite:///mlflow.db) and binds to 127.0.0.1:5000. That is fine for one person. For a team, keep going.

Step 2: Run a tracking server on PostgreSQL and S3

Create the database and a dedicated user:

-- psql as an admin user
CREATE USER mlflow WITH PASSWORD 'change-me';
CREATE DATABASE mlflow OWNER mlflow;

Install the drivers the server needs. This is the first trap: MLflow 3.17.1 pulls in SQLAlchemy 2.1, and SQLAlchemy 2.1 changed the default PostgreSQL driver from psycopg2 to psycopg 3. A plain postgresql:// URL with only psycopg2-binary installed fails with ModuleNotFoundError: No module named 'psycopg'. I hit exactly that.

pip install "psycopg[binary]==3.3.6" "boto3==1.43.115"

# Create or migrate the schema. Run this before first start AND after every
# MLflow upgrade, with all servers stopped (3.17.0 does not support mixed versions).
mlflow db upgrade "postgresql://mlflow:change-me@db.internal:5432/mlflow"
# INFO mlflow.store.db.utils: Creating initial MLflow database tables...
# INFO mlflow.store.db.utils: Updating database tables

Now start the server. --artifacts-destination with the default --serve-artifacts makes the server proxy artifact uploads and downloads, so only the server needs S3 credentials. Clients see artifact URIs like mlflow-artifacts:/....

# Server credentials: prefer an instance role / IRSA / workload identity over keys
export AWS_DEFAULT_REGION=eu-west-1
# For MinIO, Ceph or another S3-compatible store, also set:
# export MLFLOW_S3_ENDPOINT_URL=https://s3.internal.example

mlflow server \
  --backend-store-uri "postgresql://mlflow:change-me@db.internal:5432/mlflow" \
  --artifacts-destination "s3://acme-mlflow-artifacts" \
  --host 0.0.0.0 \
  --port 5000 \
  --workers 4 \
  --allowed-hosts "mlflow.internal.example,mlflow.internal.example:5000" \
  --cors-allowed-origins "https://mlflow.internal.example"

--allowed-hosts is not optional once people reach the server by DNS name. By default the middleware accepts localhost and private IP addresses only; anything else gets a 403. I confirmed this with a fake Host header against a local 3.17.1 server:

curl -s -H "Host: mlflow.internal.example" \
  "http://127.0.0.1:5000/api/2.0/mlflow/experiments/search?max_results=1"
# Invalid Host header - possible DNS rebinding attack detected
curl -s http://127.0.0.1:5000/health
# OK

To run it as a container, extend the official image (the v3.17.1 tag exists on ghcr.io/mlflow/mlflow) with the drivers:

# Dockerfile
FROM ghcr.io/mlflow/mlflow:v3.17.1
RUN pip install --no-cache-dir "psycopg[binary]==3.3.6" "boto3==1.43.115"
EXPOSE 5000
ENTRYPOINT ["mlflow", "server", "--host", "0.0.0.0", "--port", "5000"]
docker build -t acme/mlflow-server:3.17.1 .
docker run -d --name mlflow -p 5000:5000 \
  -e MLFLOW_SERVER_ALLOWED_HOSTS="mlflow.internal.example" \
  acme/mlflow-server:3.17.1 \
  --backend-store-uri "postgresql://mlflow:change-me@db.internal:5432/mlflow" \
  --artifacts-destination "s3://acme-mlflow-artifacts"

The open-source server has no authentication by default. Put it behind your SSO proxy or enable the built-in --app-name basic-auth, and read MLflow's network exposure guidance before exposing it beyond a private network. On clients, point at the server with one variable:

export MLFLOW_TRACKING_URI=https://mlflow.internal.example

Step 3: Log a training run and a LoggedModel

This script trains a small regressor, logs parameters and a metric, logs the model with name= (not artifact_path=), and registers it in one call.

# train.py
import mlflow
from sklearn.datasets import load_diabetes
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split

mlflow.set_experiment("diabetes-rf")  # uses MLFLOW_TRACKING_URI

X, y = load_diabetes(return_X_y=True, as_frame=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)

with mlflow.start_run(run_name="rf-depth-6") as run:
    params = {"n_estimators": 200, "max_depth": 6, "random_state": 42}
    mlflow.log_params(params)
    model = RandomForestRegressor(**params).fit(X_train, y_train)

    model_info = mlflow.sklearn.log_model(
        sk_model=model,
        name="rf-model",                      # MLflow 3: replaces artifact_path
        input_example=X_train.iloc[:5],       # infers the signature
        registered_model_name="diabetes-rf",  # creates version N
        # sklearn models are saved with skops by default; tree models need this
        skops_trusted_types=["sklearn.tree._tree.Tree"],
    )
    rmse = mean_squared_error(y_test, model.predict(X_test)) ** 0.5
    # Attach the metric to the LoggedModel as well as the run
    mlflow.log_metric("rmse", rmse, model_id=model_info.model_id)

    print("run_id", run.info.run_id)
    print("model_id", model_info.model_id)
    print("model_uri", model_info.model_uri)
    print("registered version", model_info.registered_model_version)
    print("rmse", round(rmse, 2))

Real output against the PostgreSQL-backed server:

Successfully registered model 'diabetes-rf'.
Created version '1' of model 'diabetes-rf'.
run_id 4ba1baf05d0c45909850aacd36ec46c0
model_id m-9c95c600841a4fd1b5363f988addb140
model_uri models:/m-9c95c600841a4fd1b5363f988addb140
registered version 1
rmse 53.81

About skops_trusted_types: in 3.17.1 the sklearn flavor saves with skops unless you ask for pickle, and skops refuses to round-trip sklearn.tree._tree.Tree without explicit trust. My first run failed with UntrustedTypesFoundException until I added that line. Only list types you have reviewed; the error message explains why that type is risky in a file from someone else.

Step 4: Register the model and promote it with an alias

Registration already happened above. Promotion is moving an alias. Your serving code only ever references the alias, so promotion and rollback are both a single API call.

# promote.py
from mlflow import MlflowClient

client = MlflowClient()
client.set_registered_model_alias("diabetes-rf", "champion", version="1")
client.set_model_version_tag("diabetes-rf", "1", "validation_status", "approved")

mv = client.get_model_version_by_alias("diabetes-rf", "champion")
print("champion ->", mv.version)
# champion -> 1

# Rollback later is the same call with the previous version:
# client.set_registered_model_alias("diabetes-rf", "champion", version="1")

You can check it over REST, which is handy from CI or a deploy script:

curl -s "$MLFLOW_TRACKING_URI/api/2.0/mlflow/registered-models/alias?name=diabetes-rf&alias=champion"
# {"model_version": {"name": "diabetes-rf", "version": "1",
#   "source": "models:/m-9c95c600841a4fd1b5363f988addb140",
#   "status": "READY", "aliases": ["champion"], ...}}

Note that source points at the LoggedModel URI, not a run path. That is the MLflow 3 lineage: version, then logged model, then run.

Step 5: Search and compare LoggedModels

Because metrics live on the LoggedModel, you can query models directly instead of digging through runs:

import mlflow

lm = mlflow.get_logged_model("m-9c95c600841a4fd1b5363f988addb140")
print(lm.name, lm.status, [(m.key, round(m.value, 2)) for m in lm.metrics])
# rf-model READY [('rmse', 53.81)]

exp = mlflow.get_experiment_by_name("diabetes-rf")
best = mlflow.search_logged_models(
    experiment_ids=[exp.experiment_id],
    filter_string="metrics.rmse < 70",
    order_by=[{"field_name": "metrics.rmse", "ascending": True}],
    output_format="list",
)
print(best[0].model_id)
# m-9c95c600841a4fd1b5363f988addb140

# Load whatever currently holds the alias
model = mlflow.pyfunc.load_model("models:/diabetes-rf@champion")

Step 6: Trace a GenAI app with mlflow.trace

MLflow's tracing records each call as a tree of spans. Decorate your own functions, and use autologging for supported clients (for example mlflow.openai.autolog(); the integrations list covers the rest). This example needs no API key:

# genai_app.py
import mlflow
from mlflow.entities import SpanType

mlflow.set_experiment("support-bot")

@mlflow.trace(span_type=SpanType.TOOL)
def lookup_order(order_id: str) -> dict:
    return {"order_id": order_id, "status": "shipped"}

@mlflow.trace(span_type=SpanType.AGENT)
def answer(question: str) -> str:
    # Stand-in for an LLM call. With a real client, call mlflow.openai.autolog() once.
    order = lookup_order("A-1001")
    return f"Your order {order['order_id']} has {order['status']}."

answer("Where is my order?")

mlflow.flush_trace_async_logging()   # traces export asynchronously
trace = mlflow.get_trace(mlflow.get_last_active_trace_id())
print(trace.info.trace_id, trace.info.state, [s.name for s in trace.data.spans])
# tr-83f5959bcbb05e0cc2f5b69c05d4a2ce OK ['answer', 'lookup_order']

Without the flush, get_trace returned None in my first attempt because the export queue had not drained. In a long-running service you do not need it; in scripts and tests you do.

Step 7: Evaluate with mlflow.genai.evaluate and scorers

Evaluation runs your function over a dataset, traces every call and applies scorers. Custom scorers are plain Python functions decorated with @scorer; built-in judges such as Correctness, Guidelines and RetrievalGroundedness call an LLM (the default judge in 3.17.1 is openai:/gpt-4.1-mini, overridable with model=).

# evaluate.py (append to genai_app.py)
from mlflow.genai.scorers import scorer

@scorer
def mentions_status(outputs: str) -> bool:
    return "shipped" in outputs

@scorer
def exact(outputs: str, expectations: dict) -> bool:
    return outputs == expectations["expected_response"]

data = [
    {"inputs": {"question": "Where is my order?"},
     "expectations": {"expected_response": "Your order A-1001 has shipped."}},
    {"inputs": {"question": "Has it shipped?"},
     "expectations": {"expected_response": "Yes."}},
]

results = mlflow.genai.evaluate(
    data=data,
    predict_fn=answer,          # called with each row's inputs as kwargs
    scorers=[mentions_status, exact],
)
print(results.metrics)
# {'mentions_status/mean': 1.0, 'exact/mean': 0.5}

# Gate: fail CI if quality drops below the bar
assert results.metrics["mentions_status/mean"] >= 0.95, "eval gate failed"

Run that assert in CI and only call set_registered_model_alias (or mlflow.genai.set_prompt_alias for prompts) when it passes. The evals in CI with GitHub Actions guide shows the workflow file, and MLOps in 2026: Evals Are the New Unit Tests covers how far to trust judge scores.

Step 8: Serve the champion model

mlflow models serve resolves the alias at startup and serves a /invocations endpoint. Use --env-manager local when the current environment already has the model's dependencies; the default is virtualenv, which builds a fresh environment and needs pyenv.

mlflow models serve -m "models:/diabetes-rf@champion" \
  --env-manager local -h 127.0.0.1 -p 5001

curl -s -X POST http://127.0.0.1:5001/invocations \
  -H 'Content-Type: application/json' \
  -d '{"dataframe_split":{"columns":["age","sex","bmi","bp","s1","s2","s3","s4","s5","s6"],
       "data":[[0.038,0.05,0.062,0.022,-0.044,-0.035,-0.043,-0.003,0.02,-0.018]]}}'
# {"predictions": [218.32266689161702]}

For production, build an image and run it on your platform. The alias is resolved at build or start time, so redeploy (or restart) after moving it.

mlflow models build-docker -m "models:/diabetes-rf@champion" -n acme/diabetes-rf:v1

On Kubernetes, KServe can serve MLflow models natively; see deploying a model with KServe. The full option list is in MLflow's model serving docs.

Troubleshooting

Symptom Likely cause Fix
ModuleNotFoundError: No module named 'psycopg' on mlflow db upgrade or server start SQLAlchemy 2.1 defaults postgresql:// to psycopg 3 pip install "psycopg[binary]", or use postgresql+psycopg2:// with psycopg2 installed
HTTP 403 Invalid Host header - possible DNS rebinding attack detected DNS name not in the allowed-hosts list (default is localhost and private IPs) Add --allowed-hosts or MLFLOW_SERVER_ALLOWED_HOSTS with every hostname and host:port clients use
UntrustedTypesFoundException from mlflow.sklearn.log_model sklearn flavor saves with skops by default and tree models need explicit trust Pass skops_trusted_types=["sklearn.tree._tree.Tree"] for types you reviewed
Client gets NoCredentialsError uploading artifacts Server started with --no-serve-artifacts, or experiment created before proxying was enabled Use --artifacts-destination with proxying; create a new experiment (existing ones keep their artifact location)
Server errors about missing tables or columns after upgrade Schema not migrated Stop all servers, back up, run mlflow db upgrade <uri>, restart on one version
mlflow.get_trace() returns None right after a call Traces export asynchronously Call mlflow.flush_trace_async_logging() in scripts and tests
Warning that stages are deprecated, or transition_model_version_stage in old code Stages deprecated since 2.9.0 Replace with set_registered_model_alias and models:/name@alias
Warning: "artifact_path is deprecated" MLflow 2.x style log_model call Use name=
Correctness or Guidelines scorer fails with an auth error Default judge is openai:/gpt-4.1-mini and no key is set Set OPENAI_API_KEY or pass model= pointing at your provider
mlflow models serve hangs building an environment Default --env-manager virtualenv needs pyenv and downloads Python Use --env-manager local or uv, or build an image with build-docker

FAQ

Are MLflow model stages removed in MLflow 3?

They are deprecated, not removed: the stage APIs have been marked deprecated since 2.9.0 and still exist in 3.17.1. Use aliases instead. An alias like champion is a movable pointer to a version, and code loads models:/name@champion.

What is a LoggedModel in MLflow 3?

It is a model entity with its own ID, metrics, parameters and artifacts, introduced in MLflow 3 so models are not tied to a single training run. log_model returns its model_id, and the URI models:/<model_id> loads it. Registered model versions point at a LoggedModel as their source.

Can I use SQLite or a file store for a team MLflow server?

SQLite is now the default for local use and fine for one person. For a shared server with several workers, use PostgreSQL or MySQL and a real object store, since many concurrent writers and large trace tables are exactly what a single file handles badly.

Does MLflow 3 replace a separate LLM observability tool?

For many teams, yes: it covers tracing, evaluation with scorers, a prompt registry and feedback in the same server as the model registry. If you already standardise on OpenTelemetry, MLflow can export spans using the GenAI semantic conventions, so you are not locked into one backend.

When I set up MLflow for a team, the first things I check are boring ones: that mlflow db upgrade is a step in the deploy pipeline with writers stopped, that only the server holds bucket credentials, that --allowed-hosts lists every name in use, and that nothing in the codebase still calls a stage API. After that, the habit that pays off is making the alias the only thing deploy scripts reference, so rollback never needs a code change. Official docs: tracking server, model registry, MLflow 3 migration guide, tracing, evaluation and prompt registry.

Related: MLOps in 2026: Evals Are the New Unit Tests, Agent Observability in 2026 and MLOps vs LLMOps vs AgentOps in 2026.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related