Module 6: Checks Groups And Realistic Scenarios

5. Parametrizing data: don't hit a hot route

Overview

Notice something we've done so far without questioning it: each VU requests the same data. Focus/basic/3h, over and over. Or Studio/pro/4h, a thousand times. The scenario has several steps, yes, but the content of each request is identical in all the iterations. And that, although it generates traffic, measures a falsely optimistic version of the system. Because when you always request the same thing, you hit a single hot route: the same code path, the same record, the same cache entry. And a system responding to the same repeated question is much faster —and much less representative— than one responding to the varied questions real users ask it.

Parametrizing is the solution: vary the input data in each iteration, taking it from a list, so the test exercises many paths and not just one. Instead of always quoting Focus/basic/3h, each iteration picks a row from a dataset —Focus, Studio, or Boardroom; basic or pro; different hours— and quotes that. The effect is twofold. First, realism: the mix of requests resembles that of real traffic. Second, and subtler, measurement honesty: you stop benefiting from caches and hot routes that in production wouldn't be so hot, and your p95 reflects the real cost of computing varied responses. In this lesson you learn to parametrize —with a list in Python (executed) and a SharedArray in k6 (content)— and you see measured the difference between hitting a hot route and spreading the load among varied data.

Connection to the module: parametrizing is the third piece of the realistic scenario. In k6, the dataset is loaded with a SharedArray (content); in Python, with a list each VU walks through (executed). The value check from lesson 2 was already prepared for this: it computes the expected price with the API's formula for any data, not a fixed number. You'll see the real distribution of a parametrized run against the canonical API, measured in this environment with Python 3.14.0. The boundary: here we vary the data; chaining steps where the data flows from one response to the next is the correlation of lesson 6.

The restaurant that only knows how to cook one dish

Imagine you want to know if a restaurant's kitchen holds up on a busy night, and to test it you send a thousand orders of the same dish: a thousand identical Caesar salads. The kitchen organizes itself: it prepares a mountain of lettuce, a bucket of dressing, and produces salads in a line at an impressive speed. You conclude: "the kitchen is very fast, it holds up perfectly." But a real night isn't a thousand identical salads: it's salads, pastas, meats, desserts, each with its station, its ingredients, its timing. The kitchen that flies making a thousand identical salads can collapse when a hundred different dishes arrive at once, because now it has to switch stations, find varied ingredients, coordinate. Testing with a single repeated dish gave you a falsely optimistic answer.

Hitting a hot route is sending a thousand identical salads. The system "organizes itself" around that single request —it caches the result, keeps the code path warm, maybe doesn't even recompute— and responds flying. Parametrizing is sending the varied menu: each iteration requests something different, the kitchen can't optimize for a single thing, and you measure how it holds up the real variety. The test with varied data is harder and more honest, just like judging a kitchen by a full-menu night and not by a thousand salads.

Always requesting the same data hits a single hot route: the same code path, the same record, the same cache entry, which the system responds to with a falsely optimistic speed. Parametrizing —varying the input data from a list— exercises many paths, like the varied menu of a real night, and measures the honest cost of answering different questions. It's the difference between a thousand identical salads and the full menu.

Parametrizing in k6: the SharedArray (content)

In k6, the dataset is loaded once and shared among all VUs with a SharedArray. The name is literal: a shared array in memory, so 50 VUs don't load 50 copies of the dataset. Each VU, in each iteration, picks a row:

// quote_parametrized.js - quote varying the data with a SharedArray.
// SHOWN AS CONTENT: k6 is not installed in this environment.
import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';

