Vài tháng một lần, lại có một trình phân tích HTML nhanh hơn xuất hiện, các bảng benchmark được chia sẻ khắp nơi, rồi ai đó tuyên bố “đội cũ” đã lỗi thời. Nhưng đến lúc bạn cần chọn mọi đoạn văn có chứa một từ nào đó, hoặc lấy phần tử cha của một node khớp điều kiện, bạn sẽ lại hiểu vì sao lxml vẫn đang mở ở tab bên cạnh.
lxml là một binding của libxml2 đã có 20 năm tuổi. Nó không bóng bẩy. Nó không mới. Và với một nhiệm vụ rất cụ thể — mọi thứ cần XPath “đúng nghĩa” — gần như không có lựa chọn Python phổ biến nào cạnh tranh được. Đây là bài đánh giá thực chiến về những gì nó làm được, nơi nó âm thầm tỏa sáng, và vài chỗ mặc định của nó có thể khiến bạn “vấp” nếu không biết trước.
lxml trong một đoạn ngắn: Thực chất nó là gì?
lxml là binding Python cho các thư viện C libxml2 và libxslt. Nó là trình phân tích và trình xuất dữ liệu, không phải web scraper và cũng không phải trình duyệt — nó biến markup thành một cây dữ liệu để bạn truy vấn và chỉnh sửa, rồi chuyển cây đó trở lại thành bytes. Nó cung cấp API tương thích với ElementTree, một engine XPath 1.0 đầy đủ, XSLT 1.0 và xác thực schema, được Stefan Behnel duy trì với khẩu hiệu "thư viện giàu tính năng và dễ dùng nhất để xử lý XML và HTML trong ngôn ngữ Python".
Đây là bức tranh hiện tại, dựa trên snapshot GitHub và PyPI được lấy vào 2026-07-14:
| Trường | Giá trị |
|---|---|
| Repo | lxml/lxml |
| Stars | 3,043 |
| Forks | 620 |
| Open issues | 16 |
| License | BSD-3-Clause |
| Created | 2011-02-11 |
| Last push | 2026-07-02 |
| PyPI stable | 6.1.1 (2026-05-18) |
| Bundled engine | libxml2 2.14.6 + libxslt 1.1.43 |
Điều cần nói rõ ngay để tránh ai đó bảo tôi “thổi phồng”: bài đánh giá này không có bí mật nào ẩn giấu. lxml đủ “lâu đời” để mọi hành vi được nhắc đến ở đây đều đã được ghi trong tài liệu lxml, changelog của libxml2, hoặc một chủ đề Launchpad nào đó. Tôi không phát hiện mẹo độc quyền hay undocumented trick nào, và cũng không định bịa ra. Giá trị của phần sau nằm ở chỗ nó được hệ thống hóa, định lượng và tổ chức xoay quanh lxml như một chủ thể — chứ không phải vì nó là tin mới.
Thiết lập kiểm thử (và vì sao số timing là dữ liệu mượn)
Bài đánh giá này dùng hai nhóm dữ liệu, đến từ hai nguồn khác nhau, nên tôi nói rõ ngay từ đầu.
Các kiểm thử năng lực — hành vi XPath, hai API parser, namespace, encoding, vòng đời node — tôi chạy mới trên một máy: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Mọi con số trong các file artifacts/raw/*.json đều được tính bởi script khi chạy, chứ không nhập tay. Các bài test năng lực là những giá trị boolean và enum mang tính xác định, nên chỉ cần một lượt chạy là đủ ổn định — tải máy không làm thay đổi việc //a/@href có trả về chuỗi thuộc tính hay không.
Còn số timing và footprint bộ nhớ không đến từ bộ bài này. Chúng được dùng lại nguyên văn từ bộ benchmark selectolax trước đó — cùng máy, cùng virtual environment, cùng bản build lxml và libxml2, benchmark tính đến 2026-07-13 — và tôi không chạy lại ở đây. Đây là chủ ý. Chạy benchmark timing cùng lúc với một loạt script kiểm thử năng lực sẽ dễ gây tranh chấp CPU, làm méo số liệu được dùng lại, và cũng là làm trùng việc: trong bộ trước, lxml vốn đã là thư viện đối chứng được đo đầy đủ rồi. Dùng lại cùng bộ số giúp so sánh táo với táo thay vì tạo thêm một phép đo thứ hai với chút khác biệt khó nhận ra. Vì vậy khi bạn thấy số mili-giây bên dưới, hãy hiểu là “cùng rig kiểm thử, tính đến 2026-07-13”, chứ không phải “tôi vừa đo lại hôm nay”.
Mỗi phát hiện đều có nhãn độ tin cậy: single-observation cho các bài test xác định, triple-run cho các phân phối timing được dùng lại, và hypothesis khi tôi đề xuất một cơ chế nhưng chưa cô lập nó.
XPath: Điều mà selectolax và BeautifulSoup đơn giản là không có
Đây là điểm nhấn, nên tôi bắt đầu từ đây.

Tôi đưa xpath() của lxml qua một ma trận 37 mục đã đăng ký trước — kết quả mong đợi cho từng trường hợp đều được viết vào source trước khi chạy test, để tôi không thể vô thức chấm điểm “nới tay”. Mười axis, chín kiểu predicate, mười hàm built-in, ba kiểu giá trị trả về dạng scalar, và năm case bẫy cố ý dùng cú pháp chỉ có ở XPath 2.0, thứ mà engine 1.0 của lxml đáng lẽ phải từ chối.
| Hạng mục | Phạm vi | Kết quả |
|---|---|---|
| Axes | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 pass |
| Predicates | [1] / last() / position()<n / so khớp thuộc tính / kiểm tra thuộc tính tồn tại / and / or / nested [.//a] / not() | 9/9 pass |
| Functions | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 pass |
| Return types | boolean / number scalars | 3/3 pass |
| Trap cases | matches() / sequences / if-then-else / except / lỗi cú pháp | 5/5 bị từ chối đúng cách |
Điểm số là 37/37, và cột “trap” mới là phần đáng chú ý. matches(), sequence expressions, if/then/else, và except đều là cú pháp XPath 2.0, và engine 1.0 của libxml2 không “nửa vời” hỗ trợ chúng — nó ném ra XPathEvalError và từ chối hẳn, thay vì âm thầm trả về một node set sai. Vì vậy đây là điểm tuyệt đối sau khi đã cố tình phá, chứ không phải điểm tuyệt đối nhặt từ những câu dễ. Mọi hành vi ở đây đều đúng như tài liệu XPath của lxml mô tả, và đó chính là điều cần kiểm chứng.
Tôi cũng xin nhận một lỗi của harness, vì đây là dạng “37/37” mà bạn mới có thể tin thật. Bộ expected đầu tiên của tôi cho //div[.//a[@href]] dự đoán hai kết quả; thực tế chỉ có một. Tôi tưởng lxml sai khoảng ba mươi giây, rồi kiểm tra fixture và phát hiện phần tử thứ hai là <footer>, không phải <div> — vậy là kỳ vọng của tôi sai, không phải engine. Tôi sửa expected set và để lại ghi chú lỗi trong source. Trình tự đổ lỗi đúng là: nghi ngờ chính test của mình trước khi nghi ngờ thư viện C 20 năm tuổi.
XPath so với CSS: Những thứ CSS về cơ bản không thể diễn đạt
Lời khẳng định “XPath mạnh hơn CSS” nghe khá trừu tượng, nên tôi đã định lượng khoảng cách đó. lxml cho bạn cả .xpath() và .cssselect() (cái sau chuyển CSS sang XPath ở bên dưới). Tôi lấy mười mục tiêu chọn lọc và kiểm tra xem CSS có biểu đạt được không.

| Mục tiêu | XPath | CSS (cssselect) |
|---|---|---|
Lọc theo nội dung text (contains(text(),"bargain")) | Có | Không có text predicate |
Chọn parent từ child (//b/parent::p) | Có | Không có parent selector |
Trả về giá trị thuộc tính (//a/@href) | Có | Chỉ trả về elements |
Trả về text node (//p/text()) | Có | Không hỗ trợ text nodes |
Ancestor axis (//td/ancestor::div) | Có | Không có điều hướng lên trên |
Lọc parent theo số lượng child (//ul[count(li)=4]) | Có | Không có count predicate |
Lọc theo độ dài text (string-length(text())>5) | Có | Không có length predicate |
nth-child / last-child / adjacent sibling | Có | Có (3 nền tảng cơ bản) |
Bảy trong mười mục tiêu hoàn toàn không có tương đương CSS. Lọc theo nội dung text, đi ngược lên cha và tổ tiên, lấy trực tiếp giá trị thuộc tính hay một text node trơn làm kết quả, predicate dựa trên số lượng — CSS không diễn đạt được. Chỉ ba thứ (nth-child, last-child, adjacent sibling) chạy được ở cả hai bên. Đó là câu trả lời đã được định lượng cho câu hỏi “tôi được gì khi chọn lxml”. selectolax chỉ có CSS và không có xpath() nên bảy kiểu truy vấn này либо phải biến thành nhiều bước loop Python, либо không làm được. Nếu logic scraping của bạn phụ thuộc vào bất kỳ thứ nào trong số đó, lựa chọn đã rõ.
(Và vâng, harness lại bắt lỗi tôi lần nữa ở đây: tôi dự đoán một tập rỗng cho string-length(text())>5, nhưng thực tế có hai chuỗi dài sáu ký tự khớp. Sửa kỳ vọng, không sửa công cụ.)
Ba mức độ “khắt khe”: etree vs recover vs lxml.html
XPath là lý do để chọn lxml. Ba chế độ strictness khác nhau là lý do để giữ nó.

Phần lớn parser chỉ cho bạn một hành vi khi input bị lỗi. lxml cho bạn ba, và chúng đủ dự đoán được để tôi đưa sáu loại markup lỗi qua từng chế độ và đăng ký trước cách từng đường đi phải phản ứng.
| Input lỗi | lxml.etree (strict) | etree + recover=True | lxml.html (lenient) |
|---|---|---|---|
Tag chưa đóng <root><a>x</root> | raise | recover | accept |
Lồng sai thứ tự <b><i></b></i> | raise | recover | accept |
Entity chưa định nghĩa | raise | recover | accept |
Dấu & trần Tom & Jerry | raise | recover | accept |
Nhiều root <a>1</a><b>2</b> | raise | recover | accept |
| XML hợp lệ | accept | accept (0 errors) | accept |
Thuộc tính boolean <input disabled> | raise | recover | accept |
Bảy trên bảy khớp với kỳ vọng đã đăng ký trước. lxml.etree ném XMLSyntaxError ở cả sáu nhóm lỗi. Thêm recover=True vào cùng parser đó thì nó nuốt lỗi và dựng lại một cây dùng được — và đây là phần hay bị đánh giá thấp — parser.error_log sau đó liệt kê toàn bộ lỗi mà nó đã nuốt. lxml.html thì chấp nhận tất cả mà không phàn nàn.
Bộ phân loại quyết định “raise vs recover vs accept” bản thân nó cũng dựa vào độ dài error_log lúc chạy, không phải hard-code, nên một tài liệu hợp lệ khi chạy dưới recover=True vẫn được gắn đúng nhãn “accept” (log rỗng) thay vì “recover”. Phiên bản đầu tiên của bộ phân loại tôi từng gắn mọi kết quả recover=True là “recover” và dán sai nhãn cho input sạch; đọc error_log thực sự đã sửa được điều đó.
Ý nghĩa thực tế: nếu bạn cần validate nghiêm khắc để feed hỏng phải fail to, dùng lxml.etree. Nếu là HTML bẩn ngoài đời chỉ cần “đi qua cho xong”, dùng lxml.html. Và trường hợp trung gian mà nhiều công cụ không làm được — “hãy nới tay, nhưng cho tôi biết chính xác nó hỏng ở đâu để log lại” — dùng recover=True và đọc error log. selectolax có chế độ lenient, và thế thôi; không có strict mode, cũng không có error log.
iterparse: Cơ chế streaming mà selectolax hoàn toàn không có
Đây là một khả năng, không phải một nút tăng tốc. selectolax chỉ nhận một chuỗi toàn bộ — không có giao diện incremental. iterparse của lxml trả ra các element khi chúng đóng lại, và khi ghép với mẫu fast_iter kinh điển (gọi elem.clear() và xóa sibling đứng trước khi bạn đi tiếp), nó giữ bộ nhớ phẳng dù tài liệu có lớn đến đâu.

Tôi đo trực tiếp đặc tính bộ nhớ này — peak RSS qua ru_maxrss, mỗi đối tượng chạy trong một process mới riêng, trên 300,000 phần tử <record> tổng dung lượng khoảng 15 MB.
| Chế độ | Peak RSS delta | Ghi chú |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | giải phóng dần; phẳng bất kể số lượng |
iterparse không clear | ~386 MB | giữ reference; nặng như load toàn bộ |
etree.parse (load toàn bộ, mốc đối chứng) | ~386 MB | nặng đúng như dự đoán; chứng minh đồng hồ đo phản ánh đúng biên độ |
Chế độ bị giới hạn giữ peak RSS delta ở khoảng 1-2 MB so với mức ~386 MB của load toàn bộ — chênh lệch cỡ 0,3-0,4% — và event của record đầu tiên xuất hiện trước khi file đọc xong, nên đây thực sự là streaming incremental, không phải “streaming giả”. Dòng đáng học nhất là dòng giữa. Chạy đúng vòng lặp iterparse đó nhưng bỏ clear(), bộ nhớ lại leo về ~386 MB, vì bạn đang giữ reference cho mọi thứ. Lợi thế nằm ở clear(), không phải bản thân iterparse. Mốc load toàn bộ cao hơn rất nhiều so với chế độ giới hạn cũng xác nhận rằng đồng hồ RSS thật sự nhìn thấy chênh lệch kích thước, chứ không “mù số”. (Bài test bộ nhớ này tôi chạy trong bộ này — nó là phép đo footprint, khác với các số timing được dùng lại.)
Phiên bản thực tế của điều này: một file XML export nhiều GB không thể chứa trong RAM thì selectolax không có đường đi nào cả. Hoặc dùng parser streaming của lxml, hoặc chuyển sang một ngôn ngữ khác.
Namespace: RSS, SVG, và cái bẫy default namespace
Mười hai case namespace, bao gồm RSS qua ba namespace, SVG với default namespace cộng xlink, và XML dùng default namespace. Cả mười hai đều pass.
lxml kéo //dc:creator/text() từ một RSS feed ra đúng ["Alice", "Bob"], resolve //atom:link/@href và //content:encoded qua ba namespace tách biệt trong cùng một tài liệu, xử lý //s:rect và //s:use/@xlink:href trong namespace thứ hai của SVG, tách tên Clark-notation {uri}local bằng QName, và kiểm tra qua nsmap. Đây là hành vi được duy trì và ghi tài liệu, và cũng là một chiều kích mà selectolax không chạm tới, vì selectolax chỉ dành cho HTML5 và không xử lý các XML namespace tùy ý.
Có một cái bẫy đã được tài liệu hóa mà bạn nên ghi nhớ. XPath không có khái niệm default namespace. Nếu bạn trỏ //book vào một tài liệu khai báo xmlns="urn:..." thì sẽ không có kết quả nào — tiền tố rỗng là không xác định trong XPath, như tài liệu lxml nói rõ. Bạn phải gán một prefix giả (//c:book với namespaces={"c": "urn:..."}, và nó tìm thấy cả ba) hoặc quay về //*[local-name()='book'] (cũng ba). Không phải bug — đó là XPath spec, được triển khai trung thực. Chỉ là nó làm mọi người ngạc nhiên đúng một lần.
Những trang web bẩn ngoài đời: độ chính xác trên 11 trang scrape thật
Các test tổng hợp thì sạch; web thực tế thì không. Tôi dùng lại mười một trang được capture thật từ bộ fixture của selectolax (tính đến 2026-07-10, chỉ đọc) và đưa lxml.html chạy qua chúng với lxml là đối tượng chính.
| Fixture | Size | Links | libxml2 recovered errors | Strict XML |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | raised |
| wiki_scraping.html | 227 KB | 460 | 0 | raised |
| gov_whitehouse.html | 289 KB | 154 | 0 | raised |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | raised |
| forum_reddit.html | 129 KB | 318 | 0 | raised |
| docs_python.html | 80 KB | 341 | 2 | raised |
| ecommerce_books.html | 51 KB | 94 | 0 | raised |
| news_hackernews.html | 35 KB | 229 | 0 | raised |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | raised |
| spa_quotes_js.html | 6 KB | 5 | 0 | raised |
Tất cả mười một đều parse được bằng lxml.html, và số link, heading, image khớp với số lxml được dùng lại từ bộ selectolax ở cả mười một case — cross-check true. Sự đồng thuận này cho tôi biết việc dùng lại số liệu là thật sự táo với táo, chứ không phải hai phép đo khác nhau nhưng khoác cùng nhãn.
Phát hiện phụ là strict XML parser đã ném lỗi ở mười trong số mười một trang. Phần lớn trang web ngoài đời không phải XML hợp lệ, và chính vì thế chế độ HTML recovery của libxml2 tồn tại để “nuốt” chúng. Ngoại lệ duy nhất là BBC News, được render bởi Next.js và đủ “chuẩn” để sống sót qua strict XML parsing. Không phải thứ gì gắn nhãn “HTML” cũng cần đến recovery path.
Một lưu ý đếm số rất dễ nhầm. Trên docs_python.html, //a[@href] (kiểm tra thuộc tính tồn tại) đếm được 343, còn bộ selectolax dùng if n.get("href") (giá trị truthy) đếm được 341. Hai mục thừa là các link href="" rỗng. Đây là khác biệt về quy ước đếm — thuộc tính có tồn tại hay thuộc tính có giá trị không rỗng — chứ không phải khác biệt hành vi của lxml, và số liệu sẽ khớp khi bạn đồng bộ predicate. Rất đáng nhớ khi scrape: link href rỗng có được tính hay không là lựa chọn của bộ lọc, không phải của parser.
Giới hạn độ sâu trông giống bug, nhưng không phải
Bộ selectolax trước đó đã ghi nhận lxml làm rớt nội dung sâu nhất trên markup <div> lồng 1,000 và 5,000 tầng, và diễn đạt như thể “lxml lặng lẽ làm mất nội dung sâu nhất.” Tôi muốn biết cơ chế, nên đã chạy parser mặc định cùng với huge_tree=True.

| Độ sâu yêu cầu | Parser mặc định đạt tới | huge_tree=True đạt tới |
|---|---|---|
| 300 | 253 (mất phần còn lại) | 299 (khôi phục) |
| 1000 | 253 (mất phần còn lại) | 999 (khôi phục) |
| 5000 | 253 (mất phần còn lại) | 2045 (vẫn mất) |
Parser mặc định cắt ở khoảng 253 tầng và âm thầm bỏ phần sâu hơn. Đó không phải bug — đó là cơ chế phòng DoS của libxml2, một giới hạn lồng nhau khoảng 256 tầng để ngăn tài liệu độc hại làm tràn stack, và điều này đã được nêu trong chủ đề Launchpad của lxml về XML_PARSE_HUGE. Đặt huge_tree=True thì độ sâu 300 và 1,000 đều được khôi phục đầy đủ. Tuy nhiên ở độ sâu 5,000, ngay cả khi bật huge_tree, nó cũng chỉ đạt 2,045 — nghĩa là có thêm một ngưỡng recursion cứng hơn ở tầng libxml2, và huge_tree không gỡ được.
Vì vậy hành động cụ thể là: khi bạn parse markup sâu từ nguồn đáng tin, hãy dùng lxml.html.HTMLParser(huge_tree=True). Điểm bổ sung mà bộ này cung cấp so với quan sát được dùng lại là cơ chế (một safety cap, không phải hỏng dữ liệu), cách khắc phục (huge_tree), và thực tế là vẫn còn một ngưỡng thứ hai mà cách khắc phục này không chạm tới.
DOM đọc/ghi, serialization, encoding
lxml là một cây dữ liệu đọc/ghi đầy đủ, không phải extractor chỉ đọc, và tôi đã kiểm tra bề mặt chỉnh sửa theo từng case. Tất cả tám thao tác DOM đều pass: SubElement, insert, remove, replace, strip_tags (bỏ tag nhưng giữ text), strip_elements (bỏ cả tag lẫn text của nó), drop_tree (một tính năng riêng của lxml.html), và mô hình text/tail hai ô thường làm người mới nhầm lẫn — trong <p>head<b>bold</b>tail</p>, p.text là "head", b.text là "bold", còn b.tail là "tail".
Serialization cũng pass đủ năm trên năm: tostring ở chế độ XML và HTML (HTML đúng cách không tự đóng void elements), pretty_print, canonical hóa C14N (method="c14n", cũng là tính năng riêng của lxml), và round-trip sạch.
Encoding là nơi lxml âm thầm tách mình ra. Cho nó bytes không phải UTF-8 — "<p>café éè</p>".encode("latin-1") qua lxml.html.fromstring — nó khôi phục nguyên vẹn café éè, không có ký tự thay thế U+FFFD, không rơi mất byte nào. Điều này trực tiếp khớp với vai trò “reference sạch” của nó trong bộ selectolax trước đó, nơi cùng input đó đã bị hỏng âm thầm ở hai engine còn lại (Lexbor sinh ra ký tự thay thế, Modest thì bỏ byte hẳn). Cơ chế nhận diện charset của lxml dựa trên libxml2 đơn giản là ổn định hơn ở đây.
Mặt còn lại là sự nghiêm ngặt về cách bạn khai báo encoding. encoding="latin-1" trong XML declaration sẽ ném XMLSyntaxError: Unsupported encoding: latin-1, trong khi tên chuẩn IANA encoding="ISO-8859-1" thì parse bình thường và trả về café. libxml2 chỉ chấp nhận tên encoding chuẩn hóa, không chấp nhận alias — một chi tiết đã được ghi lại từ launchpad #613302. Khó chịu nếu bạn không biết, nhưng rất đơn giản một khi đã biết.
Cuối cùng là vòng đời node. Tôi chạy ba kịch bản handle “cũ” trong các subprocess cô lập (nếu crash thật sự xảy ra thì exit code sẽ khác 0): giữ một node sau khi tree bị garbage-collect, đọc handle sau drop_tree(), và dùng node sau remove(). Không có segfault nào trong cả ba — lxml giữ reference của node tới tree để tránh use-after-free. selectolax cũng đạt trạng thái an toàn như vậy trong bài test này.
Tốc độ và bộ nhớ (dùng lại số liệu, và nói thật về điều đó)
Tất cả nội dung trong phần này được dùng lại từ bộ selectolax, tính đến 2026-07-13. Bộ này không tự tạo ra bất kỳ số timing nào, và tôi thà nói điều đó hai lần còn hơn để bạn tưởng tôi vừa đo lại.
| Hạng mục | Giá trị lxml | Cách hiểu |
|---|---|---|
| Pure parse p50 (10 MB) | 77.9 ms | nhanh hơn khoảng 33-34% so với selectolax-Lexbor |
| Full parse + extract p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | gần như ngang Lexbor ở dữ liệu nhỏ |
| Throughput CSS 100k node | 3,002,646 nodes/s | nhóm nhanh nhất trong ba engine C |
| 10 MB RSS delta | 128.9 MB | tiết kiệm bộ nhớ nhất trong sáu parser, nhẹ hơn BeautifulSoup khoảng 1.7x |
| Import cold start | 14.1 ms | nhanh hơn khoảng 2.3x so với kiểu import parsel |
Các số pure-parse và throughput rất mạnh, và lxml là parser tiết kiệm bộ nhớ nhất trong sáu parser được đo. Tuy vậy, bức tranh threading cần một ghi chú. Dữ liệu được dùng lại cho thấy tốc độ tăng chỉ 1.21x khi chạy 4 threads, được đánh dấu là chưa kết luận — nhưng đó là đường đi với parser dùng chung. FAQ của lxml nói rõ rằng GIL chỉ được thả trong lúc parse khi mỗi thread dùng parser riêng của mình (hoặc bản sao của default parser); shared parser sẽ tuần tự hóa truy cập. Tôi đã xác minh về mặt API rằng cách làm đúng là có thể (XMLParser.copy() tồn tại, get/set_default_parser tồn tại, XPathEvaluator có khóa nội bộ), nhưng tôi không đo speedup theo từng thread parser — đó sẽ là một phép đo timing mới, và bộ này không tạo ra những số như vậy. Vì thế hãy đọc “1.21x” như “trên đường đi naive dùng parser chung”, chứ không phải trần threading của lxml.
Và cũng cần một dấu sao cho tất cả các số này: đây là số trên một nền tảng duy nhất, macOS arm64. Khẳng định rằng pure parse của lxml nhanh hơn Lexbor đi ngược lại quan điểm phổ biến rằng parser dựa trên Lexbor là nhanh nhất, nên điều đó thực sự cần được kiểm tra lại trên Linux x86_64 trước khi ai đó coi nó là kết luận cuối cùng.
Giấy phép: Điểm cộng nhàm chán nhưng đáng giá
lxml phát hành dưới BSD-3-Clause, và các thư viện C nó đóng gói — libxml2 và libxslt — đều là MIT. Đây là chuỗi giấy phép hoàn toàn permissive, không có copyleft ở bất kỳ mắt xích nào, và điều đó rất quan trọng ngay khi bạn phân phối lại. Để so sánh, wheel selectolax đóng gói Modest dưới LGPL-2.1 và Lexbor dưới Apache-2.0, nên lxml là câu chuyện “sạch” hơn nếu bạn ship trong một sản phẩm đóng.
Còn có một lợi thế thực dụng khi cài đặt: lxml phát hành sẵn wheel được link tĩnh với libxml2 và libxslt, nên pip install lxml thường không cần system libxml2 và cũng không cần compiler trên máy bạn — trải nghiệm khác hẳn so với tự build từ source.
lxml phù hợp ở đâu — và lớp trích xuất AI sẽ thay thế ở đâu
Đến lúc nói rõ ranh giới, vì đây là một nhầm lẫn rất dễ gặp. lxml là thư viện phân tích cú pháp. Nó đưa cho bạn một cây dữ liệu và một query engine rất mạnh, còn mọi thứ xoay quanh cây đó vẫn là việc của bạn: lấy trang về, render JavaScript, vượt anti-bot, viết và bảo trì XPath, rồi cấu trúc kết quả. Đó là một lớp khác với dịch vụ trích xuất được host sẵn, và hai thứ này không phải đối thủ mà là hàng xóm.
Với một developer không muốn tự gánh cả stack fetch-render-select-maintain, lớp phía trên là nơi một thứ như Thunderbit xuất hiện — và với độc giả ở đây, đó là API, MCP server và CLI, chứ không phải browser extension. Thunderbit Open API cung cấp POST /distill để biến một trang thành Markdown sạch và POST /extract để lấy dữ liệu có cấu trúc theo JSON Schema, với tùy chọn renderMode và batch jobs cho khối lượng lớn. Cùng engine đó cũng có sẵn dưới dạng MCP server (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) cho agent và coding assistant, và dưới dạng CLI bạn có thể chạy thẳng từ terminal qua npx @thunderbit/thunderbit-cli. Nó xử lý JS rendering, anti-bot và CAPTCHA ngay từ đầu và trả về JSON khớp schema — tức là lớp nằm phía trên parsing, chứ không thay thế parsing.
Dùng thử Thunderbit để trích xuất dữ liệu web
Cách nhìn này rất đơn giản. Chọn lxml khi bạn sở hữu pipeline và muốn kiểm soát XPath một cách chính xác trên một cây dữ liệu mà bạn hiểu rõ. Chọn API trích xuất AI khi bạn không muốn tự duy trì selector và rendering nữa. Rất nhiều hệ thống thực tế dùng cả hai — lxml cho các feed có cấu trúc mà họ kiểm soát, và dịch vụ trích xuất cho những trang “đuôi dài” rối rắm mà họ không kiểm soát.
Bài đánh giá này không thử gì
Đây là một bài đánh giá tạm thời, không phải bảng chấm điểm cuối cùng, nên đây là những gì nó chưa bao quát.
Tất cả số timing và memory đều là số dùng lại, trên một nền tảng duy nhất (macOS arm64, Python 3.14), và mang theo các lưu ý của bộ trước — kết quả “lxml nhanh hơn ở pure parsing” đi ngược đồng thuận phổ biến và cần được kiểm tra lại trên Linux x86_64. Speedup của parser theo từng thread chưa được test (vì sẽ cần timing mới). Tôi đo bộ nhớ iterparse trên 300k records nhưng không đo XML thực tế cỡ GB, không đo iterparse trên HTML so với XML, và cũng không làm bài soak nhiều giờ. XSLT 1.0 của lxml, các cơ chế validation RelaxNG / XMLSchema / DTD, và EXSLT extensions đều chưa được test ở đây — một bề mặt năng lực lớn, nhưng nằm ngoài lõi parsing và selection. Tôi quan sát được ngưỡng độ sâu thứ hai ở 2,045 nhưng chưa xác định hằng số recursion chính xác của libxml2. Chỉ bản stable 6.1.1 được test, không phải alpha 7.0.0. Windows, bản build từ source, và bản 3.14t free-threaded đều chưa được test. Và ngay trong XPath, tôi có kiểm tra các hàm built-in nhưng chưa test biến XPath, custom extension functions bằng Python, hay việc tái sử dụng các đối tượng etree.XPath được biên dịch trước.
Kết luận
lxml không phải là “thứ mới nhanh nhất”, và chính điều đó mới là lý do để khuyên dùng nó. Đây là binding libxml2 đã tồn tại hai thập kỷ, có engine XPath 1.0 đầy đủ mà không lựa chọn Python phổ biến nào sánh được, ba chế độ strictness khi parse có thể dự đoán được với error log ở giữa, một parser streaming thực sự cho tài liệu không vừa bộ nhớ, hỗ trợ đa namespace và encoding chuẩn xác, cùng một giấy phép hoàn toàn permissive. Vài điểm nhọn — ngưỡng độ sâu khoảng 253 và con số threading với parser dùng chung — đều đã được ghi tài liệu, có thể điều chỉnh, và giờ thì đã được giải thích.
Nếu bạn sở hữu pipeline scraping của mình và phụ thuộc vào XPath, lxml vẫn là parser đáng chọn. Nếu bạn không muốn tự duy trì selector và rendering, đó là việc của một lớp trích xuất AI như Thunderbit API, MCP và CLI — phân công công việc rõ ràng, không phải cuộc đấu. Dù chọn gì, hãy xem các con số này là tạm thời và kiểm tra lại timing trên nền tảng của chính bạn trước khi dẫn chúng vào tài liệu thiết kế.
Dùng thử Thunderbit để trích xuất dữ liệu web Get Started Free
Câu hỏi thường gặp
lxml có phải là web scraper không?
Không. lxml là một parser và serializer — binding Python cho libxml2/libxslt, biến markup thành một cây dữ liệu có thể chỉnh sửa và truy vấn. Nó không tự lấy trang, không render JavaScript, cũng không xử lý anti-bot; bạn phải cung cấp lớp request (qua requests, httpx, browser headless, hoặc dịch vụ scraping) rồi đưa bytes cho lxml.
Khi nào nên dùng lxml thay vì BeautifulSoup hoặc selectolax? Hãy chọn lxml khi bạn cần XPath. BeautifulSoup có thể dùng lxml làm parser backend nhưng không có XPath gốc, còn selectolax chỉ dùng CSS và nhanh hơn trong phạm vi hẹp của nó. Nếu logic chọn lọc của bạn cần lọc theo text, đi lên parent hoặc ancestor, trích xuất thuộc tính/text node, hoặc dùng predicate theo số lượng, thì engine XPath của lxml là lựa chọn Python phổ biến duy nhất diễn đạt trực tiếp được.
Vì sao lxml âm thầm bỏ nội dung lồng quá sâu?
Parser mặc định của nó giới hạn độ lồng khoảng 253 tầng — đây là cơ chế phòng DoS của libxml2 trước tài liệu độc hại, không phải bug. Đặt huge_tree=True (ví dụ lxml.html.HTMLParser(huge_tree=True)) thì nó khôi phục đầy đủ độ sâu 300 và 1,000. Lưu ý vẫn còn một ngưỡng recursion cứng hơn quanh 2,045 tầng mà huge_tree không gỡ được.
lxml có thả GIL khi parse đa luồng không? Chỉ trong điều kiện phù hợp. FAQ của lxml nói rằng GIL được thả trong lúc parse khi mỗi thread dùng parser riêng hoặc dùng bản sao của default parser; nếu dùng chung parser thì truy cập sẽ bị tuần tự hóa. Mức tăng 1.21x trong dữ liệu dùng lại phản ánh đường đi naive dùng parser chung, không phản ánh trần tốc độ khi mỗi thread có parser riêng, vì phần đó chưa được đo ở đây.
lxml còn được duy trì trong năm 2026 không? Có. Bản stable 6.1.1 phát hành vào 2026-05-18, repo được push lần cuối vào 2026-07-02, và bản alpha 7.0.0 đang được phát triển. Với khoảng 3.000 GitHub stars và libxml2 vẫn được bảo trì tích cực bên dưới, đây vẫn là một thư viện hiện tại, được hỗ trợ tốt, chứ không phải di sản cũ.


