4.4 Deploying a Custom Model to a Model Endpoint

Key Takeaways

  • A custom model is any model wrapped in mlflow.pyfunc.PythonModel, which exposes a predict(context, model_input) method Databricks can serve like any built-in flavour.

  • load_context loads artifacts once per replica at startup; predict runs per request, so expensive setup belongs in load_context.

  • Log a custom model with mlflow.pyfunc.log_model(python_model=..., artifacts=..., pip_requirements=..., signature=...), then register it in Unity Catalog.

  • Deploy by creating a serving endpoint whose served entity names the registered model and version or alias, with a workload size and scale_to_zero_enabled.

  • The logged signature and pinned dependencies are what make the endpoint reproducible; a missing dependency is the most common cause of a failed endpoint build.

Last updated: August 2026

4.4 Deploying a Custom Model to a Model Endpoint

Not every deployable model is a plain scikit-learn estimator. A production model frequently needs pre-processing, post-processing, business rules, an ensemble of several estimators, or a lookup table bundled alongside the weights. MLflow's pyfunc custom model is the mechanism that packages all of that behind one uniform predict interface, and Databricks Model Serving deploys it exactly like any built-in flavour.

Step 1 — Wrap the Logic in mlflow.pyfunc.PythonModel

import mlflow
import pandas as pd

class RiskScorer(mlflow.pyfunc.PythonModel):

    def load_context(self, context):
        """Runs ONCE per serving replica when the container starts."""
        import joblib, json
        self.model = joblib.load(context.artifacts["classifier"])
        with open(context.artifacts["thresholds"]) as f:
            self.thresholds = json.load(f)

    def predict(self, context, model_input: pd.DataFrame) -> pd.DataFrame:
        """Runs on EVERY request."""
        # Pre-processing that must match training exactly
        features = model_input.assign(
            spend_ratio=model_input["monthly_spend"] / model_input["credit_limit"]
        )[self.model.feature_names_in_]

        proba = self.model.predict_proba(features)[:, 1]

        # Post-processing: business banding the raw estimator cannot express
        band = pd.cut(proba,
                      bins=[0, self.thresholds["low"], self.thresholds["high"], 1],
                      labels=["low", "medium", "high"])

        return pd.DataFrame({"probability": proba, "risk_band": band.astype(str)})

The split between the two methods is the part exam questions probe:

MethodRunsPut here
load_context(context)Once per replica at container startDeserialising weights, reading config files, opening a tokenizer
predict(context, model_input)On every requestOnly per-request work

Loading the model inside predict would repeat expensive deserialisation on every call and destroy the endpoint's latency profile.

Step 2 — Log It with Artifacts, Dependencies, and a Signature

from mlflow.models import infer_signature

signature = infer_signature(sample_input_df, sample_output_df)

with mlflow.start_run(run_name="risk_scorer_custom"):
    mlflow.pyfunc.log_model(
        artifact_path="model",
        python_model=RiskScorer(),
        artifacts={
            "classifier": "/dbfs/tmp/risk_clf.joblib",
            "thresholds": "/dbfs/tmp/thresholds.json",
        },
        pip_requirements=["scikit-learn==1.4.2", "pandas==2.2.1", "joblib==1.4.0"],
        signature=signature,
        input_example=sample_input_df.head(3),
        registered_model_name="prod_ml.risk.risk_scorer",
    )
  • artifacts is a dictionary of files copied into the model package. Their paths are resolved through context.artifacts at load time — never hard-code a DBFS path inside predict, because the serving container may not have that mount.
  • pip_requirements pins the environment the endpoint rebuilds. Omitting a transitive dependency is the single most common cause of an endpoint that fails to start.
  • signature declares the input and output schema. Unity Catalog registration requires it, and the endpoint validates incoming requests against it, so a malformed payload returns a clear error instead of a silent misprediction.

Step 3 — Create the Serving Endpoint

from databricks.sdk import WorkspaceClient
from databricks.sdk.service.serving import EndpointCoreConfigInput, ServedEntityInput

w = WorkspaceClient()

w.serving_endpoints.create_and_wait(
    name="risk-scorer-endpoint",
    config=EndpointCoreConfigInput(
        served_entities=[
            ServedEntityInput(
                name="risk-scorer-v1",
                entity_name="prod_ml.risk.risk_scorer",   # 3-level UC name
                entity_version="1",
                workload_size="Small",
                scale_to_zero_enabled=True,
            )
        ]
    ),
)

