MyCertStack logoMyCertStack

    4: Model Deployment

    Move a data science model into production.

    A trained model that lives only in a notebook is a science project, not a product. Production means the model is reachable from the application layer with predictable latency, returns versioned outputs that can be audited, and degrades gracefully when inputs change. Snowflake offers five deployment surfaces that span this requirement space: external functions that call a remotely hosted model, pre-built Cortex ML and Cortex AI functions that wrap Snowflake-managed models, Scalar Python UDFs for row-by-row invocation, Vectorized Python UDFs for batch-friendly pandas-based inference, and Model Registry inference (warehouse or Snowpark Container Services) for models logged as first-class schema objects.

    Surface selection is not a style preference. It is a function of where the model weights live, what the dependency tree looks like, what latency the consumer needs, and whether the workload requires GPUs. Picking the wrong surface produces either runaway cost (a GPU pool spun up for a 5 KB logistic regression) or silent failure (a 4 GB transformer pushed through a warehouse UDF that hits the package size ceiling).

    Externally hosted models via external functions

    An external function is a Snowflake-side function whose body is implemented at a remote HTTPS endpoint reached through an API integration. The remote endpoint can be a cloud provider's managed inference service, a self-hosted REST API, or any system that accepts JSON over POST. The Snowflake side never sees the model weights. Each row of the input batch becomes a JSON element in the request body, and the response is mapped back into a result column.

    External functions earn their place when the model cannot be brought into Snowflake. Examples are vendor-licensed models with no redistribution rights, models that depend on hardware Snowflake does not expose, and models that must remain in a centrally governed ML platform for regulatory reasons. They are the wrong tool when latency matters: every invocation crosses the network boundary, an API gateway sits in the middle, and the round trip dominates the cost budget. They are also the wrong tool when the input volume is high, because each invocation incurs cloud egress and the remote endpoint charges per request.

    CREATE OR REPLACE API INTEGRATION CHURN_API_INTEG
     API_PROVIDER = aws_api_gateway
     API_AWS_ROLE_ARN = 'arn:aws:iam::123456789012:role/snowflake_inference'
     API_ALLOWED_PREFIXES = ('https://abc.execute-api.us-east-1.amazonaws.com/prod/')
     ENABLED = TRUE;
    
    CREATE OR REPLACE EXTERNAL FUNCTION ML_PROD_DB.SCORING.CHURN_REMOTE(
     TENURE NUMBER, MONTHLY_CHARGES NUMBER, CONTRACT_TYPE STRING
     )
     RETURNS FLOAT
     API_INTEGRATION = CHURN_API_INTEG
     AS 'https://abc.execute-api.us-east-1.amazonaws.com/prod/score';
    

    💡 Exam Trap: External functions do not bypass Snowflake's normal access model. A consumer still needs USAGE on the function and USAGE on the API integration. Granting only the function privilege without the integration produces a 0x00... error message that points at the integration, not the function.

    Pre-built models

    Pre-built models are Snowflake-managed models invoked via SQL functions. Two families exist. Cortex ML Functions (FORECAST, CLASSIFICATION, ANOMALY_DETECTION, TOP_INSIGHTS) wrap classical ML models trained on the customer's own data when the function is called or via a model object that persists training results. Cortex AI Functions (COMPLETE, SUMMARIZE, SENTIMENT, TRANSLATE, EXTRACT_ANSWER, CLASSIFY_TEXT, EMBED_TEXT_768, EMBED_TEXT_1024) wrap Snowflake-hosted LLMs and embedding models defined and covered in Chapter 3.

    The deployment story for pre-built models is "no deployment". The function call is the deployment. There is no log step, no stage, no version. The trade-off is loss of control: the model architecture is fixed, only documented hyperparameters can be tuned, and explainability is limited to what the function returns. Models trained via Snowflake ML Functions do not appear in the Model Registry API, although Cortex Fine-Tuned LLMs are visible in the Registry's Snowsight UI as a read-only entry.

    💡 Exam Trap: A FORECAST model object created with CREATE SNOWFLAKE.ML.FORECAST is not the same object as a model registered via Registry.log_model. SHOW MODELS returns Model Registry objects; SHOW SNOWFLAKE.ML.FORECAST returns Cortex ML Function objects. Both are first-class objects, but they live under different commands and cannot be cross-managed.

    Scalar Python UDFs

    A Scalar Python UDF accepts one row at a time and returns one value. The UDF body loads the model from a stage on the first invocation, caches it in the warehouse process memory, and serves subsequent calls from cache. Scalar UDFs are simple to author and reason about. They earn their place for low-volume inference where the model is small, dependencies are few, and the developer wants direct control over the call signature.

    from snowflake.snowpark.functions import udf
    from snowflake.snowpark.types import FloatType, IntegerType, StringType
    import sys, joblib
    
    @udf(name="CHURN_SCALAR",
     is_permanent=True,
     stage_location="@ML_PROD_DB.SCORING.MODEL_STAGE",
     replace=True,
     imports=["@ML_PROD_DB.SCORING.MODEL_STAGE/churn_lr.joblib"],
     packages=["scikit-learn==1.3.0", "joblib"])
    def churn_scalar(tenure: int, monthly_charges: float, contract: str) -> float:
     import os
     IMPORT_DIR = sys._xoptions["snowflake_import_directory"]
     model = joblib.load(os.path.join(IMPORT_DIR, "churn_lr.joblib"))
     return float(model.predict_proba([[tenure, monthly_charges, contract]])[0, 1])
    

    The Scalar UDF pays a per-row Python call overhead. For 10 rows that overhead is invisible; for 100 million rows it dominates wall-clock time. The Vectorized UDF was designed to amortize this overhead.

    Vectorized Python UDFs

    A Vectorized Python UDF declares its input as a pandas DataFrame and receives batches of rows in each call. The UDF body runs once per batch, not once per row. Decorating a Python function with @pandas_udf or applying _sf_vectorized_input selects this mode. Batch size is chosen by Snowflake; the developer can request a maximum via the max_batch_size option.

    Vectorized UDFs are the right choice when the model itself benefits from vectorized math (any scikit-learn estimator with a real predict method, any XGBoost or LightGBM model, any model that accepts a 2D array). They are the wrong choice when the prediction logic must traverse stateful per-row Python branching that cannot be expressed as array operations.

    import pandas as pd
    from snowflake.snowpark.functions import pandas_udf
    from snowflake.snowpark.types import PandasDataFrameType, FloatType, IntegerType, StringType
    
    @pandas_udf(name="CHURN_VECTORIZED",
     is_permanent=True,
     stage_location="@ML_PROD_DB.SCORING.MODEL_STAGE",
     replace=True,
     imports=["@ML_PROD_DB.SCORING.MODEL_STAGE/churn_xgb.joblib"],
     packages=["scikit-learn==1.3.0", "xgboost==1.7.6", "joblib", "pandas"],
     input_types=[PandasDataFrameType([IntegerType(), FloatType(), StringType()])],
     return_type=FloatType(),
     max_batch_size=4000)
    def churn_vectorized(df: pd.DataFrame) -> pd.Series:
     import sys, os, joblib
     model = joblib.load(os.path.join(sys._xoptions["snowflake_import_directory"], "churn_xgb.joblib"))
     return pd.Series(model.predict_proba(df.values)[:, 1])
    

    💡 Exam Trap: A Vectorized UDF still runs inside the warehouse Python sandbox. It cannot install arbitrary pip packages at runtime, it cannot open outbound network connections without an external access integration, and it inherits the same package set published in the Snowflake Anaconda channel.

    Snowflake Model Registry inference

    The Model Registry is the recommended deployment surface for models authored in Python and intended to live inside Snowflake. A model is logged as a first-class schema-level object with Registry.log_model. Each invocation of log_model against the same model name with a different version_name produces a new version, which is immutable. The Registry stores the serialized model, conda or pip dependencies, a signature describing input and output columns, optional metrics, optional comment, and optional tags. The Registry handles stage layout under the covers; the developer does not write to a stage directly.

    from snowflake.ml.registry import Registry
    from snowflake.ml.model import model_signature
    
    reg = Registry(session=session, database_name="ML_PROD_DB", schema_name="SCORING")
    
    sig = model_signature.infer_signature(
     input_data=X_train_sample,
     output_data=y_train_sample,
    )
    
    mv = reg.log_model(
     model=xgb_clf,
     model_name="CHURN_PREDICTOR",
     version_name="V_2025_Q1",
     signatures={"predict_proba": sig},
     conda_dependencies=["scikit-learn==1.3.0", "xgboost==1.7.6"],
     metrics={"validation_auc": 0.864, "validation_f1": 0.712},
     comment="Quarterly retrain on Q1 customer base. PSI vs baseline = 0.04.",
     options={"enable_explainability": True},
    )
    

    Once logged, the model exposes its methods both from Python and from SQL. The Python path uses mv.run(input_df, function_name="predict_proba"). The SQL path uses the model_name!METHOD_NAME(...) syntax. Warehouse inference is the default and runs the model in the warehouse Python sandbox. Snowpark Container Services inference, covered below, is selected by calling mv.create_service(...) against a compute pool.

    SELECT CUSTOMER_ID,
     CHURN_PREDICTOR!PREDICT_PROBA(TENURE, MONTHLY_CHARGES, CONTRACT_TYPE) AS CHURN_SCORE
    FROM ML_PROD_DB.SCORING.CUSTOMER_FEATURES;
    

    Retrieving a model from the Registry is symmetric. reg.get_model("CHURN_PREDICTOR") returns a Model instance. The default attribute returns the version flagged as default. The version("V_2025_Q1") method returns a specific version. Aliases (covered in the lifecycle section) provide a stable handle that can be repointed at a new version without touching downstream SQL.

    💡 Exam Trap: A warehouse-deployed model can only use packages available in the Snowflake Anaconda channel. PyPI-only packages require an artifact repository mapping or a deployment to Snowpark Container Services. Logging a model with pip_requirements=[...] instead of conda_dependencies=[...] forces SPCS-only execution: the model cannot run in a warehouse and any SQL invocation against a warehouse-only deployment will fail.

    💡 Exam Trap: Models developed using snowflake.ml.modeling classes (the Snowpark ML modeling API) cannot be deployed directly to a GPU compute pool. The workaround is to extract the native estimator with to_xgboost(), to_sklearn(), or the equivalent and log that native object, which can then be served on GPU SPCS.

    Snowpark Container Services for model deployment

    Some models do not fit the warehouse Python sandbox: a Hugging Face transformer needing GPUs, a model with PyPI-only dependencies, an ensemble whose code base exceeds the warehouse package size. Snowpark Container Services (SPCS) lifts these constraints. The Model Registry's create_service() method packages the model into a container image, pushes it to the account's internal image repository, and starts an inference service on a compute pool of the requested instance family.

    mv.create_service(
     service_name="CHURN_PREDICTOR_SVC",
     service_compute_pool="ML_CPU_XS_POOL",
     image_build_compute_pool="ML_BUILD_POOL",
     ingress_enabled=True,
     max_instances=4,
     gpu_requests=None,
     num_workers=None,
    )
    

    A compute pool is an account-level construct: a set of VM nodes with a specified instance family. CPU families are appropriate for tree-based and linear models; GPU families (NV_S and larger) are appropriate for deep learning and large embedding models. The ingress_enabled=True flag exposes a public REST endpoint on Snowflake's gateway; downstream applications can call the model with JSON payloads using the dataframe_split protocol. Without ingress, the service is reachable only through the SQL service function generated by the Registry.

    Worker process count is computed automatically. For CPU models the server provisions twice the number of CPUs plus one worker; for GPU models a single worker is provisioned and the model is loaded once per GPU. Scaling is horizontal: increase max_instances to add more replicas behind the gateway. A SPCS-deployed model is invoked from Python with mv.run(df, function_name="predict_proba", service_name="CHURN_PREDICTOR_SVC") or from SQL through the service function.

    📊 Limit: Image building fails if it takes more than one hour. Table functions are not supported when deploying a model to SPCS via Model Serving.

    📊 Limit: Snowpark Container Services is available in commercial AWS, Microsoft Azure, and Google Cloud regions. Government regions are not supported.

    ⚠️ Anti-Pattern: Spinning up a GPU compute pool for a 200 KB logistic regression because the model is "in production". GPU pools are billed by node-hour while the pool is active. A logistic regression should be deployed as a warehouse Model Registry inference or a Vectorized UDF and warm in milliseconds, not minutes.

    Storing predictions

    Three storage patterns coexist in production deployments, and each answers a different question.

    The first pattern stores predictions inline alongside the input row in a single output table. The scoring query performs a SELECT ... model!METHOD(...) FROM ... and inserts the result. This is the simplest pattern and works well for batch scoring where every input is scored once and the prediction has no independent lifecycle.

    CREATE OR REPLACE TABLE ML_PROD_DB.SCORING.CHURN_SCORES_DAILY AS
    SELECT CUSTOMER_ID,
     CURRENT_TIMESTAMP() AS SCORED_AT,
     'V_2025_Q1' AS MODEL_VERSION,
     CHURN_PREDICTOR!PREDICT_PROBA(TENURE, MONTHLY_CHARGES, CONTRACT_TYPE):"output_feature_0"::FLOAT AS CHURN_SCORE
    FROM ML_PROD_DB.SCORING.CUSTOMER_FEATURES_DAILY;
    

    The second pattern stores predictions in a separate prediction table keyed by entity ID and timestamp. This separation lets predictions outlive the input row, supports retrospective scoring under multiple model versions, and is the layout that ML Observability expects (the SOURCE table for a model monitor includes features, predictions, optional actuals, and a timestamp column).

    The third pattern is stream-driven. A Snowflake Stream on the input table captures inserts; a Task consumes the stream every few minutes, scores the new rows, and writes results to the prediction table. This decouples the producer (an application writing customer activity) from the scorer (the model). The prediction lag is bounded by the Task interval.

    💡 Exam Trap: Storing predictions inline in the same table that the application writes to creates a circular update pattern and breaks Stream change tracking. Predictions belong in a separate table or in a Dynamic Table downstream of the source.

    Stage commands

    Although the Model Registry abstracts stage management for registered models, stages still appear in two contexts a data scientist will face. The first is loading the source model file in a Scalar or Vectorized UDF, where the file is uploaded with PUT file://... to an internal named stage and referenced via the UDF's imports parameter. The second is inspecting the Registry's own backing storage, which is accessible via the snow://model/<modelName>/versions/<versionName>/... URI scheme inside the registry.

    CREATE OR REPLACE STAGE ML_PROD_DB.SCORING.MODEL_STAGE
     ENCRYPTION = (TYPE = 'SNOWFLAKE_SSE');
    
    PUT file:///local/path/churn_xgb.joblib @ML_PROD_DB.SCORING.MODEL_STAGE
     AUTO_COMPRESS = FALSE
     OVERWRITE = TRUE;
    
    LIST @ML_PROD_DB.SCORING.MODEL_STAGE;
    

    The AUTO_COMPRESS = FALSE flag is required for model serialization formats that the Snowflake gzip path corrupts (pickle and joblib both qualify); leaving the default causes joblib.load to fail at UDF execution time with a checksum error.

    🎯 Scenario: A team has trained a 600 MB sentence-transformer model in Python on their laptop and wants real-time semantic search inside Snowflake. Latency target is 200 ms p95 from REST endpoint. The model needs PyTorch and the sentence-transformers PyPI package. A warehouse UDF cannot install the package and would exceed the package size ceiling. A Scalar UDF would be too slow per call. An external function would add network egress and cost. The right surface is Model Registry inference on a Snowpark Container Services GPU compute pool with ingress_enabled=True, scaled horizontally via max_instances based on observed concurrency.

    Decision Tree: Choosing the Right Deployment Surface

    Decision rule: Choose Model Registry warehouse inference when the model uses Anaconda-channel packages, fits in warehouse memory, and needs SQL plus Python access with version tracking. Choose Model Registry on Snowpark Container Services when PyPI-only packages, GPU acceleration, or sub-second REST latency through an ingress endpoint is required. Choose a Vectorized UDF when the team is not ready to adopt the Registry but needs batch throughput on Anaconda-channel packages. Choose a Scalar UDF only for legacy code or single-row interactive lookups. Choose an external function only when the model cannot be brought into Snowflake at all. Choose a Cortex pre-built function when the task fits a Snowflake-managed model and no custom training is needed.

    Limits and quotas

    A model version, once logged, is immutable: its serialized artifact, signature, dependencies, and code paths cannot be altered. Comments, tags, and metrics on the version can be modified via ALTER MODEL ... MODIFY VERSION. To replace a model artifact, the practitioner logs a new version under the same model name and reassigns the relevant alias. The Registry cannot be used inside Snowflake Native Apps. Models cannot be shared or cloned, and are skipped during database replication. Warehouse Python sandbox memory is bound by the warehouse size class; oversized models will load on a Snowpark-optimized warehouse but fail on a standard small warehouse.


    Continue with the interactive course

    Track your progress, jump into practice questions and use the Shark AI tutor inside the Snowflake - SnowPro Advanced Data Scientist learning hub.