Releasing Models Safely

Blue-green model deployments

A blue-green deploy keeps two complete, fully warmed-up environments and flips every request between them with one instant switch, the way a home changeover switch moves the whole house from grid power to a generator, never a blend of both.

Read these first

On this page 7
  1. The short answer
  2. The analogy you have already lived
  3. Why it exists
  4. How it works
  5. A real example you have seen
  6. Remember this
  7. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

A blue-green deploy keeps two complete, ready-to-go copies of your service and switches every request from one to the other, all at once.

The analogy you have already lived

You have seen a changeover switch next to a home inverter or generator. It moves the whole house from grid power to backup power with one flip. Nothing is ever half on grid and half on generator. It is one or the other, decided in an instant, by a single physical action.

Blue-green deployment is that same switch, for a service. Blue is the version currently live. Green is the new version, fully running and fully ready, but not yet receiving any real traffic. Flip the switch, and every request moves to green at once. Flip it back, and every request returns to blue — equally fast.

Why it exists

Canary releases work by mixing old and new traffic gradually. That mixing is a strength when you want a careful, incremental check — but it is also a real cost. Two versions running side by side, even briefly, means extra complexity. Which version wrote which log line? Which one does a support ticket refer to? Can the two versions even safely share a database schema at the same time?

Sometimes you do not want a gradual mix at all. You want an instant, all-or-nothing switch, with an equally instant way back if something is wrong. Blue-green gives you that, at the cost of needing to run two full copies of the environment at once, even if only briefly.

How it works

   BEFORE the switch
   all real traffic -----------> BLUE  (live, serving everyone)
                                  GREEN (fully deployed, fully warmed up, idle)

   THE SWITCH  (one instant action)
   all real traffic -----------> GREEN (now live, serving everyone)
                                  BLUE  (idle, kept exactly as it was)

   IF SOMETHING IS WRONG
   all real traffic -----------> BLUE  (switched back, instantly — it never stopped being ready)

Notice green is fully running and fully "warmed up" before it receives a single real request. Its model is already loaded into memory, ready to answer instantly. That is what makes the switch itself fast: there is no cold start hiding inside it.

A real example you have seen

A television broadcast studio often has two identical control rooms, one live and one on standby. It can cut between them instantly if the live one has a technical problem. The standby room was never half-running — it was fully ready, the entire time, waiting.

Remember this

  • Blue-green keeps two full, ready environments, and switches all traffic at once, not gradually.
  • The new environment is fully warmed up before the switch, which is what makes the switch itself instant.
  • Rolling back is only switching again, back to the environment that was never turned off.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install scikit-learn pandas numpy pytest

The router

Two model "slots", one active pointer, and a switch that is a single attribute assignment — which matters more than it looks, as the broken version further down demonstrates.

bluegreen.py
"""A blue-green router: two fully warmed-up environments, one active pointer,
switched with a single atomic assignment.
"""
import time

import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

COLUMNS = ["income", "years", "age"]


def make_training_data(n=400, seed=0):
    rng = np.random.RandomState(seed)
    X = pd.DataFrame({
        "income": rng.uniform(5, 80, n).round(1),
        "years": rng.uniform(0, 10, n).round(1),
        "age": rng.randint(21, 65, n).astype(float),
    })
    score = 0.05 * X["income"] + 0.35 * X["years"] + 0.01 * X["age"] - 3.5
    y = (score + rng.normal(0, 0.8, n) > 0).astype(int)
    return X, y


def load_model(seed) -> object:
    """Standing in for a slow, real model load (reading a large file from disk)."""
    time.sleep(0.05)  # simulated load latency -- this is the cost warming up avoids
    X, y = make_training_data(seed=seed)
    return make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)).fit(X, y)


class BlueGreenRouter:
    def __init__(self):
        self.models = {}
        self.active = "blue"

    def deploy(self, slot: str, seed: int):
        """Loads and warms up a slot WITHOUT sending it any traffic yet."""
        self.models[slot] = load_model(seed)

    def switch_to(self, slot: str):
        assert slot in self.models, f"{slot} was never deployed -- refusing to switch to it"
        self.active = slot  # a single attribute write -- atomic under the GIL

    def predict(self, applicant: dict) -> tuple[int, str]:
        model = self.models[self.active]
        row = pd.DataFrame([applicant], columns=COLUMNS)
        return int(model.predict(row)[0]), self.active


router = BlueGreenRouter()

start = time.perf_counter()
router.deploy("blue", seed=0)
router.switch_to("blue")
print(f"blue deployed and warm in {time.perf_counter() - start:.3f}s")

