Skip to content

Performance

Measured Results

Measured with the suite in tests/test_benchmark.py on one workstation; each figure is the median across three runs. Treat these as a relative guide: absolute numbers depend on the machine and Python build.

Conditions: navi-sanitize 0.2.2 candidate runtime code (commit 63a6455), CPython 3.12.12, AMD Ryzen 9 9950X, Linux, pytest-benchmark 5.2.3, single thread, September 2026.

clean() --- Per-String Cost

Scenario Median Per second Input
Short, clean text (no-op) 1.2 µs ~840K ~38 chars, no stage changes anything
Short, hostile, with jinja2_escaper 21.1 µs ~47K 19 chars: homoglyphs, null byte, zero-width, template syntax
13,000 chars, clean 313 µs ~3.2K Large no-op input
4,000 chars, hostile, with jinja2_escaper 295 µs ~3.4K Repeated hostile pattern
47,500 chars (57KB UTF-8), hostile, with jinja2_escaper 3.4 ms ~290 Stress payload

walk() --- Nested Structure Cost

Scenario Median Per second Input
100-item nested dict, clean 346 µs ~2.9K Copy and traversal, no stage changes anything
100-item nested dict, hostile 2.4 ms ~420 Copy plus pipeline changes on every string

Opt-in Utilities and Escapers

Scenario Median Per second Input
decode_evasion(), double-encoded path 10.2 µs ~98K %252e%252e%252fetc%252fpasswd
decode_evasion(), 13,600 chars of mixed encodings 579 µs ~1.7K URL, HTML entity and \xHH escapes
jinja2_escaper(), 11,400 chars of overlapping delimiters 802 µs ~1.2K Called directly
path_escaper(), 10,200 chars of dot segments 117 µs ~8.6K Called directly

Running Benchmarks

uv run pytest tests/test_benchmark.py -v             # everything
uv run pytest tests/test_benchmark.py -v -k clean    # clean() only
uv run pytest tests/test_benchmark.py -v -k walk     # walk() only

The 47,500-char payload uses pedantic() mode (50 rounds, 5 warmup) to avoid excessive iterations. Compare versions on the same machine and interpreter; CI runners are too noisy for small differences.

When to Use clean() vs walk()

Situation Use
Single user input field clean()
JSON request body walk()
Individual form fields already extracted clean() on each
Nested config from untrusted source walk()

walk() builds new dicts and lists so the input is never modified. If you know which fields matter, calling clean() on those fields avoids copying the rest.

Cost by Stage

Stage Cost Notes
Null bytes O(n) str.count / str.replace
Invisible chars O(n) One compiled regex
NFKC normalization O(n) unicodedata.normalize (C implementation)
Homoglyphs O(n) NFD, per-character dict lookup, NFC
Escaper Varies Depends on the escaper

Even for clean text every stage scans the whole string; the invisible-character regex and the normalization calls dominate.

Hot Paths

Sanitize once at the boundary and store the result, rather than on every use.

Printable ASCII is never changed by the universal stages, so you can skip them for such input. Check isprintable() as well as isascii(): ASCII control characters such as ESC are stripped and must not take the shortcut. Apply your escaper either way:

from navi_sanitize import clean

def clean_fast(text: str) -> str:
    if text.isascii() and text.isprintable():
        return text
    return clean(text)

safe = my_escaper(clean_fast(user_input))  # the escaper still runs

What costs more: input that triggers changes costs more than clean input of the same size, because each changing stage rebuilds the string and logs a warning (compare the clean and hostile rows above). Beyond that, an expensive custom escaper is the main lever.