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.

QuestionTool
"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

Featurewrklocust
LanguageC (config + Lua for custom)Python (config and scenarios)
Efficiency (RPS per core)Very highModerate
Minimum setupOne line of bashA 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 machinesManual✅ 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 that locust will run when the user "acts".
  • self.client: an HTTP session (based on requests). 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:

  1. Lists the books (GET /books).
  2. Picks a random one from the response and requests its detail (GET /books/<id>).
  3. (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

ConceptWhat it's for
on_startA 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=TrueLets 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 with failure()). 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:

Aspectwrklocust
Setup time0 seconds (1 line of bash)~5 minutes (writing locustfile.py)
Percentile outputYes, with --latencyYes, automatic
Multi-step scenariosVery hard (Lua)Trivial (Python)
Real-time UINoYes
Reproducibility in CIExcellentExcellent (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" → wrk is 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 (which locust already uses internally) and raise the concurrency level.
  • Or switch to wrk for 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:

  • /fast confirms its consistency (p99=24ms, ~8x p50).
  • /slow confirms 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:

  • /home and /products/<id>: consistent, low percentiles.
  • /products: p99=180ms is decent for a listing endpoint.

What you'd investigate first on /search?q=<x>:

  1. 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).
  2. Searches for rare terms (with very low cardinality) that return zero results but do a full seq scan. Solution: add a LIMIT and/or an index.
  3. 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 /home and /products (read-heavy). Weight weight = 4.
  • BuyerUser: browses /products and does POST /checkout. Weight weight = 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:

  • locust is the load testing tool when you need complex scenarios in Python: multi-step, stateful, with conditional logic.
  • Anatomy: HttpUser with @task, wait_time, on_start, self.client. Weights with @task(N) and weight.
  • UI mode (interactive web on :8089) for exploration. Headless mode for CI and automation, with --csv for version-controlled evidence.
  • Use name= to group variable paths, catch_response=True when a 200 can be a logical failure.
  • When to use locust vs wrk: wrk for simple stateless loads, locust for flows with state/logic.
  • Common traps: --users isn't RPS, missing name=, saturating locust itself, wait_time=0 giving unrealistic numbers.

Before moving on you should be able to:

  • Write a locustfile.py with several @tasks and weights
  • Run locust headless 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

  1. Locust Documentation — complete official documentation with examples.
  2. Locust on GitHub — source code, issues, community examples.
  3. Locust API Reference — reference for all the classes (HttpUser, FastHttpUser, SequentialTaskSet, etc.).
  4. Writing a locustfile — the official step-by-step guide on how to structure scenarios.
  5. Distributed Load Testing — how to scale locust across multiple machines.
  6. Carl Bystrom — Locust 1.0 talk — a talk by the creator explaining the tool's philosophy.
  7. k6 vs Locust comparison — an honest comparison between tools, useful when deciding which to use for what.

Module 1 — Database Performance & Query Tuning Guide