YAML or Go?
What the declarative format can express, where it runs out of road, and how to keep load shape in YAML while behaviour lives in Go.
LoadWave takes tests in two forms. They are not competing options — most mature setups use both, for different parts of the same test.
The short answer
Use YAML when
The test is a sequence of requests where each one only needs values from the ones before it. Most smoke tests, health checks and read-heavy browse flows are exactly this.
Use Go when
The test needs a session, a branch, a retry, a real authentication flow, weighted decisions, or a custom metric. Anything with an `if` in it.
What YAML can do
A YAML scenario is a list of steps. Each step is a request or a pause, values
carry forward through capture, and ${...} templating puts them back into
later URLs, headers, query parameters and bodies.
scenarios:
- name: browse
steps:
- name: list products
get: /api/products
expect: [200]
capture:
productId: items.0.id
- think: 1s-3s
- name: view product
get: /api/products/${productId}
expect: [200]That covers a surprising amount. Combined with built-in variables like
${__vu} — unique across the whole fleet — you can give every simulated user its
own account without writing a line of Go:
- name: login
post: /api/auth/login
json: { username: "loadtest-user-${__vu}", password: "correct-horse-battery-staple" }
capture:
token: data.tokenThe full surface is in the configuration reference.
Where it runs out of road
YAML has no conditionals, no loops, no error handling and no arithmetic. That is a deliberate ceiling, not a gap waiting to be filled — a configuration format that grows an expression language has become a programming language with worse tooling.
You have hit the ceiling when you want to:
- Branch. Do one thing if the cart is empty, another if it is not.
- Hold session state across iterations. Sign in once per user, then browse for
the rest of the run.
capturelives for one iteration; Go state lives for the virtual user's lifetime. - Authenticate with anything but a bearer token. Request signing, token refresh, a challenge-response.
- Decide by weight inside a scenario. One in twenty visitors abandons at payment.
- Emit a custom metric. The value of the basket, the size of a queue.
- Parse a response properly.
capturereads a path out of JSON; it does not decode into a struct you can reason about.
The usual split
Behaviour in Go, where it can be tested and reviewed like any other code. Load shape and pass/fail criteria in YAML, where they can be varied per environment without a rebuild.
A scenario entry with only a name refers to one compiled into the binary:
name: storefront-checkout
baseURL: https://staging.example.com
load:
executor: ramping-vus
stages:
- { duration: 2m, target: 200 }
- { duration: 10m, target: 200 }
- { duration: 2m, target: 0 }
thresholds:
- { metric: http_req_duration, stat: p95, op: "<", value: 800 }
- { metric: cart_value, stat: p50, op: ">", value: 0 }
scenarios:
# Weights set here override the ones declared in Go, so the traffic mix can
# be retuned per environment without rebuilding.
- name: browse
weight: 4
- name: checkout
weight: 1go build -o checkout ./cmd/checkout
./checkout run checkout.yaml --uiThis is the arrangement worth aiming for. The thing that changes often — how much load, for how long, and what counts as passing — stays in a file anyone can edit in a pull request. The thing that changes rarely and needs review stays in Go.
What running Go tests costs you
One thing, and it is worth knowing up front:
Every host in the fleet must run the same binary. The scenarios are compiled
in, so the binary is the test. Ship one build to every load host rather than
installing loadwave on each.
In exchange, it is impossible for one host to be quietly running last week's scenario. The agent advertises its version and the dashboard shows it, so a mismatch is visible immediately.
For a YAML-only test, any loadwave binary of the same version will do.