answer, slot = router.predict({"income": 60.0, "years": 7.0, "age": 34.0})
print(f"serving from '{slot}': {answer}")

start = time.perf_counter()
router.deploy("green", seed=1)  # deployed AHEAD of the switch, while blue keeps serving
print(f"green deployed and warm in {time.perf_counter() - start:.3f}s (blue kept serving throughout)")

router.switch_to("green")
answer, slot = router.predict({"income": 60.0, "years": 7.0, "age": 34.0})
print(f"serving from '{slot}': {answer}")
Output
blue deployed and warm in 0.053s
serving from 'blue': 1
green deployed and warm in 0.052s (blue kept serving throughout)
serving from 'green': 1

The 0.05Xs timings are this machine's real measurement of a deliberately fake 50ms load delay, so the exact third decimal will differ slightly on any other run — both numbers should still land close to 0.05s. The key detail is when that cost is paid: before the switch, while blue is still answering every real request. Nobody waiting on a real answer ever experiences that fifty milliseconds.

Proving the switch is really instant, under real concurrent load

concurrent_switch.py
import threading
import time
from bluegreen import BlueGreenRouter

router = BlueGreenRouter()
router.deploy("blue", seed=0)
router.deploy("green", seed=1)

applicant = {"income": 60.0, "years": 7.0, "age": 34.0}
results = []
stop = threading.Event()

def hammer():
    while not stop.is_set():
        _, slot = router.predict(applicant)
        results.append(slot)

threads = [threading.Thread(target=hammer) for _ in range(8)]
for t in threads:
    t.start()

time.sleep(0.05)
router.switch_to("green")   # the atomic cutover, while 8 threads are mid-flight
time.sleep(0.05)

stop.set()
for t in threads:
    t.join()

print(f"total requests during the switch: {len(results)}")
print(f"served by blue:  {results.count('blue')}")
print(f"served by green: {results.count('green')}")
print(f"served by neither (would mean a bug): {len(results) - results.count('blue') - results.count('green')}")
Output
total requests served during the switch: 212
served by blue:  122
served by green: 90
served by neither (would mean a bug): 0

Eight threads hammered predict() continuously while the switch happened mid-flight. The exact counts (212 total, split 122/90) are from one real run and will differ on yours — thread scheduling is not something Python controls precisely. The number that matters is the last line: zero requests were served by neither, meaning every single request got a clean, well-defined answer from exactly one slot. That is what "atomic" is actually buying you.

Now break something on purpose

A tempting, and unsafe, alternative: clear the active slot first, then set the new one, as two separate steps.

unsafe_switch.py
def unsafe_switch_to(slot):
    """A tempting-looking but unsafe 'clear then set' switch -- two separate steps."""
    router.active = None   # step 1: "clear" the old one first
    time.sleep(0.001)      # any gap here is a real production risk
    router.active = slot   # step 2: set the new one

Run against the same eight-thread hammering as above:

Output
errors during the unsafe two-step switch: 37904
first error: KeyError(None)

In the tiny window where router.active is None, every request landing in that window looks up self.models[None], which does not exist, and crashes. Nearly thirty-eight thousand real errors, from a change that looks almost identical to the safe version — the only difference is doing the switch in two steps instead of one. The safe version works because a single attribute assignment in CPython cannot be observed "half-done" by another thread; the two-step version manufactures exactly the in-between state the single-step version avoids.

Testing the router

test_bluegreen.py
import pytest
from bluegreen import BlueGreenRouter


def test_switching_to_an_undeployed_slot_is_refused():
    router = BlueGreenRouter()
    router.deploy("blue", seed=0)
    with pytest.raises(AssertionError):
        router.switch_to("green")


def test_switch_and_instant_rollback():
    router = BlueGreenRouter()
    router.deploy("blue", seed=0)
    router.deploy("green", seed=1)
    applicant = {"income": 60.0, "years": 7.0, "age": 34.0}

    router.switch_to("green")
    _, slot = router.predict(applicant)
    assert slot == "green"

    router.switch_to("blue")  # rollback -- blue was kept warm the whole time
    _, slot = router.predict(applicant)
    assert slot == "blue"


def test_the_idle_slot_is_never_used_for_real_traffic():
    router = BlueGreenRouter()
    router.deploy("blue", seed=0)
    router.deploy("green", seed=1)
    applicant = {"income": 60.0, "years": 7.0, "age": 34.0}
    for _ in range(20):
        _, slot = router.predict(applicant)
        assert slot == "blue"  # never green, until switch_to is called
