Module 1: Performance Mindset & Benchmarking
`locust`: load testing with Python scenarios
Capsule description
wrk is perfect when each request is independent — one GET after another against the same endpoint or a rotating set. But the real world has flows:
A user opens the catalog → searches for a book → views the detail → adds it to the cart → proceeds to checkout → confirms the order.
Each step depends on the previous one (you need the book_id from step 2 for step 3, the cart_id from step 4 for step 5). You have to parse JSON, keep session cookies, take realistic pauses between requests. wrk doesn't do that.
locust does. It's a load testing tool written in Python where your scenarios are Python code — you define classes, methods, conditional logic, whatever you need. You can simulate virtual users that behave differently, scale to thousands of them, and watch the behavior in a real-time web UI.
In this capsule you'll install locust, write your first locustfile.py, run it in headless mode (no UI, for CI/automation) and with the UI (for interactive exploration). You'll learn to structure multi-step scenarios and read the stats it reports. By the end, you'll have the third and final tool in your kit complete.
When to use locust vs wrk vs pgbench
The three tools don't compete — they complement each other. Each answers a different question.
| Question | Tool |
|---|---|
| "How fast does PostgreSQL run in isolation on this hardware?" | pgbench |
| "What percentiles does this HTTP endpoint have under load?" | wrk |
| "How does the system behave when 500 users follow a full flow (login → search → buy)?" | locust |
wrk vs locust — the most common decision
| Feature | wrk | locust |
|---|---|---|
| Language | C (config + Lua for custom) | Python (config and scenarios) |
| Efficiency (RPS per core) | Very high | Moderate |
| Minimum setup | One line of bash | A locustfile.py file |
| Simple stateless scenarios | ✅ Excellent | ✅ Good |
| Multi-step scenarios with dependencies | ❌ Very hard | ✅ Excellent |
| Real-time UI | ❌ None | ✅ Yes (web UI) |
| Distribution across machines | Manual | ✅ Built-in (master/worker) |
| Parse JSON, handle cookies | ❌ Lua is limited | ✅ Trivial with a requests-like API |
| Learn it in 5 minutes | ✅ | ⚠️ 30-60 minutes |
Informal rule:
- If your test is "GET / with concurrency X" →
wrk. - If your test has state (session, cart, rotating JWTs) or logic (parse a response to build the next request) →
locust.
Installation
locust installs with pip. Recommendation: in a virtual environment.
mkdir locust-demo
cd locust-demo
python -m venv venv
source venv/bin/activate
pip install locust
# Verify
locust --version
# locust 2.27.0 ...
Your first locustfile.py
Locust looks for a file called locustfile.py in the current directory by convention (you can point it elsewhere with -f).
Hello world: hitting an endpoint with a single @task
# locustfile.py
from locust import HttpUser, task, between
class WebsiteUser(HttpUser):
"""Simulates a user who only GETs /fast."""
# Waits between 1 and 3 seconds between requests from the same user
# (simulates a real human's "think time")
wait_time = between(1, 3)
@task
def hit_fast(self):
self.client.get("/fast")
Anatomy:
HttpUser: the base class. Each instance simulates a "virtual user" with its own HTTP session (cookies, persistent headers).wait_time = between(1, 3): wait time between consecutive tasks from the same user. It simulates the fact that a human doesn't send 1,000 requests per second from the same browser.@task: marks methods thatlocustwill run when the user "acts".self.client: an HTTP session (based onrequests). It keeps cookies between calls.
Running in UI mode (interactive web)
# Assuming your API is on localhost:8000
locust -H http://localhost:8000
# Output:
# [2026-05-15 14:23:01,234] hostname/INFO/locust.main: Starting Locust 2.27.0
# [2026-05-15 14:23:01,235] hostname/INFO/locust.main: Starting web interface at http://0.0.0.0:8089
Open http://localhost:8089 in the browser. You'll see a form:
- Number of users: how many virtual users to simulate in total.
- Spawn rate: how many users to create per second (ramp-up).
- Host: the base URL (already filled in if you passed
-H).
Enter, for example, 100 users / spawn rate 10 / start swarming. You'll see:
- Statistics: RPS, latency (including percentiles), error rate, a table per endpoint.
- Charts: real-time graphs of RPS, response time, number of users.
- Failures: detailed HTTP errors.
- Download Data: CSVs with all the numbers.
Running in headless mode (no UI, for CI / automation)
# 100 users, spawn rate 10/s, for 60 seconds, no UI
locust -H http://localhost:8000 \
--users 100 \
--spawn-rate 10 \
--run-time 60s \
--headless
Expected output when it finishes:
Type Name # reqs # fails | Avg Min Max Median | req/s failures/s
--------|----------------------------|--------|---------|---------|--------|--------|--------|--------|----------
GET /fast 5240 0 | 4 1 82 3 | 87.30 0.00
--------|----------------------------|--------|---------|---------|--------|--------|--------|--------|----------
Aggregated 5240 0 | 4 1 82 3 | 87.30 0.00
Response time percentiles (approximated)
Type Name 50% 66% 75% 80% 90% 95% 98% 99% 99.9% 99.99% 100% # reqs
--------|------------------------------|--------|------|------|------|------|------|------|------|------|------|------|------
GET /fast 3 4 5 5 6 8 12 18 45 72 82 5240
--------|------------------------------|--------|------|------|------|------|------|------|------|------|------|------|------
Aggregated 3 4 5 5 6 8 12 18 45 72 82 5240
Reading it: you already have p50/p95/p99 reported directly. Total throughput. Errors. Per endpoint and aggregated. All in a single command.
Multi-step scenarios: the real case where locust shines
Now the case that justifies using locust. You'll simulate a user who follows this flow:
- Lists the books (
GET /books). - Picks a random one from the response and requests its detail (
GET /books/<id>). - (Optional) Adds it to a cart (
POST /cart).
# locustfile.py
import random
from locust import HttpUser, task, between
class BookstoreUser(HttpUser):
wait_time = between(1, 3)
def on_start(self):
"""Runs once when the virtual user starts up."""
# Example: if you needed a login, you'd put it here:
# self.client.post("/login", json={"user": "x", "pass": "y"})
# self.client.cookies persists for the following requests.
pass
@task(3) # weight 3 — happens 3x more often than the others
def list_and_view_book(self):
# Step 1: list
with self.client.get("/books", catch_response=True) as resp:
if resp.status_code != 200:
resp.failure(f"List books failed: {resp.status_code}")
return
books = resp.json()
if not books:
resp.failure("No books returned")
return
# Step 2: pick a random one and request its detail
book = random.choice(books)
self.client.get(f"/books/{book['id']}", name="/books/<id>")
# 'name' groups all the detail requests under the same label
# in the stats (without it, each id would be a separate entry).
@task(1) # weight 1 — happens once for every 3 of the previous task
def add_to_cart(self):
# Assumes the API has POST /cart with a hardcoded book to keep it simple
self.client.post("/cart", json={"book_id": 42, "qty": 1})
New concepts
| Concept | What it's for |
|---|---|
on_start | A hook that runs once when the virtual user is born. Useful for login, setup. |
@task(N) | Weight. If you have @task(3) and @task(1), the first happens 3x more than the second. |
catch_response=True | Lets you manually mark the request as success() or failure(). Useful when a 200 can be a logical failure (e.g.: an empty response). |
name="..." | Groups requests with variable paths (/books/1, /books/2, etc.) under a single name in the stats. |
Why name= matters so much
Without name, the stats show you:
GET /books/1 ... 50 reqs
GET /books/47 ... 45 reqs
GET /books/123 ... 38 reqs
... (hundreds of rows)
With name="/books/<id>":
GET /books/<id> ... 4,500 reqs
The second form is the one you want for readable reports.
Distribution across machines (master/worker)
A single locust instance can generate thousands of RPS, but there's a limit. For large loads, locust supports running multiple workers distributing the load:
# On the "master" machine
locust -H http://target.example.com --master
# On each "worker" machine (you can run several)
locust -H http://target.example.com --worker --master-host=master.local
The master coordinates, the workers generate the load. For typical local baselines you don't need it, but knowing it exists helps when you scale.
Worked example: the Bookstore's preliminary baseline (preview)
In capsule 08 you'll run the Bookstore API's real baseline. Here we prepare a locustfile.py you'll be able to reuse.
Minimal setup
Assuming the app exposes:
GET /books(list, optional filter by?author=)GET /books/{id}(detail)GET /orders?page=N(paginated list)
# bench/locustfile.py
import random
from locust import HttpUser, task, between
# A small list to rotate authors (avoids an artificial cache hit)
AUTHORS = ["tolkien", "asimov", "le_guin", "herbert", "orwell"]
class BookstoreReader(HttpUser):
"""Read-heavy user: lists, views details, browses pages."""
wait_time = between(1, 3)
weight = 3 # 3x more common than the other user type
@task(5)
def list_all_books(self):
self.client.get("/books", name="/books")
@task(3)
def list_books_by_author(self):
author = random.choice(AUTHORS)
self.client.get(
f"/books?author={author}",
name="/books?author=<x>"
)
@task(2)
def view_book_detail(self):
# Assumes IDs from 1 to 100 for this exercise
book_id = random.randint(1, 100)
self.client.get(f"/books/{book_id}", name="/books/<id>")
class BookstoreNavigator(HttpUser):
"""User who browses pages (including deep pages — the large OFFSET case)."""
wait_time = between(2, 5)
weight = 1 # less common than the reader
@task(3)
def first_page_orders(self):
self.client.get("/orders?page=1", name="/orders?page=<low>")
@task(1)
def deep_page_orders(self):
# Deep pages — this is going to hurt with a large OFFSET
page = random.randint(500, 2000)
self.client.get(
f"/orders?page={page}",
name="/orders?page=<deep>"
)
Running headless with the script
# Bookstore running on localhost:8000
locust -H http://localhost:8000 \
-f bench/locustfile.py \
--users 50 \
--spawn-rate 5 \
--run-time 60s \
--headless \
--csv reports/baseline
--csv reports/baseline generates three CSV files (baseline_stats.csv, baseline_failures.csv, baseline_history.csv) that you can version-control as evidence of the baseline.
How to read the stats
locust's output (in the terminal or the UI) has three main tables.
Table 1: stats per endpoint
Type Name # reqs # fails | Median Avg Min Max
GET /books 3450 0 | 50 65 8 420
GET /books?author=<x> 2200 12 | 320 580 45 3420
GET /books/<id> 1500 0 | 35 48 5 180
GET /orders?page=<low> 720 0 | 85 98 22 310
GET /orders?page=<deep> 240 45 | 4200 4850 2100 12400
What you look at:
# reqs: how many requests your test processed for that endpoint. Enough to trust the percentiles (ideally: >1,000).# fails: how many failed (status >= 400 or marked withfailure()). If it's high, the rest's percentiles may be affected.Median: the p50.Avg: the average (with its known problems — see capsule 02).Max: the worst case (may be a single outlier).
Table 2: approximate percentiles
Type Name 50% 75% 90% 95% 99% 99.9% 100%
GET /books 50 70 105 140 280 410 420
GET /books?author=<x> 320 480 980 1520 3100 3380 3420
GET /books/<id> 35 45 70 95 160 180 180
GET /orders?page=<low> 85 110 180 240 295 310 310
GET /orders?page=<deep> 4200 5500 8400 10100 12100 12380 12400
This is where the golden information lives. Per endpoint, the percentiles you'll put in BENCHMARKS.md:
/books: p95=140, p99=280. Decently tight./books?author=<x>: p95=1,520, p99=3,100. A suspicious bimodal distribution (the p99-p50 delta is ~10x)./orders?page=<deep>: p95=10,100, p99=12,100. These horrible numbers confirm the large OFFSET problem we'll see in module 8.
Table 3: failures (if there are any)
# Failures
12 GET /books?author=tolkien ConnectionError: ...
45 GET /orders?page=1843 HTTPError: 504 Gateway Timeout
If you see failures, your baseline isn't valid until you understand why. Some cases:
- Persistent 5xx → the app is failing in some cases, there's a bug.
- Timeouts → the app responds but slower than the timeout configured in
locust(raise--connection-timeout). - Connection errors → the app is saturated and rejecting connections (you found maximum capacity).
Visual comparison with wrk (equivalent output)
The same test (50 connections, 60s, GET /fast) in both:
| Aspect | wrk | locust |
|---|---|---|
| Setup time | 0 seconds (1 line of bash) | ~5 minutes (writing locustfile.py) |
| Percentile output | Yes, with --latency | Yes, automatic |
| Multi-step scenarios | Very hard (Lua) | Trivial (Python) |
| Real-time UI | No | Yes |
| Reproducibility in CI | Excellent | Excellent (with --headless) |
| Max throughput from one machine | ~50k RPS | ~5-10k RPS typically |
When NOT to use locust:
- If the test is "1 endpoint, GET, stateless" →
wrkis 5x faster to spin up. - If you need to generate more than ~10k RPS from a single machine.
When to DO use locust (most "realistic" tests):
- Multiple user types.
- Multi-step flows.
- You need to parse JSON or handle cookies.
- You want a UI to explore.
Why does this matter in real work?
1. Realistic capacity planning tests.
"Can we handle Black Friday traffic?". With wrk you only hit one endpoint. With locust you simulate real user behavior — some browse, some add to the cart, some check out. That's what separates a "technical" test from a "business" test.
2. Reproducing concurrency bugs.
Sometimes an endpoint fails only when it's called after another one (race condition, transaction problem). wrk doesn't reproduce that. locust with a @task that follows the exact flow does.
3. Tests integrated into CI.
A mature pattern: on every PR, run a short locustfile.py against the staging deploy. If the regression is >X%, block the merge. Teams that do this: Spotify, Mozilla, several large open source projects (it's Locust's original use case).
Traps and common mistakes
Mistake 1 (conceptual): thinking "100 users" means 100 RPS
Symptom: you run with --users 100 and expect ~100 RPS. You actually see 30 RPS or 200 RPS, depending on the endpoint.
Why it happens: --users is the number of concurrent virtual users, not RPS. Each virtual user makes a request, waits (wait_time), makes another. If the endpoint takes 100ms and wait_time is between 1-3s, each user does ~0.3-1 RPS. With 100 users → ~30-100 RPS.
How to fix it: if you want to control exact RPS, set wait_time = constant(1) and compute: RPS = users / (wait_time + endpoint_latency). Or use a tool oriented toward a fixed RPS (wrk2, k6).
Mistake 2 (practical): not using name= on variable paths
Symptom: your stats have 200 different rows (/books/1, /books/2, ...). Impossible to read.
Why it happens: locust groups by exact path by default. Each /books/{id} with a different id is a different endpoint in the stats.
How to fix it: whenever the path has variable parts, use name= to group them:
self.client.get(f"/books/{id}", name="/books/<id>")
Mistake 3 (conceptual): mixing HTML reading with API requests
Symptom: your test has with self.client.get("/api/books") as r: ... r.cookies .... It works, but you're "acting like a browser" when the app is a pure JSON API.
Why it's confusing: locust is designed both for web page tests (where you imitate a browser) and for JSON APIs (where you make pure calls). Mixing paradigms muddles the code.
How to tell them apart: decide what you're measuring. If it's the API: use client.get() with name= and validate the JSON. If it's the frontend: simulate the loading of assets too (CSS, JS, images) — but that's frontend load testing, another domain.
Mistake 4 (practical): running locust on the same machine as the app and saturating it
Symptom: in headless with many users, you see that locust itself is at 100% CPU. Your numbers don't reflect the app — they reflect that locust can't keep up.
Why it happens: locust is written in Python — it isn't as efficient as wrk. A single locust instance typically tops out at ~5-10k RPS before saturating.
How to fix it:
- Distribute across multiple machines (master/worker mode).
- Or use
gevent(whichlocustalready uses internally) and raise the concurrency level. - Or switch to
wrkfor simple high-load tests.
Mistake 5 (conceptual): assuming wait_time doesn't affect percentiles
Symptom: "I set wait_time = between(0, 0) for maximum throughput, but my p99 exploded".
Why it happens: without wait_time, the virtual users hammer the app at the maximum possible speed. That saturates shared resources (pool, locks) and the percentiles blow up. It's not a bug in your app — it's that the test isn't realistic.
How to fix it: use a wait_time that reflects a real human user's behavior (typically between 1-5 seconds). If you want to test maximum capacity without think time, that's fine — but document it: "raw capacity test without think time".
Exercises
Exercise 1: Your first locustfile.py
Bring up the demo API (api_demo.py) and write a locustfile.py that simulates 50 users hitting /fast and /slow with a 3:1 weight (more /fast than /slow). Run 60s headless and report the percentiles.
See solution
# locustfile.py
from locust import HttpUser, task, between
class DemoUser(HttpUser):
wait_time = between(1, 3)
@task(3)
def fast(self):
self.client.get("/fast")
@task(1)
def slow(self):
self.client.get("/slow")
Run it:
locust -H http://localhost:8000 \
--users 50 --spawn-rate 5 --run-time 60s --headless
Expected output (numbers vary):
Type Name # reqs # fails | Median Avg Min Max
GET /fast 720 0 | 3 5 1 45
GET /slow 240 0 | 55 120 50 1980
Response time percentiles
GET /fast 50%=3 75%=5 90%=8 95%=12 99%=24
GET /slow 50%=55 75%=58 90%=85 95%=420 99%=1820
Reading it:
/fastconfirms its consistency (p99=24ms, ~8x p50)./slowconfirms its bimodality (p99=1820ms, ~33x p50).- Just as we'd expect from the simulated endpoint.
Exercise 2: Multi-step scenario
Modify the locustfile.py from Exercise 1 so each user first hits /fast (as a "warmup") and then hits /slow. Make sure to use name= to group the stats properly.
See solution
# locustfile.py
from locust import HttpUser, task, between
class FlowUser(HttpUser):
wait_time = between(1, 2)
@task
def fast_then_slow(self):
# Step 1: warmup
self.client.get("/fast", name="/fast (warmup)")
# Step 2: the expensive operation
self.client.get("/slow", name="/slow (real work)")
Run it:
locust -H http://localhost:8000 \
--users 30 --spawn-rate 3 --run-time 60s --headless
Output:
Type Name # reqs #fails | Median Avg
GET /fast (warmup) 620 0 | 3 6
GET /slow (real work) 620 0 | 55 125
Analysis: the request count is the same for both endpoints (because each @task runs both in order). That confirms the flow is running end to end. The percentiles are per endpoint, which lets you analyze each step separately.
Lesson: this pattern is the basis for complex flows (login → search → buy). Each step is measured separately, so you can see where the time goes.
Exercise 3: Parsing a response to chain requests
Imagine /list returns {"items": [{"id": 1}, {"id": 2}, ...]}. Write a @task that lists, picks a random item from the response, and does GET /items/<id> with the chosen id.
(To test this, add to api_demo.py:
@app.get("/list")
def list_items():
return {"items": [{"id": i} for i in range(1, 21)]}
@app.get("/items/{item_id}")
def get_item(item_id: int):
return {"id": item_id, "name": f"item_{item_id}"}
Restart uvicorn.)
See solution
# locustfile.py
import random
from locust import HttpUser, task, between
class ChainedUser(HttpUser):
wait_time = between(1, 2)
@task
def list_then_pick(self):
# Step 1: list
with self.client.get("/list", catch_response=True) as resp:
if resp.status_code != 200:
resp.failure(f"List failed: {resp.status_code}")
return
data = resp.json()
items = data.get("items", [])
if not items:
resp.failure("Empty items list")
return
# Step 2: pick a random one and request its detail
item = random.choice(items)
self.client.get(
f"/items/{item['id']}",
name="/items/<id>"
)
Run it:
locust -H http://localhost:8000 \
--users 20 --spawn-rate 2 --run-time 30s --headless
Output:
Type Name # reqs #fails | Median Avg
GET /list 420 0 | 2 4
GET /items/<id> 420 0 | 2 3
Lesson: parsing JSON, deciding the next request based on the response, grouping under name= — all natural in Python. Doing this in wrk with Lua would be feasible but far more painful.
Exercise 4: Identify the problematic endpoint from aggregated stats
You run locust against an unknown app. The output shows:
Type Name # reqs # fails | 50% 75% 90% 95% 99%
GET /home 5200 0 | 8 12 18 25 50
GET /products 3100 0 | 25 40 65 85 180
GET /products/<id> 4500 0 | 12 18 30 45 90
GET /search?q=<x> 820 24 | 450 1100 3200 4800 9200
POST /checkout 280 2 | 120 180 320 450 850
Which endpoint is the most urgent problem? Why? What would you investigate?
See solution
The problematic endpoint is /search?q=<x>. Reasons:
- It has errors (24 fails out of 820 = ~3%). Any error rate >1% is a sign that the endpoint is at its capacity limit or has bugs.
- p99=9,200ms (9.2 seconds). Unacceptable for user experience.
- A very bimodal distribution: p50=450ms is already high, but p99 is 20x worse. It indicates that some searches fall into a very bad "slow path".
/checkout is the second one to investigate: p99=850ms is high for a checkout (users are waiting for confirmation), although without serious errors yet.
The rest are healthy:
/homeand/products/<id>: consistent, low percentiles./products: p99=180ms is decent for a listing endpoint.
What you'd investigate first on /search?q=<x>:
- A sequential scan on the search column (a typical bug in mid-size apps that forget to create a GIN index). We'd confirm it with
EXPLAIN ANALYZE(module 2) and fix it with an appropriate index (module 3). - Searches for rare terms (with very low cardinality) that return zero results but do a full seq scan. Solution: add a
LIMITand/or an index. - Pool saturation specific to slow queries — search requests hog connections for a long time and other requests wait (module 6).
The senior pattern: first locate the problematic endpoint with numbers (what you just did). Then you install pg_stat_statements to see the exact query (module 5). Then EXPLAIN ANALYZE to understand the plan (module 2). Then you apply the fix (module 3 or 4 depending on the case). Then you re-measure. Without the first step, everything else is guesswork.
Exercise 5: Two user types with weights
Write a locustfile.py with two user classes:
BrowserUser: browses/homeand/products(read-heavy). Weightweight = 4.BuyerUser: browses/productsand doesPOST /checkout. Weightweight = 1.
Run 60s with 100 total users. What proportion do you expect in each class?
See solution
# locustfile.py
from locust import HttpUser, task, between
class BrowserUser(HttpUser):
"""User who only browses — the most common one."""
wait_time = between(2, 5)
weight = 4 # 4x more common than the buyers
@task(3)
def home(self):
self.client.get("/home")
@task(2)
def products(self):
self.client.get("/products")
class BuyerUser(HttpUser):
"""User who checks out — less common."""
wait_time = between(1, 3)
weight = 1
@task(2)
def products(self):
self.client.get("/products")
@task(1)
def checkout(self):
self.client.post("/checkout", json={"items": [42]})
Run it:
locust -H http://localhost:8000 \
--users 100 --spawn-rate 10 --run-time 60s --headless
Expected proportion: with weight 4:1, out of 100 total users, approximately:
- ~80 BrowserUser
- ~20 BuyerUser
Lesson: weights are how you simulate a realistic mix. In real e-commerce, "browsers" are far more common than "buyers" (typically 95:5 or more). The capacity test should reflect that proportion so you don't over-estimate the checkout load.
Exercise 6: Report the results in BENCHMARKS.md
Take the output from Exercise 4 (the unknown app's data) and write the corresponding section of BENCHMARKS.md. Include context, methodology, a table with percentiles, and at least 3 actionable observations.
See solution
## Baseline — App "X" (Locust)
### Context
- **Hardware (locust client):** MacBook Pro M2, 16GB RAM
- **Hardware (app):** remote server, t3.medium on AWS
- **App version:** commit hash abc123 on main
- **Topology:** locust local, app on AWS us-east-1 (RTT ~80ms)
- **Notes:** first baseline, identify problems to prioritize
### Methodology
- Tool: `locust` 2.27.0
- Virtual users: 100, spawn rate 10/s
- Duration: 60s
- User types: BrowserUser (4) + BuyerUser (1) = 4:1
- Scenarios: home / products / search / checkout
- A single run (preliminary; reproduce 5x for a serious baseline)
### Results (approximate percentiles)
| Endpoint | # reqs | # fails | p50 | p75 | p95 | p99 |
|--------------------|--------|---------|-----|-----|-----|-----|
| GET /home | 5,200 | 0 | 8ms | 12ms| 25ms| 50ms|
| GET /products | 3,100 | 0 | 25ms| 40ms| 85ms|180ms|
| GET /products/<id> | 4,500 | 0 | 12ms| 18ms| 45ms| 90ms|
| GET /search?q=<x> | 820 | 24 (3%) |450ms|1100ms|4800ms|9200ms|
| POST /checkout | 280 | 2 (1%) |120ms|180ms|450ms|850ms|
### Observations
1. **`/search?q=<x>` is the critical problem.** p99=9.2s is unacceptable and it has a 3% error rate. Likely a missing GIN index on the search column. Prioritize for module 3.
2. **`/checkout` is at its limit.** p99=850ms with a 1% error rate indicates tight capacity. Investigate before any promotions / campaigns.
3. **The other endpoints are healthy.** /home and /products/<id> with p99 < 100ms are acceptable.
4. **The 80ms RTT to AWS contaminates the numbers.** We'd subtract it to isolate the pure app, but the real user also lives with that RTT.
5. **Missing:** multiple runs, percentiles of the failed requests, a breakdown of errors by status code.
What matters: the structure is the same regardless of the result. Context + methodology + table + actionable observations. This lets someone else (or you in 3 months) understand what was measured and why you decided what you decided.
Summary and next step
In this capsule you learned:
locustis the load testing tool when you need complex scenarios in Python: multi-step, stateful, with conditional logic.- Anatomy:
HttpUserwith@task,wait_time,on_start,self.client. Weights with@task(N)andweight. - UI mode (interactive web on
:8089) for exploration. Headless mode for CI and automation, with--csvfor version-controlled evidence. - Use
name=to group variable paths,catch_response=Truewhen a 200 can be a logical failure. - When to use
locustvswrk:wrkfor simple stateless loads,locustfor flows with state/logic. - Common traps:
--usersisn't RPS, missingname=, saturatinglocustitself,wait_time=0giving unrealistic numbers.
Before moving on you should be able to:
- Write a
locustfile.pywith several@tasks and weights - Run
locustheadless with CSVs as evidence - Read the stats and extract percentiles per endpoint
- Identify which tool (
wrk/locust/pgbench) to use based on the question
Next capsule — Reporting improvements in BENCHMARKS.md. You now have all three tools. Now you'll learn the discipline of communicating what you measure: how to build the before/after table, what to say and what NOT to say with benchmarks, how to avoid reports that are "technically correct but misleading". It's the capsule that separates "measured it" from "communicated the measurement".
Resources
- Locust Documentation — complete official documentation with examples.
- Locust on GitHub — source code, issues, community examples.
- Locust API Reference — reference for all the classes (
HttpUser,FastHttpUser,SequentialTaskSet, etc.). - Writing a locustfile — the official step-by-step guide on how to structure scenarios.
- Distributed Load Testing — how to scale
locustacross multiple machines. - Carl Bystrom — Locust 1.0 talk — a talk by the creator explaining the tool's philosophy.
- k6 vs Locust comparison — an honest comparison between tools, useful when deciding which to use for what.
Module 1 — Database Performance & Query Tuning Guide