You spent three weeks building a model that hits 94% accuracy on your validation set. Your team is excited. You deploy it to production. Within 48 hours, something is wrong — predictions are off, latency spikes, or the service is just silent. The logs are a mess. Your on-call engineer is staring at a stack trace at 2am.
This is not a rare story. It is the most common experience in ML engineering, and it happens even to experienced teams. The gap between a notebook and a production ML system is not just an engineering gap — it is a reliability gap, an observability gap, and often a discipline gap.
In this post, we break down the seven most common reasons ML pipelines fail in production, with concrete fixes for each. No fluff, just patterns we have seen repeatedly across real deployments.
1. Training-serving skew
This is the silent killer. Your model trains on data processed one way, but at inference time, the data goes through a slightly different pipeline. The difference can be as small as a single feature being normalized differently, or a categorical encoder fit on slightly different values.
The result is not a crash. It is slow, silent degradation. Your model returns predictions, but they are wrong in ways that take days or weeks to catch.
What it looks like
Your offline metrics are strong, but your business metrics — click-through rate, conversion, churn — start moving in the wrong direction after deployment.
The fix:
Use a single feature pipeline shared between training and inference. Not two separate scripts that happen to do the same thing — the same code path, the same object, serialized and reloaded.
# Wrong: two separate pipelines
# train.py
df['amount_log'] = np.log1p(df['amount'])
# serve.py <- diverges silently over time
features['amount_log'] = np.log(features['amount'] + 1)
# Right: one pipeline, serialized
from sklearn.pipeline import Pipeline
import joblib
pipeline = Pipeline([
('log_transform', FunctionTransformer(np.log1p)),
('scaler', StandardScaler())
])
pipeline.fit(X_train)
joblib.dump(pipeline, 'feature_pipeline.pkl') # same artifact in prod
# Wrong: two separate pipelines
# train.py
df['amount_log'] = np.log1p(df['amount'])
# serve.py <- diverges silently over time
features['amount_log'] = np.log(features['amount'] + 1)
# Right: one pipeline, serialized
from sklearn.pipeline import Pipeline
import joblib
pipeline = Pipeline([
('log_transform', FunctionTransformer(np.log1p)),
('scaler', StandardScaler())
])
pipeline.fit(X_train)
joblib.dump(pipeline, 'feature_pipeline.pkl') # same artifact in prod
Key rule: If your training code and serving code share a feature name but not the same transformation code, you have a bug waiting to happen.
2. Dependency hell and environment drift
Your model trains fine with Python 3.9, PyTorch 2.0.1, and CUDA 11.7. Your production container runs Python 3.10, PyTorch 2.1.0, and CUDA 12.0. Suddenly your custom CUDA kernel behaves differently, or a library update changes the default behavior of a function you relied on.
What it looks like
The model loads, but outputs are numerically different from what you validated offline. Or the service crashes with an import error or a CUDA assertion.
The fix
Pin everything — not just your direct dependencies, but your transitive dependencies too. Use a lockfile. Build your training and serving environments from the same base Docker image.
# requirements.txt -- too loose
torch>=2.0
numpy>=1.24
# requirements.lock -- what you actually want in prod
torch==2.0.1+cu117
numpy==1.24.3
tokenizers==0.13.3
transformers==4.30.2
# requirements.txt -- too loose
torch>=2.0
numpy>=1.24
# requirements.lock -- what you actually want in prod
torch==2.0.1+cu117
numpy==1.24.3
tokenizers==0.13.3
transformers==4.30.2
3. No input validation at the serving boundary
Your model was trained on clean, preprocessed data. In production, raw input arrives from mobile apps, third-party APIs, and user forms. Null values, out-of-range floats, unexpected categories, wrong data types: all of these reach your model if you let them.
What it looks like
Random NaN predictions. Occasional crashes that are hard to reproduce. Silent errors when a category encoder sees a value it was not trained on.
The fix
Validate inputs at the boundary before they touch your model. Define a schema and enforce it strictly.
from pydantic import BaseModel, validator
class PredictionRequest(BaseModel):
user_age: int
transaction_amount: float
merchant_category: str
@validator('user_age')
def age_must_be_valid(cls, v):
if v < 0 or v > 120:
raise ValueError('age out of expected range')
return v
@validator('merchant_category')
def category_must_be_known(cls, v):
known = {'retail', 'travel', 'dining', 'other'}
return v if v in known else 'other' # graceful fallback
from pydantic import BaseModel, validator
class PredictionRequest(BaseModel):
user_age: int
transaction_amount: float
merchant_category: str
@validator('user_age')
def age_must_be_valid(cls, v):
if v < 0 or v > 120:
raise ValueError('age out of expected range')
return v
@validator('merchant_category')
def category_must_be_known(cls, v):
known = {'retail', 'travel', 'dining', 'other'}
return v if v in known else 'other' # graceful fallback
4. Missing model versioning and rollback capability
Something goes wrong in production. You need to roll back to yesterday’s model. But your model artifacts are stored in a shared S3 path that gets overwritten on every deploy. There is no rollback path — only a full redeployment.
What it looks like
A 3am incident where the only fix takes 45 minutes and requires someone who knows the infra.
The fix
Every model artifact gets a version. Every deployment is a pointer to a specific version. Rolling back is changing the pointer, not rebuilding the system.
# Versioned artifact path
s3://shipml-models/fraud-detector/v1.4.2/model.pkl
s3://shipml-models/fraud-detector/v1.4.2/pipeline.pkl
s3://shipml-models/fraud-detector/v1.4.2/metadata.json
# metadata.json -- always include this
{
"model_version": "1.4.2",
"trained_at": "2026-03-15T10:22:00Z",
"training_dataset": "transactions_2024q4",
"metrics": { "auc_roc": 0.943, "f1": 0.871 },
"promoted_by": "sumit.k@shipml.in"
}
# Versioned artifact path
s3://shipml-models/fraud-detector/v1.4.2/model.pkl
s3://shipml-models/fraud-detector/v1.4.2/pipeline.pkl
s3://shipml-models/fraud-detector/v1.4.2/metadata.json
# metadata.json -- always include this
{
"model_version": "1.4.2",
"trained_at": "2026-03-15T10:22:00Z",
"training_dataset": "transactions_2024q4",
"metrics": { "auc_roc": 0.943, "f1": 0.871 },
"promoted_by": "sumit.k@shipml.in"
}
With SageMaker, use Model Registry to track versions and approval status. The version string must be the same identifier from training job through deployment — no manual renaming, no ambiguity.
5. No monitoring — flying blind after deploy
Deploying a model without monitoring is like launching a product without analytics. You will not know it is broken until a human notices something wrong — and by then, the damage is done.
Most teams add logging. Very few teams track what matters: input distributions, prediction distributions, and model performance over time.
What it looks like
Model accuracy degrades over three months as the real world shifts. Nobody notices because no alert fires. A quarterly review catches it, but thousands of bad predictions have already been served.
The fix
Monitor three layers: infrastructure (latency, error rate, memory), data (input feature distributions vs training baseline), and model (prediction distribution, confidence scores).
import boto3
cloudwatch = boto3.client('cloudwatch')
def log_prediction(features, prediction, confidence, latency_ms):
cloudwatch.put_metric_data(
Namespace='ShipML/FraudDetector',
MetricData=[
{'MetricName': 'PredictionLatency',
'Value': latency_ms, 'Unit': 'Milliseconds'},
{'MetricName': 'PredictionConfidence',
'Value': confidence, 'Unit': 'None'},
{'MetricName': 'FraudPredictionRate',
'Value': float(prediction), 'Unit': 'None'},
]
)
import boto3
cloudwatch = boto3.client('cloudwatch')
def log_prediction(features, prediction, confidence, latency_ms):
cloudwatch.put_metric_data(
Namespace='ShipML/FraudDetector',
MetricData=[
{'MetricName': 'PredictionLatency',
'Value': latency_ms, 'Unit': 'Milliseconds'},
{'MetricName': 'PredictionConfidence',
'Value': confidence, 'Unit': 'None'},
{'MetricName': 'FraudPredictionRate',
'Value': float(prediction), 'Unit': 'None'},
]
)
6. Synchronous inference where you need async
Your model takes 800ms to run. Your API endpoint is synchronous. Under load, requests queue up, timeouts start firing, your upstream service retries, which creates more load. Classic thundering herd.
What it looks like
The service works perfectly at low traffic. Under load it falls over completely. P99 latency jumps from 900ms to 30 seconds.
The fix
For high-latency models, use async inference with a queue. For batch-able workloads, use model batching to amortize cost across multiple requests.
# SageMaker async inference endpoint config
async_config = AsyncInferenceConfig(
output_path='s3://shipml-outputs/async-predictions/',
max_concurrent_invocations_per_instance=4,
notification_config={
'SuccessTopic': 'arn:aws:sns:...:prediction-success',
'ErrorTopic': 'arn:aws:sns:...:prediction-error'
}
)
predictor = model.deploy(
async_inference_config=async_config,
instance_type='ml.g4dn.xlarge'
)
# SageMaker async inference endpoint config
async_config = AsyncInferenceConfig(
output_path='s3://shipml-outputs/async-predictions/',
max_concurrent_invocations_per_instance=4,
notification_config={
'SuccessTopic': 'arn:aws:sns:...:prediction-success',
'ErrorTopic': 'arn:aws:sns:...:prediction-error'
}
)
predictor = model.deploy(
async_inference_config=async_config,
instance_type='ml.g4dn.xlarge'
)
For real-time use cases where async is not possible, use dynamic batching at the serving layer. PyTorch Serve and Triton both support it — they accumulate requests over a short window (5-20ms) and run them as a single batch, dramatically improving GPU utilization.
Deployment checklist
The 2am test: If your model starts behaving badly at 2am, how long does it take to detect it, diagnose it, and roll back? If the answer is more than 30 minutes, your prod setup needs work.
Final thoughts
None of these problems are exotic. They are the everyday failures that happen when a team moves fast and skips the reliability foundations. The good news is that each one has a well-understood fix, and implementing them does not require a complete infrastructure overhaul.
Start with training-serving skew and missing monitoring — they cause silent failures you will not catch for weeks. Add versioning and rollback before your first incident, not after.
At ShipML, this is exactly what we help Series A and B AI startups get right — not by building a generic MLOps platform on top of your stack, but by building the right reliability foundations inside your existing infrastructure, using your team’s tools.

