Lightweight, single-binary HTTP(S) load generator written in Go — built to be driven by humans and AI agents alike.
Why it's agent-friendly
- Zero-config one-shot mode:
hammer -url https://api/health -rps 50 -duration 10s— no profile file to author first - Machine-readable output:
-output jsonwrites the full report to stdout (logs stay on stderr), so it pipes straight intojq - SLO assertions + meaningful exit codes:
-max-error-rate,-max-p95,-max-p99turn a load test into a pass/fail check an agent can branch on (exit1= SLO violated) - Reads a profile from stdin:
… | hammer -profile -— generate traffic mixes on the fly without temp files - Quiet by default for automation:
-quietsilences the live monitor; the live HTTP/statsport is opt-in (no surprise port binds in CI/sandboxes) - Self-describing:
hammer -hdocuments every flag, the exit codes, and copy-pasteable examples
Core engine
- Constant-rate: drives a configurable RPS using a tick loop
- Weighted traffic mix: define multiple endpoints with relative weights in a JSON file
- Per-call headers: set
Authorization, API keys, tracing headers, … per call - URL / body templating:
{{ uuid }},{{ randInt 1000000 }},{{ pickOne "us" "eu" }}to avoid cache-hit skew - Live monitor: per-second
SendPS / ReceivePS / AvgRT / Pending / Err% / Slow%log line - Final report: latency percentiles + per–status-code histogram + network-error categories
- Structured JSON report: to stdout (
-output json) or a file (-json-out) for CI / baseline diffing - TTY-aware color: ANSI color in interactive terminals; byte-stable plain text for pipes, redirects, JSON, CI, and tests
- Optional HTTP stats endpoint:
GET /statswhile running (-stats-addr) - Graceful shutdown: SIGINT/SIGTERM or
-duration - Per-request timeout: stops slow servers from piling up goroutines
Install the latest GitHub release as a hammer command:
curl -fsSL https://github.com/chenchaoyi/hammer/releases/latest/download/install.sh | sh
hammer -versionThe installer detects macOS/Linux and amd64/arm64, verifies the downloaded
archive against SHA256SUMS, and installs to /usr/local/bin when writable
or $HOME/.local/bin otherwise. To choose a directory:
curl -fsSL https://github.com/chenchaoyi/hammer/releases/latest/download/install.sh | HAMMER_INSTALL_DIR="$HOME/bin" shIf GitHub release downloads are unstable from your network, force the mirror ladder and skip the direct GitHub attempt:
curl -fsSL https://github.com/chenchaoyi/hammer/releases/latest/download/install.sh | HAMMER_INSTALL_MIRROR=ghproxy shHAMMER_INSTALL_MIRROR accepts auto (default: GitHub first, then mirrors),
github (canonical GitHub only), ghproxy (mirror chain only), or a custom
https://proxy.example/ prefix that serves <proxy><github-url>.
Each download attempt has a bounded timeout, and the installer rejects invalid
mirror responses by checking that archives are gzip streams and SHA256SUMS
contains the expected 64-hex entry before installing anything.
If downloading install.sh itself is blocked, bootstrap through the same mirror
style first:
curl -fsSL https://ghfast.top/https://github.com/chenchaoyi/hammer/releases/latest/download/install.sh | HAMMER_INSTALL_MIRROR=ghproxy shGrab the archive for your platform from the latest GitHub release:
# macOS (Apple Silicon)
curl -L https://github.com/chenchaoyi/hammer/releases/latest/download/hammer-darwin-arm64.tar.gz | tar xz
./hammer -version
# Linux x86_64
curl -L https://github.com/chenchaoyi/hammer/releases/latest/download/hammer-linux-amd64.tar.gz | tar xz
./hammer -versionBinaries are statically linked (no libc dependency), ~7-8 MB, available for:
linux/amd64,linux/arm64darwin/amd64,darwin/arm64windows/amd64
Each release also includes a SHA256SUMS file:
curl -LO https://github.com/chenchaoyi/hammer/releases/latest/download/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missingRequires Go 1.24+.
git clone https://github.com/chenchaoyi/hammer
cd hammer
go build -o hammer .Cross-compile, e.g. for Linux:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o hammer.linux .Once installed, hammer can update itself in place:
hammer update # download the latest release and replace this binary
hammer update -check # only report whether a newer version is available
hammer update -version v1.2.0 # install a specific release
hammer update -y # skip the confirmation prompt (for scripts/CI)update resolves the target release from the GitHub API, downloads the archive
for your platform, verifies it against SHA256SUMS, and atomically swaps the
running binary. It honors the same mirror controls as the installer — pass
-mirror ghproxy (or set HAMMER_INSTALL_MIRROR) when GitHub is unreachable,
and HAMMER_REPO to update from a fork. If the binary lives in a directory you
can't write to (e.g. /usr/local/bin), re-run with sudo.
Zero-config — hammer a single URL for 10 seconds at 50 rps (no profile file needed):
./hammer -url https://httpbin.org/get -rps 50 -duration 10sOr drive a weighted traffic mix from a profile file:
./hammer -profile profiles/httpbin.json -rps 50 -duration 10sExpected output:
2026/05/16 11:00:00 Hammering @ 50 rps for 10s (timeout=30s, target=profiles/httpbin.json)
2026/05/16 11:00:01 SendPS: 50 ReceivePS: 49 AvgRT: 0.1820s Pending: 1 Err: 0|0.00% Slow: 0.00%
...
2026/05/16 11:00:10 Stopping...
=== Summary ===
Sent: 500
Received: 498
Errors: 0
Slow: 0 (> 1s)
Status codes:
200: 498
Latency (ms): min=140.12 mean=182.04 p50=180.34 p90=220.55 p95=240.07 p99=310.82 max=410.43
--- Per call ---
API: GET https://httpbin.org/get
Total Call: 332
Avg RT: 0.1830s
+++++++
API: POST https://httpbin.org/post
Total Call: 166
Avg RT: 0.1815s
+++++++
If the target returns mixed statuses or network errors, the summary breaks them down:
Status codes:
200: 412
404: 78
500: 8
Network errors:
timeout: 2
conn: 1
Hammer is designed so an autonomous agent can run a load test and act on the result with no files and no scraping of human text. The recommended recipe:
hammer -url https://api.example.com/health \
-rps 100 -duration 30s \
-output json -quiet \
-max-error-rate 0.01 -max-p99 500ms-output jsonprints the entire report to stdout as one JSON object; all progress/diagnostic logging goes to stderr, so stdout is always clean to parse.-quietremoves the per-second monitor noise.-max-*flags assert SLOs. If any is violated the process exits non-zero, so the agent can branch on the exit code alone — or read.checksin the JSON:
hammer -url https://api.example.com/ -duration 30s -output json -quiet \
-max-p99 500ms -max-error-rate 0.01 | jq '.checks'{
"passed": false,
"results": [
{ "name": "error_rate", "limit": 0.01, "actual": 0.0, "unit": "", "ok": true },
{ "name": "p99_ms", "limit": 500, "actual": 612.3, "unit": "ms", "ok": false }
]
}Exit codes (stable; safe to branch on):
| Code | Meaning |
|---|---|
0 |
Run completed; all configured SLO checks passed (or none set) |
1 |
Run completed but one or more SLO thresholds were violated |
2 |
Usage / argument error (bad flags, missing target, …) |
3 |
Setup error (couldn't load the profile, build the client, …) |
Without any -max-* flag, hammer never exits non-zero just because the target
returned errors — it only fails on usage/setup problems. Opt into pass/fail with
the SLO flags.
| Flag | Default | Description |
|---|---|---|
-url |
"" |
Single target URL (zero-config mode). Templating works in the URL. |
-profile |
"" |
Path to a traffic profile JSON file, or - to read the profile from stdin |
-url mode also accepts these (ignored in -profile mode):
| Flag | Default | Description |
|---|---|---|
-method |
GET |
HTTP method for -url mode |
-body |
"" |
Request body for -url mode (templating supported) |
-content-type |
"" |
Content-Type for -url mode: REST, WWW, or a raw value |
-header |
(none) | Header as 'Key: Value'; repeatable |
| Flag | Default | Description |
|---|---|---|
-rps |
100 |
Target requests per second |
-duration |
0 |
Total run time (e.g. 30s, 5m); 0 runs until Ctrl+C |
-timeout |
30s |
Per-request HTTP timeout |
-slow |
1s |
Log + count responses slower than this threshold |
-output |
text |
Final report format: text (human) or json (machine-readable, to stdout) |
-quiet |
false |
Suppress the per-second progress monitor and info logs |
-json-out |
"" |
Also write the structured JSON report to this file path |
-ok |
"" |
Extra status codes treated as success (e.g. -ok "404,409"). 2xx and 3xx are always OK. |
-stats-addr |
"" |
Address for the live /stats HTTP endpoint; empty (default) disables it |
-proxy |
"" |
HTTP proxy URL, e.g. http://127.0.0.1:8888 |
-insecure |
false |
Skip TLS certificate verification |
-debug |
false |
Verbose request/response logging (one log line per request) |
-version |
false |
Print version (set via -ldflags="-X main.version=...") and exit |
| Flag | Default | Description |
|---|---|---|
-max-error-rate |
-1 |
Fail (exit 1) if the error rate exceeds this fraction [0,1]; -1 disables. 0 means "no errors allowed". |
-max-p50 |
0 |
Fail if p50 latency exceeds this duration (e.g. 100ms); 0 disables |
-max-p95 |
0 |
Fail if p95 latency exceeds this duration; 0 disables |
-max-p99 |
0 |
Fail if p99 latency exceeds this duration; 0 disables |
Run ./hammer -h for the live list.
Note: the live
/statsendpoint is now off by default (was:9001). Enable it with-stats-addr :9001when you want it. This keeps automated runs from binding a port they didn't ask for.
For anything more than a single endpoint, use a profile. A profile is a stream
of JSON Call objects (no enclosing array — just concatenate them, whitespace
between objects is fine). Pass it with -profile FILE, or stream it on stdin
with -profile -.
{
"Weight": 40,
"Method": "GET",
"URL": "https://httpbin.org/get",
"Body": "",
"Headers": {
"Authorization": "Bearer your-token",
"X-Trace-Id": "hammer-load-test"
}
}
{
"Weight": 20,
"Method": "POST",
"URL": "https://httpbin.org/post",
"Body": "{\"test\":\"hammer\"}",
"Type": "REST"
}| Field | Required | Description |
|---|---|---|
Weight |
yes | Positive float; selection probability is Weight / sum(Weight) |
Method |
yes | HTTP method (GET, POST, PUT, PATCH, DELETE, …) |
URL |
yes | Full request URL including scheme |
Body |
no | Request body string (used for POST/PUT/PATCH) |
Type |
no | Content-Type hint for write methods (see below) |
Headers |
no | Map of HTTP headers sent with this call; overrides the Type-inferred Content-Type if specified |
URL and Body fields are run through Go's text/template package on every request, so you can inject random or per-request values to avoid hitting the same cache key every time.
{
"Weight": 1,
"Method": "POST",
"URL": "https://api.example.com/v1/users/{{ randInt 1000000 }}",
"Body": "{\"id\":\"{{ uuid }}\",\"region\":\"{{ pickOne \"us\" \"eu\" \"ap\" }}\",\"score\":{{ randIntRange 0 100 }}}",
"Type": "REST"
}Functions available inside {{ }}:
| Function | Returns |
|---|---|
uuid |
A random UUID v4 string |
randInt N |
A random int in [0, N) |
randIntRange LO HI |
A random int in [LO, HI) |
randString N |
A random alphanumeric string of length N |
pickOne A B C ... |
One of the provided string arguments, chosen at random |
now |
Current Unix time in seconds (int64) |
nowNano |
Current Unix time in nanoseconds (int64) |
Templates are compiled once when the profile is loaded; if a field has no {{ it's used as a plain string with zero per-request overhead. Bad templates fail loudly at load time, not at request time.
Type values:
"REST"→Content-Type: application/json; charset=utf-8"WWW"→Content-Type: application/x-www-form-urlencoded- any other non-empty value is used as the Content-Type directly (e.g.
"application/xml") - omitted / empty → no Content-Type header is set
The live /stats endpoint is opt-in. Start it by passing an address:
./hammer -url https://httpbin.org/get -duration 1m -stats-addr :9001 &
curl http://localhost:9001/statsIt stays off by default so automated/CI runs never bind a port they didn't ask for.
Two independent ways to get the structured report:
-output jsonwrites it to stdout (ideal for agents/pipelines; logs go to stderr)-json-out report.jsonwrites it to a file (handy for archiving a CI artifact)
# stdout, straight into jq
./hammer -url https://httpbin.org/get -rps 100 -duration 1m -output json -quiet | jq .
# or to a file
./hammer -profile profiles/httpbin.json -rps 100 -duration 1m -json-out report.jsonShape:
{
"start_time": "2026-05-16T10:00:00Z",
"end_time": "2026-05-16T10:01:00Z",
"duration_sec": 60.0,
"target_rps": 100,
"achieved_rps": 99.9,
"profile": "https://httpbin.org/get",
"sent": 6000, "received": 5994, "errors": 6, "canceled": 0, "slow": 0,
"error_rate": 0.001,
"slow_threshold_sec": 1,
"status_codes": [{"code": 200, "count": 5994}, {"code": 500, "count": 6}],
"network_errors": [],
"latency_ms": {
"samples": 5994,
"min": 140.12, "mean": 182.04,
"p50": 180.34, "p90": 220.55, "p95": 240.07, "p99": 310.82,
"max": 410.43
},
"per_call": [
{"method": "GET", "url": "https://httpbin.org/get", "count": 4002, "avg_rt_sec": 0.183},
{"method": "POST", "url": "https://httpbin.org/post", "count": 1992, "avg_rt_sec": 0.181}
],
"checks": {
"passed": true,
"results": [
{"name": "error_rate", "limit": 0.01, "actual": 0.001, "unit": "", "ok": true},
{"name": "p99_ms", "limit": 500, "actual": 310.82, "unit": "ms", "ok": true}
]
}
}achieved_rps, error_rate, and checks are computed for you so consumers don't
have to. checks is present only when at least one -max-* flag is set.
The cleanest way to gate CI/an agent on a regression is to let hammer do the
asserting and check the exit code — no jq math required:
./hammer -url https://api.example.com/ -rps 200 -duration 30s \
-max-p99 500ms -max-error-rate 0 -quiet || echo "SLO breach!"Prefer to assert yourself? Pipe the JSON through jq:
./hammer -url https://api.example.com/ -rps 200 -duration 30s -output json -quiet \
| jq -e '.latency_ms.p99 < 500 and .errors == 0'- Success vs. error: 2xx and 3xx responses are successes. Anything else (including 4xx, 5xx, and network failures) is an error. Use
-ok "404,409"to whitelist additional status codes as success. - Terminal color: live progress and text reports use ANSI color only when the relevant stream is an interactive TTY.
NO_COLOR,TERM=dumb, pipes, redirects,-output json, and captured test output stay plain. - Network-error categories reported in the summary:
timeout– the per-request-timeoutexceededcanceled– request was in-flight when the run ended (Ctrl+C or-duration); not counted inErrorsconn– TCP dial / read / write failure (e.g. connection refused, reset)dns– name resolution failuretls– TLS handshake / certificate validation failureother– anything else (e.g. malformed URL)
Pendingin the live log = sent − received − errors − canceled. Steady non-zero values mean the target can't keep up with the requested RPS.-timeoutcaps each individual request. When the target slows down, prefer a low timeout to keep goroutine count bounded.-rpsis enforced by a fixed-interval ticker (1s / rps). Effective RPS is bounded by how fast the target can respond and how many file descriptors are available.
A minimal HTTP server for local benchmarking lives in cmd/testserver:
go run ./cmd/testserver # listens on :9000Endpoints: /hello, /hello_in_json.
go test ./... # run all tests
go test -race ./... # race detector
go test -cover ./... # coverage report
go vet ./...The .github/workflows/release.yml workflow builds cross-platform binaries and uploads them to a GitHub release whenever a v* tag is pushed:
git tag -a v1.0.0 -m "v1.0.0"
git push origin v1.0.0The workflow runs the test suite first, then builds for linux/{amd64,arm64},
darwin/{amd64,arm64}, and windows/amd64. Unix archives contain a binary
named hammer; the Windows zip contains hammer.exe. The release also includes
install.sh and SHA256SUMS, so the one-line curl installer works from the
GitHub release page without reading files from the branch tip.
hammer.go # CLI + load generator
color.go # TTY-gated ANSI color helpers
install.sh # GitHub release installer with mirror fallback
profile/ # traffic-profile parser
profiles/ # example profile files
assets/ # README logo and brand assets
cmd/testserver/ # tiny local HTTP target for development
Apache 2.0 — see LICENSE.