bash
pytest test_bluegreen.py -q
Output
...                                                                      [100%]
3 passed in 1.14s

Common mistakes

Deploying green without warming it up. If green's model only loads on its first real request, the switch itself is instant, but the first requests after the switch pay a real cold-start cost — exactly the problem model warm-up and cold starts covers. Warm before you switch, not during.

Turning blue off right after switching to green. Blue's entire value as a rollback path depends on it staying warm and ready. Keep it running, unchanged, for a defined window after the switch — long enough to be confident green is healthy — before reclaiming its resources.

Multi-step switches, as shown above. Any "clear, then set" pattern reopens a window where a request can see an invalid or missing state. A switch should be one atomic action, not a short sequence that only usually finishes fast enough.

Assuming blue-green means zero risk. It removes the risk of a slow, uncontrolled rollout, and gives an instant rollback path. It does nothing to check whether green's answers are actually good — that is a job for the checks built in the earlier lessons of this section, run against green before it ever takes the switch.

Sharing a database or schema between blue and green incompatibly. If green expects a column blue does not write, and both must be able to run (even briefly, during rollback), a schema change needs its own backward-compatible rollout plan — a blue-green switch on the application layer does not solve a blue-green problem underneath it, in the data layer.

Try it yourself

Add a health_check(slot) method that runs a handful of sanity predictions against a slot before switch_to is allowed to target it — refusing the switch if any of them raise an exception. This turns "deployed" and "safe to receive traffic" into two separate, explicitly checked states, instead of assuming the second follows automatically from the first.

What to learn next

Researcher — Mathematics and papers.

Atomicity, and where the guarantee actually comes from

The safety of self.active = slot under concurrent reads relies on CPython's Global Interpreter Lock serialising bytecode execution: a single STORE_ATTR for a simple reference assignment cannot be interleaved with another thread's read of the same attribute in a way that observes a partially-written value. This guarantee is specific to CPython and to simple attribute or variable assignment — it does not extend to compound operations (self.active += 1 involves a read-modify-write with an interleaving window), to multi-process deployments (a real blue-green switch behind a load balancer coordinates across separate OS processes, where no shared-memory atomicity applies at all), or to other Python implementations without an equivalent lock. The unsafe_switch_to failure above is deliberately constructed as a multi-step mutation to make the general principle visible on a single machine; a real production blue-green switch is more commonly implemented at the load balancer or DNS layer, where the relevant atomicity guarantee comes from that infrastructure's own routing-table update mechanism, not from the GIL.

Resource cost and the double-capacity requirement

Running two full environments simultaneously means blue-green deployment requires, at minimum, double the steady-state serving capacity during the overlap window — a real and sometimes prohibitive cost for large models on expensive accelerators, discussed further in GPU cost per request. This is the direct resource-cost counterpart to canary's traffic-mixing complexity: canary trades operational complexity for lower peak resource cost; blue-green trades higher peak resource cost for operational simplicity and an instant, unambiguous switch.

Blue-green versus canary as points on one spectrum, not a binary choice

Formally, both patterns are instances of traffic-splitting between two serving populations, parameterised by the fraction routed to the new version and by how that fraction changes over time: canary moves the fraction from 0 to 1 gradually, often as a function of observed health metrics; blue-green moves it from 0 to 1 in a single step. Nothing prevents combining them — many production systems canary a release first, and once confident, execute the final cutover as a blue-green-style instant switch to complete the rollout, rather than a slow final ramp from 99% to 100%.

Papers and prior art

  • Humble and Farley, Continuous Delivery, 2010 — the book that popularised the blue-green name and pattern for general software releases, predating its ML-specific application.
  • Fowler, BlueGreenDeployment, martinfowler.com — martinfowler.com/bliki/BlueGreenDeployment.html

What to learn next

What to learn next

These follow on from what you just read.

  • Releasing Models Safely

    Rolling back a model

    Rolling back means switching straight back to the last version you trusted, without stopping to diagnose the new one first — the same instinct as putting your old shoes back on the moment new ones start to hurt.

  • Releasing Models Safely

    Feature flags for model rollouts

    A feature flag turns one specific behaviour on or off by editing a config value, the way a household electrical panel lets you cut power to only the kitchen without touching any other circuit — or the main switch.

  • Releasing Models Safely

    Champion and challenger models

    A champion model keeps serving every real decision while one or more challengers are scored continuously alongside it, and only take over after a sustained, statistically real advantage — the same discipline as a table-tennis champion who keeps the table only while they keep winning, not after one lucky point.