skip to content

In k6, what exit code does a run return when one of its thresholds is breached?

level: juniorimportance: must knowfreq 72%

answer

  1. the build step reads one integer
  2. pass is zero, breach is not one
  3. thresholds move it, checks do not
  4. exitcodes.ThresholdsHaveFailed

basics

~20 s

k6 exits with code 99 when at least one threshold is breached, and 0 when they all hold. That single integer is the whole machine-readable verdict a CI job reads; the printed summary is for humans.

solid answer

~30 s

A breached threshold makes k6 exit **99** — the `ThresholdsHaveFailed` constant in `errext/exitcodes/codes.go`. It is one code no matter how many metrics breached; the metric names go into the log line and the summary, never into the number. k6 reaches 99 either at the end of the run, when the final threshold pass finds a breach, or part-way through, when a rule configured to stop the test early fires. Either way the run winds down cleanly: `teardown()` still executes, the end-of-test summary still prints, and configured outputs are still flushed. A clean run exits `0`.

code

bash · 10 lines
bash
k6 run script.js
code=$?

if [ "$code" -eq 99 ]; then
  echo "a threshold was breached"
elif [ "$code" -ne 0 ]; then
  echo "k6 did not finish the test; code $code"
fi

exit "$code"

go deeper

for a junior

Memorise the pair: 0 means every threshold held, 99 means at least one did not. Capture it in the shell right after the k6 command, because the next command overwrites it.

for a middle

Be able to say that 99 is one code regardless of how many metrics breached, that the names live in the log and summary, and that teardown and the summary still run before the process exits.

for a senior

Explain both roads to 99 — the final evaluation pass and an early stop mid-run — and why k6 spends a whole table of codes rather than returning 1, so a job can separate a real verdict from a k6 malfunction.

for a principal

The tradeoff to weigh is how much of a k6 run's verdict should live in the exit code at all, given that one integer cannot distinguish a breach k6 measured from a failure the script declared for itself.

## The one number a build step actually reads k6 reports the verdict of a run as a **process exit code**. When a run finishes and at least one threshold has been breached, k6 exits with **99**. When every threshold holds and nothing else goes wrong, it exits **0**. Everything else k6 prints — the progress bars, the end-of-test summary, the `✗` next to a failing rule — is text for a human. The integer is the machine-readable verdict, and a CI job that shells out to `k6 run script.js` sees only that. The constant is `ThresholdsHaveFailed = 99` in `errext/exitcodes/codes.go`. It sits in a block of k6 exit codes that all live in the high nineties and low hundreds precisely so that they cannot be confused with the `1` that a crashed process or a shell error produces. ## Where the 99 is attached k6 reaches 99 down two different roads, and both end at the same number. 1. **At the end of the run.** After the last sample has been flushed, k6 evaluates every threshold one final time. If any breached, it builds an error reading `thresholds on metrics '<names>' have been crossed` and tags it with `exitcodes.ThresholdsHaveFailed`. 2. **Part-way through the run.** A threshold can be configured to stop the test at its first breach (that configuration field belongs to threshold syntax, not to the exit code). When that fires, k6 logs `thresholds on metrics '<names>' were crossed; at least one has abortOnFail enabled, stopping test prematurely` and aborts the run — still with exit code 99. So the code does not tell you *when* the breach happened, only that one did. ## What still happens when a threshold breaches A breach is a verdict, not a crash. k6 winds the run down in an orderly way: - **`teardown()` still runs.** k6's own tests assert that `teardown() called` appears in stdout on a threshold-aborted run. - **The end-of-test summary is still produced**, with a `✗` beside each breached rule and a `✓` beside each rule that held. - **Configured outputs are still flushed** before the process exits, so a streamed result set is not truncated by the breach. - **The error message names the breached metrics**, sorted, so the log tells you which rules failed even though the exit code cannot. ## What the number does not encode | you might expect | what k6 actually does | |---|---| | a different code per failing metric | one code, `99`, however many metrics breached | | a count of breaches in the code | the count appears in the log line, never in the code | | exit `1` like a generic tool failure | `1` is not in k6's exit-code table at all | | a failing `check()` to change the code | checks record a result; they do not move the exit code | That last row is the misconception that costs teams the most time. A run can print a page of failed checks and still exit `0`, because a check's result only feeds a metric. Only a threshold turns a measurement into a verdict, and only a threshold produces 99. ## Reading it from a CI job In a shell, the code lands in `$?` immediately after the command, and it is destroyed by the very next command — so it must be captured on the line after `k6 run`, not later: ```bash k6 run script.js code=$? ``` From there the job can distinguish a *verdict* from a *malfunction*. `99` means k6 ran your test to completion and your own pass rules rejected the result. Other non-zero codes mean k6 never got that far, or hit something else: `104` for a rejected configuration, `107` for an exception thrown by the script, `108` for a script that aborted itself, `110` for a script that marked the run failed. Those distinctions are why k6 spends a whole table of codes on outcomes another tool would collapse into `1`. ## In k6 v2 The exit-code table is unchanged by the v2 major line: `99` has meant "thresholds have failed" since k6 v0.33.0, when it was moved off the cloud-failure meaning it previously carried. A comment in `codes.go` still records that history next to the `CloudTestRunFailed = 97` entry, so if you find old material claiming 99 means a cloud test failed, it is describing a k6 older than v0.33.

  • Does k6 still print its end-of-test summary and run teardown() when a threshold breaches?
    Yes. A breach is a verdict, not a crash. k6 finishes the run, executes `teardown()`, flushes configured outputs and prints the summary with a cross beside each breached rule, and only then exits 99. k6's own test suite asserts that `teardown() called` appears in stdout even for a run stopped early by a threshold.
  • If three different metrics breach in the same k6 run, does the exit code change?
    No. It is 99 whether one metric breached or twenty. The count and the sorted list of metric names go into the error message — `thresholds on metrics 'a, b, c' have been crossed` — and into the summary. The exit code carries only the fact that the verdict was a failure.
  • Why does k6 use 99 rather than the conventional exit code 1?
    So a build step can tell a *verdict* from a *malfunction*. k6 reserves a block of codes in the high nineties and low hundreds for its own outcomes — 99 thresholds, 104 invalid config, 107 script exception, 108 self-abort, 110 marked failed. A bare `1` would collapse all of those into one indistinguishable failure.

saying these in an interview costs you the question

  • Says a failed check() makes k6 exit non-zero
  • Expects exit code 1 for a breached threshold
  • Thinks a breach kills the run before teardown() runs
  • Believes the exit code encodes which metric failed
  • Greps the summary text instead of reading the exit code