// The dataset loads ONCE and is shared among all VUs.
const dataset = new SharedArray('reservations', function () {
  return [
    { room: 'Focus',     tier: 'basic', hours: 3, expected: 7500 },
    { room: 'Focus',     tier: 'pro',   hours: 3, expected: 6000 },
    { room: 'Studio',    tier: 'basic', hours: 2, expected: 8000 },
    { room: 'Studio',    tier: 'pro',   hours: 4, expected: 12800 },
    { room: 'Boardroom', tier: 'basic', hours: 1, expected: 8000 },
    { room: 'Boardroom', tier: 'pro',   hours: 6, expected: 38400 },
  ];
});

const BASE_URL = 'http://localhost:8000';
const params = { headers: { 'Content-Type': 'application/json' } };

export default function () {
  // Each iteration picks a different row (here, at random).
  const row = dataset[Math.floor(Math.random() * dataset.length)];

  const body = JSON.stringify({ room: row.room, tier: row.tier, hours: row.hours });
  const res = http.post(`${BASE_URL}/quote`, body, params);

  check(res, {
    'status is 200': (r) => r.status === 200,
    // the expected comes from the dataset: different per row
    'price is correct': (r) => r.json('price_cents') === row.expected,
  });
}

Take it apart:

  • new SharedArray('reservations', function () { return [...]; }). Creates a shared array called reservations. The load function runs only once (in the initialization phase); its result is shared among all VUs. That's why it's a SharedArray and not a normal array: with thousands of VUs, loading the dataset once instead of a copy per VU saves a huge amount of memory. In a real case, the load function usually reads a file (JSON.parse(open('./data.json'))) or a CSV.
  • dataset[Math.floor(Math.random() * dataset.length)]. Each iteration picks a row at random. (Another common strategy is to use the iteration or VU number to walk through the dataset in order; at random is the simplest for spreading.)
  • 'price is correct': (r) => r.json('price_cents') === row.expected. The value check uses that row's expected, not a fixed number. This is the connection with lesson 2: the correctness check has to compute (or carry) the expected value from the current data, because the correct price changes with each row.

The key point of the SharedArray: load once, share among VUs, vary per iteration. It's the canonical way to parametrize data in k6 without blowing up the memory.

Parametrizing in Python: a list (executed)

