Skip to content

Declarative plans

A plan (YAML, TOML or JSON) describes the whole test: options with k6's names, and flows of steps each iteration runs. Nothing runs in Python, so plans go at full Rust speed (Tier 1).

Templates, extractors and feeders

Declarative flows can carry data without any Python in the hot path. {{ … }} templates in URLs, header values and bodies are parsed once. Extractors (json, regex, header) set per-VU variables. Feeders (inline rows, or a .csv/.json file next to the plan) give each iteration one row: the next one (sequential, split between workers), a random one, or one per VU (per-vu). Generators include uuid(), timestamp(), randomInt(min, max) and randomString(n), plus k6's __VU, __ITER and __ENV.NAME:

feeders:
  users: { file: users.csv, mode: per-vu }
flows:
  default:
    - request:
        method: POST
        url: https://example.com/login
        body: '{"username": "{{ users.username }}", "password": "{{ users.password }}"}'
        extract: { token: { json: $.token } }
    - request:
        url: https://example.com/me
        headers: { Authorization: 'Bearer {{ token }}' }
        checks: { ok: { status: 200 } }

If an extractor finds nothing, the rest of that iteration is skipped and the error is reported.

A request can send a multipart form instead of a body (text fields may hold templates; files are read once, relative to the plan), and compression compresses its body:

    - request:
        method: POST
        url: 'https://example.com/upload'
        multipart:
          owner: 'vu{{ __VU }}'
          report: { file: data/report.pdf, contentType: application/pdf }
        compression: gzip        # or deflate, br, zstd, "gzip, br"

A batch step sends its requests in parallel, like k6's http.batch: at most batch (default 20) at once, and batchPerHost (default 6, 0 for no limit) per host (both also --batch/--batch-per-host and K9_BATCH/K9_BATCH_PER_HOST). Once all have finished, each is recorded, checked and extracted from as usual, so later steps can use what any of them returned. From Python, k9.plan.batch([p.get(...), p.post(...)]).

    - batch:
        - { url: 'https://example.com/api/profile' }
        - { url: 'https://example.com/api/cart', extract: { cart: { json: '$.id' } } }
        - { url: 'https://cdn.example.com/logo.png' }

A websocket step opens a WebSocket and runs its own steps on it, still with no Python: send (templated text), receive (waits up to timeout, default 60s; bodyContains/durationBelow checks and json/regex extractors on the message), ping (waits for the pong), sleep and close. It closes normally at the end. Its checks apply to the handshake (status: 101). A receive that gets nothing (timeout, or the server closed) fails its checks, reports the error and ends that WebSocket; the flow goes on. From Python, k9.plan.websocket(url, [p.ws_send(...), p.ws_receive(...), p.ws_ping(), p.ws_close()]) builds the same.

    - websocket:
        url: 'wss://example.com/chat?token={{ token }}'
        checks: { upgraded: { status: 101 } }
        steps:
          - send: '{"join": "room-{{ __VU }}"}'
          - receive: { timeout: 5s, checks: { joined: { bodyContains: joined } } }
          - ping

A grpc step makes a unary gRPC call. protos (.proto files, relative to the plan, or named within importPaths) define the method; without them, the server's reflection service is asked once before the run. The message is protobuf's JSON mapping, and its strings (like metadata values) may hold templates. Checks are status (the gRPC code: 0 is OK), bodyContains (the response message as JSON) and durationBelow; extractors read the message (json, regex) or its metadata (header, then the trailers). It's recorded in grpc_req_duration. A streaming method takes messages: [...]: they're sent in order, the stream is half-closed and every answer read; checks see the final status, bodyContains and extractors see the answers as a JSON array ($[0].text), and it's recorded in grpc_streams*. From Python, k9.plan.grpc(target, method, message, ...). See examples/grpc.yaml.

    - grpc:
        target: api.example.com:443        # `plaintext: true` for h2c
        protos: [protos/hello.proto]
        method: hello.Greeter/SayHello
        message: { name: 'vu{{ __VU }}' }
        metadata: { authorization: 'Bearer {{ token }}' }
        checks: { ok: { status: 0 } }
        extract: { greeting: { json: '$.message' } }

compression: gzip on a step (or k9.grpc.Client(..., compression="gzip")) compresses the messages sent; answers in gzip are read either way, and k9 tells servers it accepts them. maxSendSize and maxReceiveSize (bytes; the client's max_send_size/max_receive_size) fail a call or stream with RESOURCE_EXHAUSTED when a message is larger, as k6's connect options do; by default there's no limit.

With --agents, a plan's .proto files are compiled on the manager and travel in the plan, so agents don't need them; files a script loads itself (k9.grpc.load) must exist on each agent (or use reflection).

Taken from Goose, a scenario can run a weighted mix of transactions (flows, or functions in a script) in place of exec, with onStart and onStop run once per VU:

options:
  scenarios:
    shop:
      executor: constant-vus
      vus: 50
      duration: 5m
      transactions: { browse: 5, search: 3, checkout: 1 }
      onStart: login
      onStop: logout

Each VU works through shuffled rounds of 9 iterations (5 browse, 3 search, 1 checkout), so the mix is exact, not just right on average. Iteration metrics carry a transaction tag, so a threshold like iteration_duration{transaction:checkout} targets one of them. onStart and onStop aren't iterations. onStop runs when a VU stops gracefully, not when it's interrupted. With onStart, a VU keeps its cookies between iterations, so a login session carries over (without it, cookies are cleared before each iteration, as in k6). In a script, a scenario's functions must all be async def or all plain def.

From Python: k9.plan

A script that defines a module-level plan built with k9.plan is Tier 1. Python only builds the plan, once, when the script loads. The run is then as declarative as a YAML plan (k9 inspect says "tier": "declarative"), so it goes at full Rust speed, and --workers works the same way. The helpers return plain data, and variables, feeder columns and generators are {{ … }} template strings, so f-strings work:

import k9
import k9.plan as p

users = p.feeder("users", file="users.csv", mode="per-vu")  # next to the script
token = p.var("token")

plan = p.Plan(
    [
        p.post(
            k9.env["BASE"] + "/login",
            json={"username": users.username, "password": users.password},
            extract={"token": p.from_json("$.token")},
        ),
        p.get(
            k9.env["BASE"] + "/me",
            headers={"Authorization": f"Bearer {token}"},
            checks={"ok": p.status(200)},
        ),
        p.sleep(1),
    ],
    options={"vus": 10, "duration": "30s"},
    feeders=[users],
)

k9.plan is a separate namespace from Tier 3's k9.http, because its functions describe requests instead of sending them. A script can't mix the two: it defines plan or default(), not both, and Tier 1 has no hooks.