PyQuery puts a jQuery-style API over lxml. Across five page sizes from 1 KB to 10 MB, all five displayed medians were lower than raw lxml's, including a 1.5% difference at the largest size. The benchmark does not establish that the wrapper is faster; it found no difference large enough to change this selector-and-read decision.
It was also within a few percent of selectolax from 10 KB upward in the displayed medians. Without a predefined equivalence margin, that is a close result rather than a statistical tie.
What PyQuery is
PyQuery is a Python library that gives you jQuery's selector-and-chain API over an lxml document tree. Version tested: 2.1.0, BSD licensed, 2,380 GitHub stars, 59 open issues, last pushed 2026-07-27.
Official reference: PyQuery documentation.

from pyquery import PyQuery as pq
d = pq(html)
titles = [e.text_content() for e in d("h3.title")]
hrefs = [e.get("href") for e in d("a")]
The elements it yields are lxml elements, so anything you know how to do with lxml still works. That is the design: PyQuery is an ergonomics layer, not a parser. pip install pyquery brings 3 packages — lxml, cssselect and PyQuery itself — and 20.1 MiB, nearly all of it lxml's compiled extensions.
If you have used cheerio in Node, this is the same general API idea in Python. The two selectors exercised here worked in both; the test does not establish selector-language parity across cssselect and cheerio.
The measurement
This research base already had a parser bench with a property most benchmarks lack: a parity gate that hashes extracted content — sorted titles plus sorted hrefs — against a reference parser, so a library that skips work cannot post a fast time. Five page sizes, 50 iterations, three independent runs.
Adding PyQuery to it required two things.
Re-running the reference. selectolax ran again in the same process. Its content hash reproduced on 5 of 5 sizes and its p50 landed between 0.989× and 1.079× of the published figure — so this is the same machine and the same testbed.
Running lxml in the same process too. The published bench records the machine and the Python version but not library versions, so its lxml row could have come from a different lxml release than the one PyQuery wraps here. Comparing across that gap would have compared two lxml versions and called it a wrapper tax. Running lxml alongside removes the question — both are lxml 6.1.1 in this venv.
| Page size | selectolax | PyQuery | lxml | PyQuery vs lxml |
|---|---|---|---|---|
| 1 KB | 0.0286 ms | 0.0456 ms | 0.0508 ms | 0.90Ă— |
| 10 KB | 0.1725 ms | 0.1728 ms | 0.1802 ms | 0.96Ă— |
| 100 KB | 1.4855 ms | 1.4093 ms | 1.4145 ms | 1.00Ă— |
| 1 MB | 14.97 ms | 14.96 ms | 15.03 ms | 1.00Ă— |
| 10 MB | 158.10 ms | 162.86 ms | 165.25 ms | 0.99Ă— |
p50 milliseconds, median of three runs, all in one process. parser-bench.json. All three content hashes matched the reference at every size.
The wrapper tax that is not there

PyQuery came in at or below raw lxml at every displayed median. That is not evidence that a wrapper makes parsing faster. Three run medians and no predefined equivalence margin support the narrower judgment that this fixture showed no decision-relevant selector overhead.
At 10 MB, PyQuery's three runs were 162.86, 163.17 and 161.13 ms; lxml's were 169.46, 165.25 and 164.18. The ranges are close but do not overlap. At 1 MB the two medians differ by 0.5%. These small runs support a practical judgment, not a statistical equivalence claim.

