case studies

jvq Catches a Python Cleanup That Made URL Parsing Nearly Five Times Slower

A log pipeline pulls query parameters out of request paths, millions of times a day. The function that does it is eight lines of hand-written splitting. It’s fast, and it has a bug everyone has learned to live with: %20 and + come through undecoded.

So someone opens a pull request that fixes it the proper way, with the standard library. Two lines replace eight. The review is easy to approve. This is a worked example we set up to show what jvq does with a change like that: a small repository with a pytest-benchmark suite, four real commits, and jvq 0.5.0 run on each one on a two-core cloud VM.

Four jvq runs on the same pull request. The first commit is 374% slower and fails. The next two are 16% and 11.8% slower and fail. The fourth is 3.7% slower and passes.

Four commits on one branch, each compared with main. Output trimmed to the result rows.

The Setup

The repository has the function, two benchmarks and a jvq.toml:

command = "python3 -m pytest -q -p no:cacheprovider --benchmark-json=bench.json bench_test.py"
adapter = "pytest-benchmark"
output  = "bench.json"

test_params times the parameter parser on a path with five parameters. test_route times the function next to it, which the pull request doesn’t touch. The budget is jvq’s default, 5% slower at most.

Commit 1: the Standard Library

def params(path):
    # Use the standard library so %-escapes and + are decoded properly.
    return {k: v[-1] for k, v in parse_qs(urlsplit(path).query).items()}

jvq measured 1.32 µs before and 6.21 µs after, a change of +374.3% with an interval of +366.6% to +384.5%. That’s 4.7 times as long per call, on a function that runs on every log line. The build failed. test_route moved by -0.5% and passed, which is the other half of the job: the untouched code didn’t raise a false alarm.

Nobody reviewing that diff would have guessed the number. parse_qs does a lot more than split on &, and urlsplit parses the whole URL to find the query.

Commits 2 and 3: Closer

The second try went back to splitting by hand and called unquote_plus only on pairs that contain % or +. jvq said +16.0%, interval +13.1% to +18.3%. Still over.

The third checked the whole query string for escapes once, then branched inside the loop. +11.8%, interval +9.1% to +14.1%. Still over. In Python a test on every pass through a loop costs real time when the loop body is this small.

Commit 4: Two Loops

    if "%" in query or "+" in query:
        for pair in query.split("&"):
            k, _, v = pair.partition("=")
            out[unquote_plus(k)] = unquote_plus(v)
        return out
    # The common case: nothing to decode.
    for pair in query.split("&"):
        k, _, v = pair.partition("=")
        out[k] = v
    return out

Paths with escapes take the decoding loop. Everything else takes the original one. jvq measured +3.7%, interval +2.4% to +5.0%, and passed. It needed 24 rounds per side to say so, because the upper end of the interval sat right on the budget. After 10 rounds it couldn’t tell, so it kept going until it could.

What the Gate Changed

The bug fix shipped either way. Without the gate it would have shipped at 4.7 times the cost and somebody would have found it later, in a profile or on a bill. With it, the cost was on the pull request before the merge, with a number attached, and the fix that went in decodes escapes for about 4% more time.

If a team decides a 12% slowdown is a fair price for clearer code, that’s a fine decision too. jvq makes it a decision. A looser budget for one benchmark is a few lines in jvq.toml. Commit 3’s interval reached +14.1%, so it would need 15% to pass:

[budget.bench."test_params"]
time = "15%"

The quick start has the GitHub Actions and GitLab setup, and the reference lists the harnesses jvq reads as they are.