Locust
Locust is an open-source load-testing tool that makes one deliberate bet: a load profile is a program, not a recording. Instead of clicking through a target application while a proxy captures every request into an XML tree, you write a small Python class describing what one simulated user does — log in, browse, add to cart, check out — and how long it waits between actions. Locust then runs as many copies of that class as you ask for, first inside one process and, once one machine stops being enough, across a cluster of worker machines coordinated by a single master. This page covers both halves of that bet: what the locustfile actually looks like as code, and the master/worker architecture that lets it scale from ten users on a laptop to hundreds of thousands spread across a fleet — plus where writing the scenario in code trades off against recording one in a GUI.
Imagine you want to know if a school cafeteria can handle the whole school at lunch, not just one class. You could hire one very fast actor to run through the line over and over pretending to be different kids — that's a single computer politely asking your website for things, one after another. Or you could write down, on one index card, exactly what "a kid at lunch" does — grab a tray, pick two items, wait in line, pay — and then hand copies of that card to hundreds of actual extras, plus a director with a bullhorn who tells them all when to start and counts how long the line actually took. Locust is the card and the director. The card is Python code, so it can say "sometimes grab dessert, sometimes don't" instead of just one fixed script — and if one director can't manage enough extras, you can hire more directors and have them all report back to one head director. That's the master and its workers.
What Locust is and the problem it solves
☺ Like you're 10: Load testing usually means clicking record and hoping the script still works next month — Locust makes you write down, in real code, exactly what a user does and how many of them there are.
A load test needs two things to be trustworthy: a realistic description of what a user does, and enough concurrent, independent copies of that description running at once to actually stress the system rather than politely queue behind each other. Locust answers the first with plain Python. A User class is a template for one simulated visitor — a sequence of HTTP calls decorated as tasks, with a wait_time that governs the pause between them, exactly the way a real person pauses to read a page before clicking the next thing. Because it's ordinary code, a task can branch, loop, parse a response and reuse a value from it three lines later, hit an SLO-derived threshold instead of just an HTTP status code, or read test data from a file — anything Python can do, a load test can do, with no special scripting language to learn and no proprietary file format standing between the scenario and a code reviewer.
Locust answers the second — enough concurrency to matter — with two layers of scale. Inside one process, it uses gevent-based cooperative concurrency: simulated users are lightweight greenlets that yield control while they're blocked waiting on a network response, so a single Python process can hold thousands of "waiting" users at once without needing an OS thread each. Once one process's own CPU becomes the limit, Locust adds a second layer: a master process that coordinates any number of worker processes, potentially on entirely separate machines, splitting the target user count across all of them and aggregating everyone's statistics back into one live picture. That second layer is the part this page spends the most time on, because it's the part that turns "load test on my laptop" into "load test that can actually saturate a production-shaped service."
Locust doesn't know or care what protocol your test uses — HttpUser is the common case, but a User subclass can wrap any client that can report success, failure, and a response time back into Locust's event system. gRPC, Kafka, WebSockets, and even browser automation via Playwright all exist as community patterns built on exactly that seam. What stays constant across every one of them is the same locustfile model, the same master/worker scaling story, and the same live statistics UI.
Where it fits in an SRE's capacity-planning workflow
☺ Like you're 10: This is the tool that turns "we think we can handle Black Friday" into a number you actually measured instead of a number you hoped was true.
Capacity planning is arithmetic — projected traffic, current headroom, time-to-exhaustion — but the inputs to that arithmetic have to come from somewhere, and "somewhere" is usually a load test run against a staging environment or a production canary. Locust generates the synthetic traffic that answers the questions capacity planning actually asks: where does p99 latency start climbing away from its SLO target as concurrency rises, does the autoscaler's HPA trigger fast enough to absorb a step change in demand, and what request rate makes the error rate itself start eating error budget? A production readiness review that asks "what happens at 3x your launch-day estimate" is really asking whether someone has already run that number through a tool like this one and can show the graph.
It also sits usefully next to chaos engineering rather than instead of it. A fault-injection tool like Gremlin, Chaos Monkey, or Litmus proves a system survives a single injected failure at whatever load happens to exist when the experiment runs; a Locust swarm running at the same time proves the system survives that failure under the load it would actually be carrying when the failure was likely to occur — a database failover during a quiet Tuesday night and the same failover during a traffic peak are different experiments with different blast radii, and combining the two tools is how you find that out on purpose instead of during a real incident. The traffic and the failure the system experiences during a genuine outage rarely arrive one at a time; testing them one at a time is the easy version of the exercise.
How it works: the distributed master/worker architecture
☺ Like you're 10: One process asking politely doesn't get you real numbers — Locust's answer is a boss process that hands slices of the crowd out to as many helper processes as you're willing to run.
Every Locust run has exactly one runner in charge of it, and which kind depends on how you started it. A plain locust invocation with no --master or --worker flag uses a LocalRunner: one process both generates load and serves the web UI, fine for tens or low hundreds of simulated users, or for developing a locustfile before you point it at anything that matters. The moment you need more concurrency than one process's own CPU and event loop can honestly deliver, you split those two jobs across processes: one master and any number of workers.
The master runs no significant load itself. It serves the web UI, accepts connections from workers over Locust's own lightweight protocol (ZeroMQ, on port 5557 by default, with a second channel on 5558), tells each worker how many simulated users to spawn and at what ramp-up rate, and continuously merges the response-time and failure statistics every worker reports back a few times a second into one aggregated view. Each worker does the actual work: it loads the same locustfile, spawns its assigned share of the total user count as greenlets, fires real requests at the target, and streams its local stats up to the master. Because the master's own job is coordination and arithmetic rather than firing requests, adding more workers scales the achievable load close to linearly, right up until something other than the master — the network path, the workers' own machines, or the target itself — becomes the limit.
Two operational details fall directly out of that architecture. First, every worker needs the same locustfile and the same Python dependencies as every other worker and the master — Locust doesn't ship your code to workers for you, so real deployments bake the locustfile into a container image (the official locustio/locust image is the common base) and run identical images everywhere, whether that's Docker Compose on a couple of EC2 boxes or a Kubernetes Deployment for the workers behind one master Service, scaled by replica count. Second, because gevent gives you I/O concurrency, not CPU parallelism, and Python's GIL still serializes actual bytecode execution, each Locust process effectively uses one CPU core. A single worker process can comfortably simulate a few thousand simple HTTP users before its own CPU becomes the bottleneck rather than the target's — treat any specific number as a starting point to benchmark for your own tasks, not a guarantee — which means the way to use a sixteen-core box isn't one worker process, it's sixteen worker processes, one per core, all pointed at the same master.
The locustfile: Python as the load profile
☺ Like you're 10: The whole test is an ordinary Python class — no XML, nothing to click through, just a file your normal code review already knows how to read.
A minimal locustfile is a subclass of HttpUser, one or more @task-decorated methods, and a wait_time. self.client is a wrapped requests-style session, pre-pointed at host, that automatically times every call and reports it into Locust's statistics — you don't instrument anything yourself.
# locustfile.py
import random
from locust import HttpUser, task, between
class CheckoutUser(HttpUser):
host = "https://staging.checkout.example.com"
wait_time = between(1, 3) # "think time" between tasks: 1-3s, picked at random per iteration
def on_start(self):
# runs once per simulated user, before its first task — the natural place to log in
resp = self.client.post("/api/login", json={"user": "loadtest", "pass": "..."})
token = resp.json()["token"]
self.client.headers.update({"Authorization": f"Bearer {token}"})
@task(3) # relative weight — this task runs ~3x as often as a weight-1 task
def browse_catalog(self):
item_id = random.randint(1, 5000)
# name= collapses every /api/items/ call into ONE stats row instead of thousands
with self.client.get(f"/api/items/{item_id}", name="/api/items/[id]", catch_response=True) as r:
if r.elapsed.total_seconds() > 1.5:
r.failure(f"too slow: {r.elapsed.total_seconds():.2f}s") # SLO-aware, not just status code
@task(1)
def checkout(self):
self.client.post("/api/cart/checkout", json={"items": [1, 2, 3]}) Three habits separate a locustfile that survives contact with a real system from one that quietly measures nothing useful. First, catch_response=True plus r.failure()/r.success() lets a request fail on a business condition — a 200 with an empty cart, a response slower than your SLO's latency target — not just a non-2xx status, which is the difference between a load test that reports "0% errors" against a service that's actually silently broken and one that doesn't. Second, the name= kwarg matters more than it looks: without it, every distinct URL — one per item_id — becomes its own row in the statistics table, so no single row ever accumulates enough samples for its percentiles to mean anything. Third, on_start runs once per simulated user, which is where session setup — login, grabbing a CSRF token, picking a persistent test account from a pool — belongs, so it doesn't re-run on every single task iteration and skew your request mix.
Two extension points cover most scenarios that a fixed weighted-random task mix can't: constant_pacing(t) as a wait_time holds each user to a fixed iteration duration regardless of how long the task itself took, useful for hitting a target requests-per-second rather than a target concurrency; and a LoadTestShape subclass takes over ramp-up entirely, returning (user_count, spawn_rate) for whatever elapsed time has passed, which is how you script a deliberate step load, a spike test, or a soak test instead of Locust's default flat ramp.
from locust import LoadTestShape
class StepLoadShape(LoadTestShape):
# five-minute steps: 100 -> 500 -> 1000 -> 2000 users, then stop
stages = [
{"duration": 300, "users": 100, "spawn_rate": 10},
{"duration": 600, "users": 500, "spawn_rate": 20},
{"duration": 900, "users": 1000, "spawn_rate": 20},
{"duration": 1200, "users": 2000, "spawn_rate": 20},
]
def tick(self):
run_time = self.get_run_time()
for stage in self.stages:
if run_time < stage["duration"]:
return (stage["users"], stage["spawn_rate"])
return None # None ends the testRunning it: from a laptop to a cluster
☺ Like you're 10: From ten users on your laptop to fifty thousand across a cluster, it's the same handful of flags — you're just aiming them at more machines.
With no flags at all, locust starts the LocalRunner and opens a web UI at localhost:8089, where you set the target user count and spawn rate interactively and watch charts update live. That's the right mode for developing and sanity-checking a new locustfile. CI pipelines and real capacity-test runs almost always use --headless instead, driven entirely from the command line and producing files a pipeline can archive and diff between runs.
# local, interactive — open the web UI and drive it from the browser
$ locust -f locustfile.py
# headless, CI-friendly — no browser needed, everything is a flag
$ locust -f locustfile.py --headless \
-u 500 -r 25 -t 10m \
--csv=results/checkout --html=results/checkout.html
# -u target user count -r spawn rate (users started per second)
# -t run time, then auto-stop --csv/--html machine- and human-readable reports
# distributed: one master, run on its own host/pod
$ locust -f locustfile.py --master --web-host 0.0.0.0
# distributed: N workers, run on their own hosts/pods (or several processes per host, one per core)
$ locust -f locustfile.py --worker --master-host=10.0.4.12
# headless distributed run driven from the master, waiting for a fixed worker count to connect first
$ locust -f locustfile.py --master --headless \
--expect-workers 8 -u 5000 -r 100 -t 15m \
--csv=results/checkoutOn Kubernetes, the standard pattern is a single master Pod behind a Service exposing 8089 and 5557, and a worker Deployment you scale by replica count — community Helm charts and a Locust operator package this into a couple of CRDs or values files if you don't want to hand-write the manifests. In every topology, the master and every worker must run the same Locust version and the same locustfile with the same Python dependencies; a container image built once and deployed as both roles is the simplest way to guarantee that. For raw throughput per worker process, swapping HttpUser for FastHttpUser — backed by geventhttpclient instead of requests — trades a slightly less familiar API for meaningfully higher requests-per-second per core, worth reaching for once a locustfile's logic is stable and you're chasing maximum load rather than readability. For teams that don't want to operate the worker fleet themselves, Locust's maintainers also offer Locust Cloud, a commercial hosted option that runs the distributed swarm for you from the same locustfile; treat its current pricing and limits as something to verify on locust.cloud before budgeting against it.
Gotchas and failure modes
☺ Like you're 10: Most Locust pain isn't the target falling over — it's the thing generating the load quietly running out of itself first.
The load generator becomes the bottleneck, unnoticed
The single most common mistake with any load-testing tool, Locust included, is trusting a throughput number without checking whether the generator had headroom left to produce it. Because each worker process is effectively pinned to one core, a worker pegged at high CPU is no longer honestly simulating think time or measuring real response latency — it's measuring how fast it can get around to sending the next request, which is a different, less interesting number. Locust surfaces this directly: workers report their own CPU usage to the master, and the master logs a warning when a worker's CPU crosses a high-usage threshold. Treat that warning as a hard stop on trusting the run's results, not a note to ignore — add more workers, or fewer users per worker, and rerun.
Blocking calls stall a whole worker's other users
Gevent's concurrency is cooperative: a greenlet has to voluntarily yield — which network I/O does automatically once Locust's networking is monkey-patched — for any other greenlet in that process to make progress. A task that calls a synchronous, non-gevent-aware library (a C extension holding the GIL, a blocking database driver, an accidental time.sleep() outside gevent's patched version) doesn't error; it silently stalls every other simulated user in that same worker process for the duration of the call. The symptom is a throughput number that's mysteriously low with no exceptions in the log anywhere — check for anything in the locustfile that isn't gevent-friendly before assuming the target is the slow one.
OS limits on the generator side
Simulating tens of thousands of concurrent connections from one machine runs into the same limits any high-concurrency client does: open file descriptors (ulimit -n), ephemeral local ports, and connection-tracking tables on anything doing NAT between the workers and the target. These show up as connection errors that look exactly like target-side failures unless you specifically rule out the generator host first — check ulimit -n and raise it before a large run, not after a confusing one.
Version and dependency skew between master and workers
Locust's master/worker protocol assumes both sides are running matching Locust versions with the same locustfile and the same importable dependencies. A worker on an older image, or missing a package the locustfile imports, either fails to connect cleanly or behaves subtly differently from its siblings — a source of "why does worker 3's error rate look different" that has nothing to do with the target. Build one image, deploy it as both roles, and this class of bug mostly disappears.
An unauthenticated web UI is a control plane
By default, locust's web UI on port 8089 has no authentication. Anyone who can reach it can start a new swarm, change the target user count, or point a fresh run at whatever host the locustfile defaults to. If a master is ever reachable from anywhere broader than your own team's network, use --web-auth user:pass (or put it behind your normal SSO-fronted ingress) — treat it with the same seriousness as any other control plane that can generate real load against a real system, because that's exactly what it is.
Code-defined scenarios vs. a GUI-recorded one
☺ Like you're 10: Two totally different ways to build the same test — type it out, or click-record it — and each one breaks in a different place six months later.
Locust's Python-first model is a deliberate stance in a field where the historical default, set by Apache JMeter, was the opposite: point a recording proxy at your browser, click through the real application, and let it capture every request into an editable tree, saved as an XML .jmx file. Both approaches produce a working load test; they trade off in almost every other dimension.
| Tool | Authoring model | Concurrency model | Native distributed story |
|---|---|---|---|
| Locust | Python code (User classes, tasks) | gevent coroutines — I/O-bound concurrency, ~1 core/process | Built-in master/worker, open source; you run the infrastructure |
| JMeter | GUI tree, or recorded via proxy; saved as XML (.jmx); scriptable via Groovy/JSR223 | Thread-per-user — real OS/JVM threads, heavier per-user memory | Built-in remote-testing mode (jmeter-server), older mechanism, still widely used |
| Gatling | Scala/Java/Kotlin DSL code; commercial Enterprise product adds a GUI builder | Async I/O on the JVM — high throughput per core | OSS has no built-in orchestration across machines; Gatling Enterprise (commercial) adds managed distributed injectors |
| k6 | JavaScript code | Go runtime — real parallel goroutines | OSS is single-machine; distributed via commercial k6 Cloud or the k6-operator on Kubernetes |
The case for writing the scenario as code, Locust's whole premise, is the same case for treating infrastructure or configuration as code anywhere else: a Python locustfile is diffable, reviewable in a normal pull request, testable with normal Python tooling, and capable of arbitrary logic — a conditional branch, a loop over a CSV of test accounts, a value parsed out of one response and threaded into the next request — without fighting a GUI's idea of what a "test step" is allowed to look like. Nothing about it locks you into a proprietary format either; a locustfile is a Python file, full stop.
The case for a GUI recorder, JMeter's original pitch and still its biggest advantage for a team without engineers on hand, is speed of first draft and fidelity to what actually happened: point the recorder at a real user session and it captures the exact request sequence — headers, cookies, the works — without anyone having to already know the application's request shape. The costs show up later rather than immediately. A recorded script is brittle the moment any value in a request is dynamic — a session token, a CSRF nonce, an auto-incrementing ID — because the recorder captures the value that happened to be live during recording, not the rule for producing the next one; fixing that requires manual "correlation," extracting the dynamic value from an earlier response and wiring it into the later request, which is precisely the kind of logic that's trivial in three lines of Python and awkward as a chain of GUI post-processors. And a large .jmx tree is a poor artifact for code review: a meaningful change to test logic often produces an XML diff nobody can read at a glance, where the same change in a locustfile is a normal, small pull request.
Reach for a recorder when you don't yet know the shape of the traffic and need a fast first draft, or when the people building tests aren't engineers. Reach for Locust, Gatling, or k6 when the test needs to live in version control next to the service it exercises, needs real logic — correlated tokens, weighted realistic mixes, SLO-aware pass/fail — or needs to scale past what a recorded script can produce on one machine. Many mature teams do both: a recorder or a captured HAR file to bootstrap the request shapes quickly, ported into a maintained, code-reviewed locustfile that's what actually runs in CI from then on.
Whatever generates the traffic, watching it land is a separate job: point Grafana and Prometheus at the target during a run so you can see latency, saturation, and error-rate panels move in real time next to Locust's own statistics, and read queueing theory for SRE for why latency stays flat for a long stretch of rising load and then turns upward sharply near saturation — that inflection point, not the flat part before it, is usually the number a capacity plan actually needs. The capacity-planning capstone and the bottleneck-forecasting drill both put this page's material to direct use.
Benny the Beaver: Wrote the whole checkout load test as a locustfile last night — login, browse, checkout, weighted so browsing happens three times as often as buying. Twenty minutes, no clicking through a recorder.
Sol the Sloth: Good — and did you distribute it, or is one process asking politely?
Benny the Beaver: One master, six workers, one per core on the box. Web UI says nine thousand requests a second.
Foxy: Nine thousand from the workers, or nine thousand the target actually absorbed? Did you check the workers' own CPU before you believed that number?
Benny the Beaver: ...checking now. Worker four's at ninety-four percent. That number's a lie, isn't it.
Ellie the Elephant: While you add a seventh worker, I'll pull up the Grafana dashboard — I want to see the target's own latency panel line up with whatever number Locust reports once it's honest.
Rocky the Raccoon: And once it's honest, page me. I want to unplug the payments dependency while the swarm's running, not on a quiet Tuesday night when nothing's really being tested.
Sol the Sloth: That's the whole point. Slowly, correctly, on real load. Then I'll do the capacity math on numbers that mean something.
1. What does the master process actually do in a distributed Locust run, and what does it deliberately not do? 2. Why is a single Locust worker process effectively limited to about one CPU core's worth of throughput, even though gevent lets it hold thousands of concurrently "waiting" users? 3. What does the name= keyword argument on a request do, and why does forgetting it quietly ruin your statistics? 4. Name two ways a load test can under-report the load it's actually producing. 5. What's the core tradeoff between writing a scenario as Locust/Gatling/k6 code versus recording one in JMeter's GUI?
Check your answers
- The master coordinates: it serves the web UI, tells each worker how many simulated users to spawn and at what rate, and merges every worker's statistics into one aggregated view. It generates no meaningful load of its own — that work happens entirely in the workers.
- Gevent gives I/O-bound concurrency (a greenlet yields while waiting on the network), not CPU parallelism — Python's GIL still serializes actual bytecode execution within one process. So one worker process can hold many "waiting" users at once, but its total request-generation throughput is still bounded by roughly one core's worth of CPU.
- It groups every request against a templated URL (e.g.
/api/items/<id>) into one row in the statistics table under a shared label instead of one row per distinct URL. Without it, each unique ID produces its own row with too few samples for its percentiles to mean anything. - A worker running at high CPU is no longer honestly simulating think time or measuring real latency — Locust itself warns about this via reported worker CPU usage. And OS-level limits on the generator host — exhausted file descriptors or ephemeral ports — produce connection errors that look like target-side failures but are actually the generator running out of itself first.
- Code (Locust/Gatling/k6) is diffable, code-reviewable, and supports arbitrary logic — branching, loops, correlated values pulled from earlier responses, SLO-aware pass/fail — at the cost of needing someone who can write it and having no built-in recorder. A GUI recorder like JMeter's produces a fast, faithful first draft of real traffic without requiring engineers, at the cost of brittleness against dynamic values (manual "correlation" required) and a poorly-reviewable XML artifact as the test grows.