The mechanism is simple enough: pq(html) builds an lxml tree once, d("h3.title") compiles the CSS selector through cssselect as tree.cssselect() does, and the elements handed back are lxml elements. There is little PyQuery work in this measured hot path. Traversal, manipulation, repeated queries, import, and memory remain outside the selector timing claim.
Close results from 10 KB upward
The more useful finding is the first column.
From 10 KB upward, the fastest-to-slowest median spread among selectolax, lxml and PyQuery was 4.5% at 10 KB, 5.4% at 100 KB, 0.5% at 1 MB, and 4.5% at 10 MB. This run was not an equivalence test; the practical judgment is that these gaps would not change most parser choices for this workload.
selectolax is genuinely faster at 1 KB — 0.0286 ms against 0.0456 and 0.0508 — but that row is unusable. Across the three parsers the spread at that size is 77.6%, and selectolax's own three runs ranged 0.0267 to 0.0404 ms. At 28 microseconds the timer and the scheduler dominate. I would not rank anything there.
For this selector-and-read workload, pick among these three on API and measured dependency facts rather than a presumed speed hierarchy. PyQuery showed no decision-relevant penalty against lxml. Selectolax uses a different parser stack, but this article did not measure its installed footprint, wheel coverage, or build requirements on the same basis.
For contrast, the published bench put two more Python options on the same 10 MB fixture, and they are the ones that actually differ:
| Parser (10 MB) | p50 |
|---|---|
| selectolax (lexbor) | 159.93 ms |
| lxml | 172.93 ms |
| parsel | 231.85 ms |
| selectolax (modest) | 247.95 ms |
| BeautifulSoup + lxml | 2,261.56 ms |
| BeautifulSoup + html.parser | 2,788.75 ms |
Published figures from bench_parse.json.
The historical benchmark rows put BeautifulSoup more than an order of magnitude above the faster parser medians on this fixture. Those rows were not rerun with the current-process PyQuery/lxml pair, so they are useful context rather than a controlled multiplier for the primary verdict.
Cheerio's stored row was 2,927.89 ms (2927.8857 in parser-bench.json) with matching extracted-content hashes. That cross-runtime result also depends on Node, package versions, and the historical run controls; it should not be read as an isolated library-speed multiplier.
Setup reality
| Library | Packages | Disk | Licence | Stars | Last push |
|---|---|---|---|---|---|
| PyQuery | 3 | 20.1 MiB | BSD | 2,380 | 2026-07-27 |
| cheerio (Node) | 22 (npm) | 9.0 MiB | MIT | 30,449 | 2026-08-11 |
Official reference: PyQuery on PyPI.
Three packages is a tidy dependency footprint, and two of them — lxml and cssselect — are things a great many Python scraping projects already carry. In that case PyQuery's marginal cost is a few dozen kilobytes.
The 20.1 MiB is lxml's compiled extensions, not PyQuery. It is the same 20 MiB you pay to use lxml directly.
The package is BSD licensed. At the dated snapshot it had 59 open issues and a push three weeks before testing; those observations alone do not establish maintenance quality or future compatibility.
Memory, and what broken HTML does to it
Two things every review in this batch listed as untested, now measured.
The broader stress-test context is in the ten-library memory and malformed-HTML comparison.
Peak resident memory, via /usr/bin/time -l, one fresh process per cell — the import floor is what the library costs loaded and idle, the peaks include the document.
| Library | Runtime | Import floor | 226 KB peak | 10 MB peak |
|---|---|---|---|---|
| html2text | python3.14 | 18.7 | 19.9 | 71.2 |
| pyquery | python3.14 | 30.3 | 33.9 | 172.5 |
| resiliparse | python3.14 | 20.5 | 25.1 | 225.1 |
| markdownify | python3.14 | 23.9 | 28.9 | 278.5 |
| goose3 | python3.14 | 44.1 | 52.4 | 398.5 |
| cheerio | node22 | 66.8 | 76.5 | 398.5 |
| justext | python3.14 | 30.3 | 36.6 | 431.2 |
| newspaper4k | python3.14 | 52.6 | 61.8 | 668.5 |
| trafilatura | python3.14 | 52.5 | 64.8 | 927.1 |
| turndown | node22 | 47.8 | 68.4 | 2947.1 |
memory-results.json. Python and Node baselines are not comparable to each other; the interpreter is inside both.
PyQuery is lighter than resiliparse on the large document — 172.5 MiB against 225.1 — despite a higher import floor. lxml's tree is compact, and most of PyQuery's 30.3 MiB floor is lxml being loaded rather than anything PyQuery does.
Broken HTML. Twelve documents each breaking exactly one thing — unclosed tags, mis-nested inline elements, unquoted attributes with spaces, stray closers, no <html> at all, duplicate attributes, a document truncated mid-tag, bad entities, an unclosed <script>, a lying charset declaration, a comment containing markup, and 600 levels of nesting — plus two well-formed controls at matched sizes, because "it returned nothing" only says something about malformedness if the library is not also silent on a clean document of the same size.
pyquery raised on 0 of 14 and returned nothing on 1, recovering 10/22 sentinels across the broken fixtures (malformed-results.json). One fixture is excluded from that count: per HTML5 everything after an unclosed <script> is script content, so losing it there is correct and recovering it is the deviation. Without the direct alternatives' sentinel results beside it, 10/22 is a robustness observation rather than a parser-selection ranking.
Pros and cons
In its favour. jQuery syntax, familiar to anyone who has written front-end JavaScript or used cheerio. No decision-relevant selector overhead appeared against raw lxml in this fixture. Only 3 packages, and 2 of them are likely already in your tree. It yields lxml elements, so lxml techniques remain available. BSD. Content hashes matched the reference at all five sizes.
Against it. 20.1 MiB, because lxml. 2,380 stars means a much smaller community than cheerio's 30,449 — fewer worked examples when something is odd. It is a convenience layer, so anything lxml cannot do, it cannot do. And if you were hoping the jQuery API buys performance, it does not: it buys ergonomics, and the parser underneath is the one doing the work.
Who should use it, and who should not
Use PyQuery when you or your team prefer jQuery-style selectors in Python. The measured construction, two selections, and reads showed no decision-relevant penalty against lxml; other PyQuery operations were not timed.
Use lxml directly if you prefer XPath or want one fewer package. This run did not show a selector-speed reason to choose between them.
Evaluate selectolax if its parser API and dependency stack suit your project. The 1 KB row is explicitly unranked, and this article does not support a “smallest dependency” claim.
In Node, cheerio is the analogous API shape. The stored cross-runtime rows were slower here, but runtime and historical-run differences prevent a clean library-only conclusion.
Where a managed API fits
PyQuery parses HTML you already have. It does not fetch, render JavaScript, or handle an anti-bot layer — no parser in this comparison does, and on many real targets that is the harder half.
Author note: Thunderbit is our managed option for URL-in fetching/rendering and extraction. It was not benchmarked against PyQuery here. The relevant boundary is whether you already hold HTML and want local selectors, or want page acquisition and extraction operated as a service.
The honest framing: if you hold the HTML and you know your selectors, PyQuery is free and pleasant. If the selectors keep breaking, or you are fetching at scale, that is a different purchase.
For the wider field, our web scraping API roundup covers hosted options and the open-source scraper pillar the self-hosted ones. If the parsed output feeds a model, converting HTML to Markdown in Python is where fidelity gets lost.
Try Thunderbit for Web Data Extraction
Should you use PyQuery?
Yes, if you want jQuery-style syntax in Python and the measured selector/read path represents your workload.
The benchmark found no decision-relevant selector overhead against lxml while preserving content-hash parity. It did not establish zero cost for the library as a whole.
Across five sizes, the three Python parser medians stayed close enough that API fit is likely to dominate for this task. Define an equivalence margin and rerun the exact alternatives before turning that judgment into a broader parser ranking.
Try Thunderbit for Web Data Extraction Get Started Free
FAQs
Does PyQuery slow lxml down?
No decision-relevant selector overhead appeared in this run. Across five page sizes its medians landed at or below raw lxml, both using lxml 6.1.1 in the same process. At 10 MB the close ranges did not overlap: PyQuery 161.13–163.17 ms and lxml 164.18–169.46 ms. pq(html) builds an lxml tree, and the tested selectors compile through cssselect.
Is selectolax faster than PyQuery? Its 1 KB median was lower, but that row is unranked because variation dominates at microsecond scale. From 10 KB up, median spreads were 0.5%–5.4%. That is close for this workload, not proof of equivalence or overlapping ranges in every case.
Why re-run lxml instead of quoting the published number? Because the published bench records the machine and the Python version but not library versions. Its lxml row might have come from a different lxml than the one PyQuery wraps today, and a version gap would have shown up as a wrapper tax that is not there. Running both in one process at lxml 6.1.1 removes the ambiguity.
How does it compare with cheerio? Same general API idea, different ecosystem. The two selectors exercised here worked in both and content hashes matched at all five sizes; that does not establish full selector compatibility. Cheerio's stored timings were slower, but cross-runtime and historical-run controls prevent a library-only multiplier claim.
What was not tested here? Memory was measured as peak RSS for import-only, a 226 KB document, and a 10 MB document. Malformed HTML was exercised with 12 broken documents plus two matched controls. Still untested are PyQuery's manipulation and traversal performance, repeated-query caching, URL fetching, concurrency, and representative real-site workloads. The 1 KB timing remains unranked.


