Skip to content

Skip regex passes that cannot match (~29% faster slugify) - #211

Closed
deadstrobe5 wants to merge 1 commit into
un33k:masterfrom
deadstrobe5:skip-no-match-regex
Closed

deadstrobe5 wants to merge 1 commit into
un33k:masterfrom
deadstrobe5:skip-no-match-regex

Conversation

@deadstrobe5

@deadstrobe5 deadstrobe5 commented Oct 16, 2026 •

Copy link
Copy Markdown

Why

Every slugify() call runs five or six regex passes, even when a pass can't match. On a typical ASCII title the no-match passes cost about 1.8 µs of about 5.6 µs. The worst is NUMBERS_PATTERN: its lookbehind scans the whole string even when there is no comma (~870 ns per call).

Change

Each pass now runs only when the literal it needs is present: & for entity decoding, ' for both quote passes, , for the numbers pass, and -- for the duplicate-dash pass. The same guards are in _legacy.py and slugify.py. Without that literal, re.sub would have returned its input unchanged, so output can't change.

Results

CPython 3.11, Apple M5, 15 alternating in-process rounds, median ns per call over 12 realistic inputs (titles, accents, pinyin, CJK, emoji, &):

options before after change
default 5628 4007 −28.8%
max_length + word_boundary 6452 4785 −25.8%
stopwords 6637 5005 −24.6%
allow_unicode 3873 2248 −42.0%
algorithm='modern' 5762 4145 −28.1%

Risks and how they are covered

  • Legacy output: the frozen 2,688-case legacy differential in tests/test_legacy.py passes (unchanged).
  • Nested entities: & still decodes, because the & check runs once at entry and the original sequential passes follow.
  • Randomized differential: 403,872 cases give byte-identical output and identical exceptions — both algorithms × 4 backends (auto, text-unidecode, unidecode, anyascii) × 14 option sets × ~3,600 inputs (entities, nested and out-of-range numeric references, quotes, commas, dash runs, combining marks, CJK, emoji, bytes, the first 600 code points).

Tests

133 passed (143 subtests). flake8, pycodestyle and mypy are clean.

Re-measured on CPython 3.14.7 (7 alternating processes per side, median): default slugify() on 10 typical titles x300 went from 27.3 ms to 24.0 ms (1.14x), smaller than the 3.11 numbers above.

@un33k

un33k commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Thank you. This is careful, well-argued work, and I want to say that plainly before the no.

I verified it rather than taking the writeup on trust: 9,872 differential cases came out byte-identical to master (both algorithms, entities, quotes, commas, dash runs, combining marks, custom regex patterns), all 133 tests pass, and I reproduced the speedup here — about 52% faster on ordinary titles, a little over a microsecond saved per call. The nested-entity reasoning is correct too.

I'm closing it for two reasons, neither of them about quality:

  1. It edits the frozen legacy file. slugify/_legacy.py is the permanent default pipeline, and deployed sites depend on its exact output. We only touch it for a gain that clearly outweighs any risk to that promise.
  2. The gain is negligible for how this library is used. Slugs are generated when something is saved, not in a hot loop, so a microsecond per call will never be felt by anyone. That is not enough to justify churn in frozen code.

If you ever hit an actual slugify bug in real use, please do file it as an issue — this level of rigor would be very welcome there.

🚀 Generated with Dojo ⛩️

@un33k un33k closed this Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants