Every "fastest Python HTML parser" post eventually names selectolax, and every one of them rounds the story off at "way faster than BeautifulSoup." That part is true. The part nobody finishes is what happens when you put selectolax next to lxml instead — because there, "fastest" grows an asterisk.
So I benchmarked it properly: selectolax (both of its backends) against lxml, BeautifulSoup on html.parser and on lxml, and parsel, across five page sizes from 1 KB to 10 MB, each measurement taken as the median of three separate process runs. selectolax beat BeautifulSoup by a mile and tied raw lxml — and then lost the pure-parsing step to lxml. All the numbers below are provisional and from one machine (macOS arm64, Python 3.14.2); the scripts are committed, so run them on your own box before you quote me.
What selectolax actually is (and what it isn't)
selectolax is a Python binding to two C engines — Modest and Lexbor — that parse HTML5 and query it with CSS selectors. It is not a crawler, not a browser, not a "scraper" in the button-click sense. It is the thing you hand a blob of HTML to after you've already fetched it. The maintainer's own one-liner is "A fast HTML5 parser with CSS selectors, written in Cython, using Modest and Lexbor engines."
There are two backends, and the difference matters more than the docs let on:
LexborHTMLParser(Lexbor engine) — the one the README tells you to use as of 2024.HTMLParser(Modest engine) — the original, whose underlying C library "is not maintained anymore," per that same README.
A few facts worth having before we get to speed. As of the repo snapshot taken 2026-07-10, selectolax sits at 1,653 stars with the latest release being v0.4.10 (May 2026), and PyPI pins it to Python >=3.9,<3.15. Install is the least dramatic part of this whole review: pip install selectolax pulled a 2.3 MB prebuilt cp314 wheel and worked immediately on Python 3.14 — no browser download, no doctor step, no compilation. That is the quiet advantage of a pure parser over a browser-backed tool. It just imports and runs.
One licensing wrinkle to note now rather than bury: the Python binding is MIT, but the wheel bundles the engines compiled in, and those carry their own licenses — Modest is LGPL-2.1, Lexbor is Apache-2.0. So "selectolax is MIT" is true for the Python code and incomplete for the binary you actually ship. If your legal team cares about redistributed components, that distinction is the one to flag.
The speed question, answered with actual numbers
Here is the task I timed: parse the HTML string, pull every <h3 class="title"> text, pull every <a> href. Median latency in milliseconds, taken as the median across three separate process runs; cross-run spread stayed under ~5% for the C-backed parsers at most sizes. Before any cell was timed, every parser's output was reduced to a content hash so a parser that silently did less work would be caught and excluded — on these pages all six matched at every size, so this is a genuine apples-to-apples comparison. Full data lives in the committed bench_parse.json.

| Page | selectolax (Lexbor) | selectolax (Modest) | lxml | parsel | BS (lxml) | BS (html.parser) |
|---|---|---|---|---|---|---|
| 1 KB | 0.027 | 0.029 | 0.036 | 0.044 | 0.281 | 0.323 |
| 10 KB | 0.160 | 0.171 | 0.166 | 0.228 | 1.705 | 2.049 |
| 100 KB | 1.464 | 1.565 | 1.423 | 2.020 | 16.5 | 20.6 |
| 1 MB | 14.901 | 16.9 | 14.177 | 20.9 | 181.9 | 232.6 |
| 10 MB | 159.9 | 247.9 | 172.9 | 231.9 | 2261.6 | 2788.7 |
Versus BeautifulSoup: about 12-17x, and the folklore undersells it
Turn those into ratios and selectolax-Lexbor comes out ~12x faster than BeautifulSoup(html.parser) on a 1 KB page, rising to ~17x at 10 MB, and ~10-14x faster than BeautifulSoup(lxml) across the same range. The number that circulates online — "selectolax is about 4-5x faster than BeautifulSoup" — is too low against html.parser and roughly right only against the lxml-backed BeautifulSoup. The true multiple depends on which BeautifulSoup you mean and how much you extract per page.
That also reconciles with the README's own benchmark, which implies a 25.5x edge over BeautifulSoup(html.parser). Neither figure is wrong. The README's task (title, links, scripts, and meta from small homepages) does less extraction on smaller pages, which weights BeautifulSoup's per-parse overhead more heavily. Range-limited, it comes out as: selectolax is roughly 10-15x faster than BeautifulSoup on realistic parse-and-extract work, higher on tiny pages and lighter extraction.
If your current bottleneck is a pile of BeautifulSoup code chewing through pages, this is the migration that pays for itself. That case is not controversial. The next one is.
Versus lxml: a tie — and lxml wins the part everyone forgets to isolate
Look back at the 100 KB and 1 MB rows. Lexbor and lxml are inside ~5% of each other, their per-run bands overlap, and by my own methodology that is a tie — no winner, no "faster." The one place selectolax pulls genuinely ahead is the 10 MB page (159.9 ms vs 172.9 ms, an 8.1% gap with non-overlapping intervals). So on the full task selectolax matches lxml and beats it only on the very largest documents.

Then I isolated tree construction from CSS querying, and the result flips in a way most write-ups miss. For pure parsing, with no query at all, lxml was consistently ~33-34% faster than selectolax-Lexbor on this machine — 77.9 ms vs 116.6 ms on the 10 MB page. On the full task the two converge anyway, and my working hypothesis (not something I proved with an attribution experiment) is that on these pages the CSS query is a small slice of total time, so lxml's parse-step lead gets diluted until the totals meet.
This is the most attackable claim in the whole review, and I want to be upfront about why. It reverses the common wisdom, and the one published benchmark I found that isolates parse-only — aows.jpt.sh — reports the opposite, with selectolax about 4x faster. So I fenced it: the result is single-platform (macOS arm64, Python 3.14, prebuilt cp314 wheels — a Linux x86_64 or source build is untested), it was cross-checked across four page sizes and held at every one, and it was re-verified with two different lxml APIs to rule out an API artifact. Both lxml APIs beat selectolax-Lexbor at every size. I'm not presenting "lxml parses faster" as settled fact — I'm presenting it as what my bench produced, with the script attached, against most of the published numbers. Run it on yours.
One more slice: querying 100,000 <a> on a flat page, lxml and selectolax-Modest tie (33.30 ms vs 34.19 ms, bands overlapping), while selectolax-Lexbor trails both by ~15%. What all three C engines share is being 5-7x faster than parsel or BeautifulSoup at bulk selection, whose Python-object-per-node model is the real drag. So "selectolax is the fastest at bulk CSS selection" doesn't hold either — Modest merely ties lxml, and Lexbor loses to it.
The takeaway I'd actually stand behind: selectolax's edge over lxml is not broad, full-task speed. It wins only the largest page. Its case rests on other things — API ergonomics, behavior on garbage input, and modern CSS — which is where the rest of this review lives.
Memory and cold start: rank by RSS, not by your profiler
Memory is where I have to correct my own earlier numbers, and the correction is the point. Measured as RSS delta on the 10 MB page with tracemalloc turned off, BeautifulSoup uses about 1.5-1.8x the memory of selectolax or lxml — the range runs from 1.51x (BS-lxml at 218.4 MB vs Lexbor at 144.6 MB) up to 1.75x at the top end. selectolax and lxml share the lean tier; lxml is the leanest by RSS.

An earlier pass of mine reported "~3x," and that number was wrong for an instructive reason: it was measured with tracemalloc running, and tracemalloc's per-allocation bookkeeping roughly doubles the apparent RSS of the highest-allocating parser. So a callout for anyone benchmarking parser memory: rank by RSS with your profiler off. Ranking parsers by tracemalloc peak mis-orders the C-backed ones specifically — it made selectolax-Lexbor look heavier than Modest when by real RSS they're close. BeautifulSoup is genuinely the heaviest here; it just isn't heavy by the 3x margin a contaminated instrument showed.
Cold start is minor but real: selectolax imports in about 14 ms, roughly on par with lxml and ~2.3x faster than bs4 or parsel. If you're shipping a CLI tool or a serverless function where import time is part of every invocation, that gap is worth a nod.
CSS selector coverage: strong, with a couple of real holes
CSS coverage got a 41-case matrix, each selector checked against a fixture with a known-correct answer set, plus a deliberate fault-finding pass built to break the Lexbor engine. Every case ran in its own subprocess, which turned out to be necessary — one of them crashes the whole interpreter. The results:

| Engine | PASS | WRONG | UNSUPPORTED | PROCESS_ABORT |
|---|---|---|---|---|
| soupsieve | 41 | 0 | 0 | 0 |
| selectolax Lexbor | 39 | 0 | 2 | 0 |
| lxml (cssselect) | 37 | 1 | 3 | 0 |
| parsel (cssselect) | 37 | 1 | 3 | 0 |
| selectolax Modest | 35 | 3 | 2 | 1 |
Once the hostile selectors are in the mix, Lexbor is not the outright winner — soupsieve is, with a clean 41/41 versus Lexbor's 39/41. Lexbor's two misses are :lang(en) and :dir(rtl), which it rejects with a parse error. It is perfect on everything else, including :has(), :is(), :where(), and case-insensitive attributes.
Where Lexbor does shine is against the cssselect stack. The README's flagship selector — div > :nth-child(2n+1):not(:has(a)) — returns the correct set on both selectolax engines and on soupsieve, but the wrong set on lxml and parsel, with no error raised. A scraper that copies that selector into Scrapy or parsel gets silently wrong results. To be precise about the framing: cssselect has parsed :has() since version 1.2.0 (2022), and I tested 1.4.0, so this is "supported but mis-evaluates the compound," not "unsupported." The silent-wrong-set behavior on this particular compound isn't in the cssselect tracker, which records the :has() limits as raised errors. Lexbor also handles the case-insensitive attribute flag [data-role="LEAD" i] that cssselect rejects outright.
Two gaps will decide migrations, though. selectolax supports no XPath at all — neither backend exposes xpath() — and no ::text / ::attr() pseudo-elements, since those are a parsel/Scrapy extension rather than real CSS. If your existing scrapers lean on XPath, that is the single biggest wall you'll hit; you'd be rewriting selectors, not swapping a library. On the flip side, Lexbor ships a :lexbor-contains("text" i) pseudo-class for case-insensitive text matching that neither lxml, parsel, nor standard CSS offers, and it works as documented.
Robustness on ugly HTML, which is where selectolax earns its keep
Real scraping means feeding a parser garbage and hoping it doesn't fall over. I ran 18 adversarial inputs, and this is the category where selectolax's case against lxml is strongest.
Hand lxml.html.fromstring an empty string or whitespace and it raises ParserError("Document is empty"). Both selectolax engines return a valid, empty tree instead. For a scraper looping over a list of URLs where some responses come back blank, that is one less try/except to wrap everything in. selectolax also chewed through 100,000 elements with no stack overflow.
Deep nesting produced the sharpest split. On 1,000 and 5,000 levels of nested <div>, lxml silently drops the deepest content while selectolax keeps it. libxml2 caps parse depth around 256 levels and truncates the tree with no error, so the deepest text is simply unreachable. Both selectolax engines return the full tree. It's the mirror image of the <template> trap I'll get to next: there Lexbor drops content the others keep; here lxml drops content selectolax keeps.
Not every cell was a win. The Modest backend aborts the entire Python interpreter with a SIGABRT when it hits :dir() — not a raised exception you can catch, a hard process kill. That is a real robustness caveat for anyone still on the legacy backend, and it's exactly the kind of thing that stays invisible until it takes down a production job at 3 a.m.
Two silent-data-loss traps to know before you ship
Neither of these is a discovery — both are documented upstream — but both cost real data, silently, and neither is loud in the README.
Lexbor drops <a> inside <template>
On the live MDN page I tested, selectolax-Lexbor found 497 links while lxml, both BeautifulSoup backends, and even selectolax's own Modest backend found 508. The missing eleven were a language-switcher and a discussions link living inside <template> elements (the page uses Lit web components).

The root cause is legitimate: per the HTML5 spec, <template> content is parsed into a separate inert fragment, not the normal DOM, and Lexbor follows that strictly — tree.css("a") does not descend into template content. lxml, both BeautifulSoup backends, and Modest flatten template content into the main tree, so they find those links. This is a documented open issue (selectolax#146, with the engine root cause at lexbor#170), and both readings are defensible — Lexbor is arguably the more spec-correct one. But a developer on the recommended backend silently misses that data, with no error. The reverse is worth stating too: the other parsers surface inert template content a browser never renders, so they can hand you phantom data a user can't see. The reliable escape hatch is the Modest backend, or a different library, for that specific page.
Non-UTF-8 bytes silently corrupt .text()
Pass selectolax bytes that aren't valid UTF-8 and parsing succeeds — the corruption surfaces later, and it's worse than a clean crash. On "<p>café éè</p>".encode("latin-1"), Lexbor's .text() returns replacement characters, Modest's .text() silently drops the offending bytes, and both engines raise UnicodeDecodeError only when you touch .html. The binding decodes as strict UTF-8 at read-back, not at parse. This one is related to a known selectolax issue about encode/decode strictness.
The fix is one line and belongs in muscle memory: decode the bytes yourself first — LexborHTMLParser(resp.content.decode("latin-1")) — and both engines return 'café éè' correctly. In practice, always hand selectolax a str, never raw non-UTF-8 bytes. The README doesn't spell this out.
Production dimensions (single-observation, so treat them as directional)
These next results I measured once, not across three runs, so I'm flagging them as signals rather than settled numbers.
Thread scaling is the interesting one. Parsing a 1 MB page 48 times across four threads, selectolax showed a ~3.5-3.9x wall-clock speedup — the empirical signature of a library releasing the GIL during C parsing — while BeautifulSoup(lxml) got several times slower threaded, the signature of work serializing on the GIL. lxml landed in between and inconclusive. For the free-threading era that Python is moving into, selectolax parsing that parallelizes across threads where BeautifulSoup doesn't is a genuine, if provisional, advantage. It's one thread count on one page size, and the mechanism is a hypothesis, not something I confirmed by instrumenting the C code.
On leaks: over 2,000 parse-extract-drop iterations at 1 MB, none of the three parsers showed the linear RSS climb of a leak — each settled into a bounded working-set band. I trust that result specifically because I ran a known-leak calibration subject through the same instrument, and it climbed to +198 MB as designed, which proves the instrument could see a leak and simply didn't find one in the parsers. And a node handle kept alive after its owning tree went out of scope stayed usable, with no segfault. All single-observation, none a multi-hour soak.
Where selectolax fits — and where it hands off
Everything above is about one job: turning HTML you already have into structured data, fast. selectolax is very good at that job. What it deliberately does not do is fetch the page, render JavaScript, rotate proxies, solve CAPTCHAs, or figure out which elements you want. That's all still your code. selectolax is the parse layer, and it doesn't pretend to be anything more.
That's the line where a managed extraction service sits above a parser rather than replacing it. If you'd rather not build and maintain the fetch-render-anti-bot-extract stack yourself, Thunderbit exposes that as an API, MCP server, and CLI — POST /distill turns a page into clean Markdown and POST /extract returns schema-matched structured JSON, with JS rendering and anti-bot handled for you. It's a different layer of the problem: you'd reach for selectolax when you already have the HTML and want raw parsing speed under your own control, and for something like Thunderbit's API, MCP server, or CLI when you want the fetching and extraction handled and just want structured data back. Not a swap — a different altitude on the same stack.
Try Thunderbit for Web Data Extraction
Pros, cons, and who should actually use it
Where selectolax wins:
- ~12-17x faster than BeautifulSoup on realistic parse-and-extract work, stable across three orders of page-size magnitude.
- Lean memory (the lxml tier, ~1.5-1.8x lighter than BeautifulSoup) and a ~14 ms import.
- Graceful on the inputs that break lxml — empty, whitespace, and pathologically deep nesting.
- Modern CSS including
:has(),:is(),:where(), case-insensitive attributes, and the Lexbor-only:lexbor-contains(). - A None-safe read/write DOM: missing elements return
Noneor[]instead of throwing, and you can actually mutate and re-serialize the tree. - Active maintenance (v0.4.10, mid-2026) and a trivial install.
Where it doesn't:
- Not broadly faster than lxml — a tie on the full task, and it loses the pure-parse step on my bench.
- No XPath and no
::text/::attr()— a hard migration wall for XPath-based scrapers. - Two silent-data-loss traps:
<template>content on Lexbor, and non-UTF-8 bytes via.text(). - The Modest backend is legacy and will SIGABRT on
:dir(). - Every number here is single-platform (macOS arm64, Python 3.14) and provisional.
Should you use selectolax? Yes, if you want lxml-class parsing speed with a friendlier, None-safe API and noticeably better behavior on empty and malformed input — and you're willing to live in CSS-only territory. If your codebase is built on XPath, the rewrite cost is real and you should weigh it honestly. And if you're chasing "the single fastest parser," the accurate answer from this bench is that selectolax and lxml are close enough that the tiebreaker is ergonomics and robustness, not raw speed. That's a better reason to pick a tool anyway.
Try Thunderbit for Web Data Extraction Get Started Free
FAQs
Is selectolax faster than BeautifulSoup?
Yes, clearly — roughly 12-17x faster than BeautifulSoup(html.parser) and 10-14x faster than BeautifulSoup(lxml) on a realistic parse-and-extract task, holding steady from 1 KB to 10 MB pages (macOS arm64, Python 3.14). The commonly cited "4-5x" understates the gap against html.parser.
Is selectolax faster than lxml? Not broadly. On the full parse-and-extract task they tie at 100 KB and 1 MB, with selectolax winning only the 10 MB page. On pure parsing with no query, lxml was actually ~33-34% faster on my machine — a counter-consensus result I've fenced as single-platform, so verify it on your own hardware.
Should I use the Lexbor or Modest backend?
Lexbor, in almost every case — it's the maintained, feature-complete engine the README recommends, with better CSS coverage. The one exception is a page that hides content inside <template> elements, where Lexbor's spec-correct behavior drops that content and Modest happens to keep it. Modest also has sharp edges, including a hard interpreter crash on :dir().
Does selectolax support XPath?
No. Neither backend exposes an xpath() method — selectolax is CSS-only. If your scrapers depend on XPath, migrating means rewriting your selectors, which is the biggest single cost of moving to selectolax from an lxml- or parsel-based stack.
Why is my selectolax output garbled or missing elements?
Two usual suspects. If text comes back with replacement characters or missing accents, you likely passed raw non-UTF-8 bytes — decode them to a str first (resp.content.decode("latin-1")) before parsing. If links or elements are missing on a modern site, they may live inside <template> tags that the Lexbor backend doesn't descend into; switch to Modest or another parser for that page.