The same endpoint can be created from the Serving UI or the REST API. Whichever route is used, three things must be true: the model is registered in Unity Catalog, the serving identity can read and execute it, and the logged environment resolves.

Sizing and Access Control

Two parts of that configuration decide whether the endpoint survives production:

SettingValuesWhat it controls
workload_sizeSmall (0–4), Medium (8–16), Large (16–64)Provisioned concurrency — how many simultaneous requests the endpoint is sized for
workload_typeCPU, CPU_MEDIUM, CPU_LARGE, GPU_SMALL, GPU_MEDIUM, MULTIGPU_MEDIUM, GPU_MEDIUM_8Whether each replica runs on CPU or a GPU instance
scale_to_zero_enabledTrue / FalseWhether idle replicas shut down. Databricks does not recommend it for production endpoints, because capacity is not guaranteed when scaling back up

Permissions are validated at create and update time, and the requirement is three grants rather than one: USE CATALOG on the catalog, USE SCHEMA on the schema, and EXECUTE on the model. A custom pyfunc that reads a second governed table at request time needs its own grants on that table too.

Shipping Helper Code with the Model

RiskScorer above is self-contained, but a real custom model usually imports project modules. Those modules do not exist inside the serving container unless you send them:

  • code_paths=["./src/features"] copies the listed files or directories into the model package and puts them on sys.path at load time.
  • mlflow.models.set_model(RiskScorer()) is the models-from-code alternative: point python_model at a .py file ending in that call and MLflow records the source instead of pickling the object, which sidesteps the class-not-found errors a pickled custom model raises once its defining notebook is gone.

Changing an existing endpoint uses the update-config path rather than create. The new served entity is built and brought up before traffic moves, so a failed build leaves the running version serving.

Step 4 — Query It

import requests

response = requests.post(
    f"{host}/serving-endpoints/risk-scorer-endpoint/invocations",
    headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
    json={"dataframe_records": [
        {"monthly_spend": 2400.0, "credit_limit": 8000.0, "tenure_months": 36}
    ]},
)
print(response.json()["predictions"])

Because the custom model returns a DataFrame with probability and risk_band, the endpoint's response carries both fields — the post-processing lives inside the deployed artifact rather than in every calling application.

Failure Modes Specific to Custom Models

SymptomCauseFix
Endpoint stays in CREATING then failsA dependency in the logged environment cannot be resolvedPin pip_requirements explicitly; verify the model loads in a clean cluster first
FileNotFoundError at container startpredict or load_context hard-codes a DBFS path instead of using context.artifactsPass every file through the artifacts dictionary
Requests rejected with a schema errorPayload does not match the logged signatureCorrect the payload, or relog with a signature inferred from a representative example
First request after idle is slow, later ones are fastScale-to-zero cold startExpected behaviour; disable scale-to-zero if the latency SLO forbids it
Latency far higher than in the notebookModel deserialisation placed in predict instead of load_contextMove the loading into load_context
Test Your Knowledge

A custom mlflow.pyfunc.PythonModel deserialises a 400 MB model file. Where should that deserialisation happen, and why?

A

Inside predict, so the freshest artifact is loaded for each request

B

Inside load_context, because it runs once per serving replica at container start, whereas predict runs on every request

C

In the module's top-level scope, so it happens at import time on the client

D

It should not be loaded at all; Databricks injects the artifact automatically

Test Your Knowledge

A custom pyfunc model is logged and registered, but the serving endpoint fails to start. The model loads correctly in the training notebook. What is the most likely cause?

A

Unity Catalog does not support custom pyfunc models

B

The logged environment omits a dependency that was present on the training cluster, so the serving container cannot rebuild the environment

C

Custom models must be deployed with mlflow.spark.log_model instead

D

Serving endpoints only accept models trained with AutoML

Test Your Knowledge

Why is a logged model signature required when registering a model in Unity Catalog and useful once the model is served?

A

It compresses the model artifact so the endpoint starts faster

B

It declares the input and output schema, which Unity Catalog requires for governance and which the endpoint uses to validate requests so a malformed payload returns a clear error instead of a silent misprediction

C

It stores the training data alongside the model for reproducibility

D

It determines the workload size the endpoint provisions

Sections you finish are checked off in the contents.