In Python, the equivalent is a list each VU walks through, picking a row per iteration. No special "shared array" is needed: in the generator, the list is a global object all threads read (read-only, so it's safe):

# The parametrized dataset: each iteration picks a row (equivalent to the SharedArray).
DATASET = [
    {"room": "Focus",     "tier": "basic", "hours": 3},
    {"room": "Focus",     "tier": "pro",   "hours": 3},
    {"room": "Studio",    "tier": "basic", "hours": 2},
    {"room": "Studio",    "tier": "pro",   "hours": 4},
    {"room": "Boardroom", "tier": "basic", "hours": 1},
    {"room": "Boardroom", "tier": "pro",   "hours": 6},
]

# ... inside each VU's script:
row = rng.choice(DATASET)                 # picks a different row per iteration
room, tier, hours = row["room"], row["tier"], row["hours"]
# quotes that row; the expected is computed with expected_price(room, tier, hours)

To see the difference parametrizing makes, let's run the same number of quotes two ways and compare the distribution: first hitting a hot route (always Focus/basic/3h) and then parametrized (one dataset row per request). 60 quotes each.

What to expect. The hot route should hit 1 single row and see 1 single price; the parametrized one should spread among the 6 rows and see several different prices. Real output in this environment:

  HOT ROUTE (always the same row)  (60 requests)
    distinct rows requested: 1 of 6
    distinct prices seen: [7500]
      Focus     basic 3h .....: 60

  PARAMETRIZED (one dataset row per request)  (60 requests)
    distinct rows requested: 6 of 6
    distinct prices seen: [6000, 7500, 8000, 12800, 38400]
      Focus     basic 3h .....: 15
      Boardroom pro   6h .....: 10
      Focus     pro   3h .....: 10
      Boardroom basic 1h .....: 10
      Studio    basic 2h .....: 8
      Studio    pro   4h .....: 7

Read it, because the difference jumps out:

  • Hot route: 1 of 6 rows, 1 price ([7500]). The 60 requests requested exactly Focus/basic/3h. The system always responded 7500, through the same code path, with the same warm cache entry. You measure that path, not the system.
  • Parametrized: 6 of 6 rows, 5 prices ([6000, 7500, 8000, 12800, 38400]). The 60 requests spread among the six dataset rows, exercising different rooms, tiers, and hours. The system had to compute varied prices, not repeat a single one.
  • Five prices from six rows — why 5 and not 6? Because two different rows give the same price: Studio/basic/2h = 4000×2 = 8000, and Boardroom/basic/1h = 8000×1 = 8000. It's a real detail (two entries, one price) that shows the data was actually processed —it's not a made-up number, it's the API's arithmetic over varied data—.
  • The distribution isn't perfectly uniform (15, 10, 10, 10, 8, 7) because the choice is random and 60 requests is a small sample. With more requests it would approach ~10 per row. What matters isn't the exact uniformity, but that the load spread among the six rows instead of concentrating on one.

The measured lesson: parametrizing turned a test that touched 1 path into one that touches 6. The second is harder for the system and more like real traffic. If your p95 with varied data is worse than with the hot route, that worse number is the honest one —the one your users would see—.

Strategies for picking the row

Choosing at random (rng.choice) is the simplest, but there are other ways depending on what you're after:

  • At random (Math.random() / rng.choice): spreads the load evenly on average. Good by default, when you want a representative mix.
  • By iteration or VU index (dataset[__ITER % dataset.length] in k6): walks through the dataset in order, guaranteeing that each row is used the same number of times. Useful when you need exact coverage and not just statistical.
  • Unique data per VU (each virtual user uses data only it uses —for example, a different user for logging in): avoids collisions when each iteration modifies state. It's more advanced; for quotes (which only read) random is enough.

For the Reservo scenario, random spreads well and is what we use. The general rule: choose the strategy by what you want to guarantee —a representative mix (random), exact coverage (by index), or uniqueness (per VU)—.

Common mistakes

Parametrizing the data but leaving the check with a fixed number. What happens: someone varies room/tier/hours but leaves 'price is correct': (r) => r.json('price_cents') === 7500. Why it happens: the check was left from the time when only Focus/basic/3h was quoted. How to detect it: the price check fails for everything that isn't Focus/basic/3h, because 7500 is no longer the expected value. How to fix it: the expected value has to come from that row's data —from the dataset's expected, or computed with expected_price(room, tier, hours)—. If you parametrize the data, you have to parametrize the expected value.

Loading the dataset inside the VU's function (without SharedArray). What happens: in k6, someone puts const dataset = JSON.parse(open('./data.json')) inside default, and with thousands of VUs thousands of copies of the file are loaded, blowing up the memory. Why it happens: they don't know the SharedArray. How to detect it: very high memory usage, the test slows down or falls over with many VUs. How to fix it: load the dataset once with new SharedArray('name', () => {...}) in the script's scope (outside default); all VUs share that single copy.

Confusing "more requests" with "more paths." What happens: someone raises the VUs from 10 to 1000 but keeps requesting the same data, and thinks that makes their test more complete. Why it happens: volume gets confused with variety. How to detect it: the run touches "1 of N rows" no matter how many VUs. How to fix it: raising VUs adds volume over the same hot route; parametrizing adds variety of paths. They're different axes: you want both —enough volume (M4) and varied data (this lesson)—.

Exercises

Exercise 1 — Add a row and its expected value. To the lesson's dataset, add a row for Boardroom/pro/2h. (a) Compute the expected price_cents with the API's rule. (b) Write the row as you'd put it in k6's SharedArray.

See solution
  • (a) Boardroom costs 8000 cents/hour. 2 hours = 8000 × 2 = 16000. Pro tier: 16000 × 80 // 100 = 12800 cents.
  • (b)
{ room: 'Boardroom', tier: 'pro', hours: 2, expected: 12800 },

(Notice that this price, 12800, matches the one for Studio/pro/4h in the original dataset —another price collision, like Studio/basic/2h and Boardroom/basic/1h with 8000—. It's real: different rows can give the same price.)

Exercise 2 — Read the distribution. In the real run, the hot route touched "1 of 6 rows, prices [7500]" and the parametrized one "6 of 6 rows, prices [6000, 7500, 8000, 12800, 38400]". (a) Why did the hot route see a single price? (b) Why did the parametrized one see 5 prices and not 6, if it has 6 rows? (c) Which of the two runs is more representative of real traffic and why?

See solution
  • (a) Because the 60 requests requested the same row (Focus/basic/3h), and that row always gives the same price, 7500. One datum, one price.
  • (b) Because two different rows give the same price: Studio/basic/2h = 4000×2 = 8000 and Boardroom/basic/1h = 8000×1 = 8000. Six rows, but 8000 appears twice, so there are 5 distinct prices.
  • (c) The parametrized one. It exercises 6 different paths (varied rooms, tiers, and hours) instead of 1, so it resembles the mix of requests real users make. The hot route measures a single path, often cached, and gives a falsely optimistic reading.

Exercise 3 — The salad and the menu. A colleague tests the Reservo API with 500 VUs all quoting Focus/basic/3h, gets a great p95, and declares "the API holds up 500 users." What would you tell them, using the kitchen analogy? What would you change in their test?

See solution

I'd tell them they tested their kitchen with 500 orders of the same salad: the API organized itself around that single request (same code path, same warm cache) and responded flying, giving them a falsely optimistic p95. A real night isn't 500 identical salads, but a varied menu: different rooms, different tiers, different hours, each with its computation.

What I'd change: parametrize the data. Instead of the 500 VUs requesting Focus/basic/3h, have each iteration pick a row from a varied dataset (a SharedArray), and have the price check use that row's expected value. With that they'd measure how the API holds up answering different questions —the full menu—, which is the honest number. Volume (500 VUs) is fine; it's missing variety.

Summary and next step

In this lesson you removed a silent lack of realism: always requesting the same data. Hitting a hot route —the same code path, the same record, the same cache— gives a falsely optimistic reading, like judging a kitchen by a thousand identical salads. Parametrizing —varying room/tier/hours from a list (a SharedArray in k6, a list in Python)— exercises many paths, like the varied menu of a real night, and measures the honest cost of answering different questions. You saw it measured: the same number of quotes touched 1 row and 1 price on the hot route, against 6 rows and 5 distinct prices parametrized —with two rows giving the same price (8000), a real detail that proves the data was actually processed—. And the connection with lesson 2 became clear: if you parametrize the data, you have to parametrize the check's expected value.

Before moving on you should be able to: parametrize a scenario with a SharedArray (k6) or a list (Python); explain why a hot route deceives and varied data gives the honest number; make the value check use the current row's expected value; and choose a selection strategy (random, by index, per VU) according to what you want to guarantee.

Lesson 6 is the heart of the module: correlation. Until now, although the data varies, each scenario step is independent —quote, book, and confirm use data you fix—. A real flow is a chain: the price_cents you were quoted is the one you send to book, and the booking_id the booking returned is the one you use to confirm. You'll learn to extract a value from one response and reuse it in the next, and you'll run the full quote → book → confirm flow, with the booking_id really correlated.

Resources

  • SharedArrayk6/data — the reference for the shared array: how to load a dataset once and share it among all VUs without duplicating memory. The exact source of parametrization in k6.
  • Data parameterization in k6 — official examples of how to feed a test with varied data (arrays, JSON, CSV) and strategies for picking the row. The complete pattern of this lesson.
  • random.Random.choice — Python documentation — the method each generator VU picks a dataset row with. How a datum is selected at random in Python.