Key takeaways
Serving shape is decided by how the consumer reads the score, not by how clever the model is.
Batch scoring on a schedule handles most business models, including churn, at a fraction of the cost of a live endpoint.
Real time earns its keep only when the input is not knowable before the request arrives.
In a one row request, building the pandas DataFrame costs more milliseconds than the model does.
Three hundred and twelve. That was every scoring request our churn endpoint handled in its busiest week, and I had sized it to survive two hundred a second. Nobody had lied to me. I had simply never asked the retention team when they actually read a churn score, and their answer, when I finally asked, was once a day at six in the morning.
Cost of a wrong serving choice
Serving means getting a number out of a trained artifact and into whatever consumes it. That sentence hides an enormous amount of engineering, because a prediction sitting in a Python process is worth nothing until a campaign tool, a screen or a downstream job can read it. Every choice in this part is really a choice about that last step.
My own version of that lesson was expensive in a quiet way. I stood up a real time churn endpoint on a managed container service: three replicas, one vCPU and two gigabytes each, sitting behind a load balancer, because three replicas felt like the responsible number. Monthly bill came to roughly forty seven dollars once the balancer was counted. Eight months passed before anyone opened the cost report line by line, so about three hundred and seventy six dollars had gone on serving predictions that a nightly job produced in one and a half seconds. We deleted the endpoint on a Thursday and replaced it with a scheduled job writing to a table. Retention never noticed, because their campaign tool had always read that table at six in the morning and never once called the API in between.
War story
Three replicas, eight months, about 376 dollars, to serve 312 requests in the busiest week. Deleting that endpoint and scheduling a job took an afternoon and changed nothing a user could see. Ask the consumer when they read the score before you decide how to serve it.
Ask two questions before writing any serving code. First, when does the consumer read the prediction, and second, is the input to the model knowable before that moment. Answer both honestly and the serving shape usually picks itself. Skip them and you end up defending infrastructure that exists because it seemed professional.
Three serving shapes, side by side
Batch scoring runs on a schedule, takes a whole population of rows, and writes predictions to a table or file. Real time serving, sometimes called online or synchronous serving, answers a network request with a prediction for one row or a handful of rows while the caller waits. Streaming sits between them: a long lived consumer reads events off a queue or log as they arrive, scores each one, and writes to a sink, with nobody blocking on the answer.
| Shape | Latency the caller sees | Score freshness | Idle cost per month | Hardest failure mode |
|---|---|---|---|---|
| Batch | None, score already written | Up to 24 hours old | About 0.40 USD for 30 runs of 2 minutes on 2 vCPU | Silent job failure, stale table nobody checks |
| Real time | 8 ms p50, 27 ms p99 measured | Current at request time | About 47 USD for 3 replicas plus a load balancer | Feature order drift between client and pipeline |
| Streaming | Seconds, asynchronous | Seconds behind the event | Broker plus consumers, rarely under 100 USD | Consumer lag and replay producing duplicate scores |
Notice how often stakeholders say real time when they mean fresh. Those words point at different problems. A retention manager complaining that scores are stale usually wants yesterday evening replaced by this morning, which is a scheduling change, not an architecture change. Moving a daily job to hourly costs almost nothing and solves the actual complaint; standing up an endpoint solves a complaint nobody made.
Batch scoring for the churn model
Last part we packaged the churn project into telco-churn 0.3.0, with a churn train command that writes a versioned pipeline artifact to disk. This part takes that artifact and gives other systems a way to consume what it produces. Batch first, because batch is what churn actually needs, and because the batch path is the one you can debug by looking at a file.
Scoring code is shorter than people expect, because all the feature engineering from Part 6 lives inside the saved pipeline object rather than in this script. Saving the fitted pipeline, not the bare estimator, is what keeps the two halves honest.
Real time endpoint with FastAPI
Sometimes the input genuinely is not knowable in advance. A quote form where the customer types a contract length, a fraud check on a card that has never been seen, a session that only exists once somebody clicks: those cases have no precomputed row to look up. FastAPI plus uvicorn is my default there, mostly because pydantic gives you input validation for free and because the resulting service is small enough to read in one sitting.
Now for the line that cost me an afternoon. Early versions of that handler had no reindex call, and everything passed locally. In production a Java client serialised its JSON with fields in a different order, pandas built the DataFrame in that order, and scikit-learn refused.
Worth knowing: this error is a gift compared to the alternative. Older pipelines that took a plain NumPy array would have accepted the scrambled columns silently and returned a confident, meaningless probability. Column name checking inside scikit-learn turns a wrong answer into a loud failure, which is why I always feed a named DataFrame rather than an array at serving time.
If writing a service feels like more than the problem deserves, MLflow will stand one up from a logged model with a single command, exposing a POST endpoint at /invocations that accepts a JSON payload. It is a reasonable way to get something callable in five minutes, and a poor way to run production traffic, because you inherit its request schema and its error handling instead of your own.
Streaming scores and where they earn their keep
Streaming means a process that stays alive, subscribes to a topic on a broker such as Kafka, Kinesis or Pub/Sub, and scores each event shortly after it is published. Nothing blocks on the result. Output goes to another topic, a cache or a table, and some other system picks it up when it is ready. Latency measured end to end is usually a second or two, not milliseconds, because the broker itself batches.
Genuine cases exist. Card fraud has to be judged inside the authorisation window. Ad selection has to resolve before a page paints. Session level personalisation only makes sense while the session is open. What these share is that the decision is made inside the event’s own lifetime, and a score computed an hour later would have no consumer at all.
Churn is not one of those cases and I would not stream it. Somebody’s probability of leaving in the next thirty days does not meaningfully change between 09:14 and 09:15, and pretending otherwise buys you a broker to operate, consumer lag to monitor, and replay semantics to reason about. Duplicate delivery is the part teams underestimate: brokers give at least once delivery by default, so a replayed partition will score the same customer twice, and unless your sink is keyed on customer and event id you will have two rows disagreeing about the same person. Fixing that properly costs more engineering than the entire batch path did.
Verdict on this one is blunt. Choose streaming when the consuming decision expires in seconds. Avoid it when a business process reads the score on a human schedule, which covers churn, credit review, propensity scoring, lifetime value and almost every model a marketing team asks for.
Latency budget arithmetic
Suppose you do need an endpoint. Somebody will hand you a latency target, and you should immediately break it into stages, because guessing which stage dominates is almost always wrong. Here is what a single scoring request actually spent, averaged over ten thousand calls against the service above.
| Stage | Mean time | Share of request |
|---|---|---|
| HTTP parse and routing | 0.4 ms | 4.5 percent |
| pydantic validation | 0.3 ms | 3.4 percent |
| DataFrame construction and reindex | 1.9 ms | 21.6 percent |
| Pipeline transform steps | 4.1 ms | 46.6 percent |
| predict_proba on the estimator | 1.8 ms | 20.5 percent |
| JSON response | 0.3 ms | 3.4 percent |
| Total | 8.8 ms | 100 percent |
Read that table again, because it contains the most useful thing in this part. Roughly two thirds of a one row request is spent moving data into and through pandas, and one fifth is the actual gradient boosted model from Part 12. Teams who chase latency by shrinking the model are optimising the smallest slice. Accepting a list of rows per request and scoring them in one call moves far more, since that 1.9 milliseconds of construction is paid once for fifty customers instead of fifty times.
Look at what changed between the first two groups. Median latency went from nine milliseconds to eight, which is noise. At the ninety ninth percentile it fell from seventy four to twenty seven, because a single Python process handles one request at a time and every unlucky caller queues behind whoever arrived first. Scikit-learn releases the global interpreter lock in parts of its numerical code, but not enough of it to save you, so processes beat threads for this workload. Setting workers to roughly the number of vCPUs available, then measuring, is a better starting point than any rule someone quotes at you.
My take
Quote p99 and never p50 when you agree a latency target with a product team. Median latency describes a system nobody experiences during an incident. Committing to twenty five milliseconds at p99 forces the worker count, the container size and the batching decision to be made honestly, whereas committing to ten milliseconds at p50 lets a badly sized service pass its own test while a tenth of users wait.
Rolling out a new model version safely
Serving is not something you do once. Every retrain produces a new artifact, and swapping it under live traffic is its own small discipline. Batch makes this pleasantly boring: write the new version to a separate dated file, run both for a week, compare score distributions, then repoint the table when you are satisfied. Rolling back means changing one line of configuration, and yesterday’s file is still sitting on disk where you left it.
An endpoint offers three common patterns. Shadow mode sends a copy of production traffic to the new version and throws its answers away after logging them. Canary routes a small fraction of real traffic to the new version and watches for damage. Blue green keeps two complete environments and flips a router between them. My preference for models, which differs from what I would do for ordinary application code, is shadow for a week and then a straight cutover, skipping canary entirely.
Reasoning behind that comes from an expensive mistake. I once canaried a churn model at ten percent of traffic and left it there for three weeks, waiting for evidence that never arrived, because the churn label itself takes thirty days to materialise. No amount of patience was going to make ten percent of a low volume stream significant. Shadow mode would have answered the question we could actually answer quickly, which is how often the two versions disagree: when we finally ran it, the versions differed by more than 0.1 in probability on twelve percent of customers, and two of those cases turned out to be a mis-mapped contract category. One day of shadow logging beat three weeks of canary.
Whichever pattern you choose, stamp the model version into the scoring response and into every batch row, as the code above does. Without it, an incident starts with somebody guessing which artifact was live at nine in the morning. Part 24 covers the registry that makes those version strings mean something beyond a filename you typed by hand.
Serving choice I would make for churn
For the churn project I would run a nightly batch job writing to a warehouse table, and nothing else, until somebody produces a screen where a human waits on a score. If that screen arrives, I would add a thin read API over the precomputed table rather than a model endpoint, because looking up a row is faster, cheaper and impossible to get subtly wrong. A live model endpoint is the third choice, not the first, and streaming does not enter the conversation for a thirty day churn horizon.
What I would avoid, having done it: standing up an endpoint because it demonstrates that the model is finished. Serving infrastructure is a running cost and an on call surface, and it should exist because a consumer needs it, not because a project needs a milestone. Should you find yourself unable to name the system that will call your endpoint, that is your answer.
One crack in all of this is already visible. Batch scoring reads features computed by one code path, an endpoint computes them in another, and the two drift apart the moment somebody edits a transformation on only one side. Part 23 covers feature stores and training serving skew, which is the machinery built to stop exactly that. Before you get there, take the churn service above, add a test that asserts the endpoint and the batch file agree on ten sampled customers, and watch how quickly that test starts failing once two people are editing features.
References
- Deployments Concepts, FastAPI documentation
- Deploy MLflow Model as a Local Inference Server, MLflow 3.14.0
- Model persistence, scikit-learn 1.7.2 documentation
- Deployment, uvicorn 0.51.0 documentation
- Models, pydantic 2.13.4 documentation


DrJha