How to Use MLflow 3: Tracking, Model Registry and GenAI Tracing (2026)
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.
LoggedModelis 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 formmodels:/<model_id>. It no longer has to live inside a run's artifact folder.artifact_pathis deprecated inlog_model; usename. The MLflow 3 migration guide covers it, and 3.17.1 logs "artifact_pathis deprecated. Please usenameinstead." if you use the old argument.- Stages are deprecated; use aliases.
transition_model_version_stagehas carried@deprecated(since="2.9.0")for a long time (seeclient.py). The model registry docs describe aliases such aschampion, loaded asmodels:/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.
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
How to Run LLM Evals in CI with GitHub Actions (promptfoo, 2026)
Step-by-step: gate prompt and model changes with LLM evals in GitHub Actions using promptfoo 0.124 or DeepEval 4.3, with deterministic and LLM-as-judge assertions, pass-rate thresholds, caching, cost limits, PR comments and fixes for flaky evals.
14 minOct 20, 2026MLOps vs LLMOps vs AgentOps in 2026: What Carries Over, What's New
MLOps vs LLMOps vs AgentOps explained for 2026: how the lifecycle shifts from trained models to prompts to tool-using agents, what carries over (versioning, registries, CI gates, monitoring), what is genuinely new (evals, traces, tool permissions), and a current tool map.
13 minOct 20, 2026How to Write a Blameless Postmortem: Template and Examples (2026)
A step-by-step guide to writing a blameless postmortem: when one is required, building the timeline from evidence, contributing factors instead of one root cause, why the 5 Whys misleads, owned action items, the review meeting, and a copy-paste markdown template.
15 min