Knowledge Base

How to Get Text Using Lxml

A practical guide to extracting text content from HTML and XML with lxml, covering element text, tail text, full-subtree text, and XPath string functions.

When you parse a page with lxml, the markup becomes a tree of elements, and the words you actually want to read live inside those elements. Knowing how to pull that text out cleanly is one of the most common tasks in any scraping or data-extraction project.

This tutorial walks through the main ways to get text using lxml, from grabbing a single element's text to collecting the full readable content of a whole branch of the tree, plus the gotchas that trip people up.

Quick answer

For a single node use .text, for a whole branch use text_content() or the XPath string() function, and for streamed pieces use itertext(). Beyond the basics, the real wins come from handling tail text correctly, stripping comments and scripts before extraction, and deciding early whether you want raw nodes or a cleaned, joined string.

Key takeaways

  • <code>itertext()</code> walks comments and processing instructions too unless you filter the tag, so guard against junk nodes
  • The <code>with_tail</code> argument on <code>etree.tostring(method="text")</code> controls whether trailing tail text is included
  • For very large HTML files, <code>etree.iterparse</code> with text extraction beats loading the entire tree into memory
  • XPath <code>normalize-space(string(.))</code> trims and collapses whitespace in one engine-side step, no regex needed
  • Script and style tags hide CSS and JavaScript that <code>text_content()</code> will happily concatenate into your output
  • Encoding matters: pass bytes (not a decoded str) to the parser when the document declares its own charset

Setting Up lxml

If you have not installed the library yet, add it to your environment first. lxml is a fast, C-backed parser that handles both HTML and XML, which is why it is so popular for web data work.

pip install lxml

from lxml import html, etree

source = """
<div class="card">
  <h2>Proxy Basics</h2>
  <p>Rotating <b>residential</b> IPs help with blocks.</p>
</div>
"""

tree = html.fromstring(source)

Here html.fromstring() returns the root element of the parsed fragment. From this point on, every text operation works against that tree.

Getting Text from a Single Element

The simplest property is .text. It returns only the text that appears immediately after the element's opening tag and before its first child, not the text inside nested children.

p = tree.xpath('//p')[0]
print(p.text)   # 'Rotating '

Notice that .text stops at the <b> child. It does not include "residential" because that word belongs to the child element, not the paragraph itself. This surprises a lot of beginners.

Understanding .tail

Every element also has a .tail attribute: the text that follows the element's closing tag but still sits inside the parent. The "residential" word's container has a tail of " IPs help with blocks." Together, .text and .tail let lxml represent mixed content faithfully.

Getting All Text in a Subtree

Most of the time you want every readable word inside an element, regardless of nesting. The cleanest method is text_content(), available on HTML elements.

p = tree.xpath('//p')[0]
print(p.text_content())   # 'Rotating residential IPs help with blocks.'

This concatenates the element's own text, all descendant text, and the tail text in document order. It is the go-to call when you simply want the visible string of a block.

Using itertext()

If you need the pieces rather than one joined string, iterate over them:

for chunk in p.itertext():
    print(repr(chunk))

This yields each text node separately, which is handy when you want to filter, strip, or rejoin them with custom separators.

Extracting Text with XPath

XPath gives you a declarative way to target text directly. Two patterns matter most.

  • //h2/text() selects the direct text nodes of matching elements, returning a list of strings.
  • string(//div) or the per-node .xpath('string(.)') returns the full collapsed text of a node, similar to text_content().
heading = tree.xpath('//h2/text()')
print(heading)            # ['Proxy Basics']

full = tree.xpath('string(//div)')
print(full.strip())       # 'Proxy Basics Rotating residential IPs help with blocks.'

Use text() when you want individual nodes and string() when you want one merged result.

Cleaning the Output

Raw extracted text often carries leftover whitespace and line breaks from the source markup. A small normalisation step keeps your data tidy.

import re

raw = p.text_content()
clean = re.sub(r'\s+', ' ', raw).strip()

For XML rather than HTML, parse with etree.fromstring() instead of the html module. The text methods behave the same, though XML is stricter about well-formed input and namespaces.

Why This Matters for Scraping

Reliable text extraction is the foundation of clean datasets. When you combine lxml with rotating proxies, you reduce blocks while you collect pages at scale, and the parsing logic above turns each fetched response into structured, usable text.

Comparison snapshot

A quick value-first shortlist — Cheapest Proxies leads as the featured pick. Qualitative labels only; confirm exact plans before buying.

ProviderBest forProfileValue
Bright DataEnterprises needing huge pools and compliance controlsEnterprise FocusedPremium
OxylabsLarge-scale scraping and data APIsEnterprise FocusedPremium
Smartproxy (Decodo)Newcomers who want an easy dashboardBeginner FriendlyGood
SOAXPrecise city and carrier targetingAutomation FriendlyGood

Stripping Noise Before You Extract

The cleanest way to avoid menu text, scripts and inline styles leaking into your output is to remove those subtrees before you ever call a text method. lxml ships a dedicated cleaner and a simple strip helper for exactly this.

from lxml import html, etree

tree = html.fromstring(source)
etree.strip_elements(tree, 'script', 'style', with_tail=False)

text = tree.text_content()

The with_tail=False flag is important: without it, lxml keeps the tail text that followed each removed tag, which can leave stray fragments behind. For heavier sanitisation, lxml.html.clean.Cleaner can drop comments, embedded objects and forms in one pass, leaving you with readable prose only.

Filtering itertext() to Real Content

People assume itertext() yields only words, but it also surfaces comment nodes and processing instructions when they carry text. You can pass tag names to restrict it, or test the parent tag of each chunk to skip the ones you do not want.

wanted = []
for node in tree.iter():
    if node.tag in ('script', 'style'):
        continue
    if node.text and node.text.strip():
        wanted.append(node.text.strip())

Iterating elements yourself, rather than calling a blanket joiner, gives you a hook to attach the source tag, class or XPath to each piece of text, which is invaluable when you later need to know where a string came from.

Whitespace and Unicode the Engine Way

Instead of post-processing with a regex, you can push normalisation into XPath. normalize-space() collapses internal runs of whitespace and trims the ends, all inside the C engine.

clean = tree.xpath('normalize-space(string(//div[@class="card"]))')

Be aware that normalize-space treats non-breaking spaces and other Unicode whitespace differently from Python's str.strip(). If your source uses &nbsp; heavily, you may still want a follow-up pass that replaces   before storing the result, otherwise downstream comparisons and de-duplication can quietly fail.

Streaming Text Out of Huge Documents

When a file is too big to hold in memory, build the tree incrementally and clear elements as you finish with them. This keeps memory flat even across enormous catalogues or exports.

for _, el in etree.iterparse('big.html', html=True, tag='p'):
    print(el.text_content().strip())
    el.clear()

The el.clear() call frees the children you have already read. For maximum thrift, also delete preceding siblings so the root does not accumulate emptied shells. This pattern turns a script that would crash on a multi-gigabyte file into one that runs in constant memory.

Pros and cons to weigh

Strengths

  • lxml exposes text at every granularity, from a single node to a streamed iterator, so you rarely need a second library
  • Pushing trimming and normalisation into XPath keeps extraction fast and avoids brittle regex chains
  • The strip and clean helpers remove scripts and styles cleanly, preventing code from polluting your text
  • Incremental parsing makes constant-memory text extraction possible on very large files

Trade-offs

  • The <code>.text</code> versus <code>.tail</code> split confuses newcomers and silently drops text on mixed content
  • <code>text_content()</code> is an HTML-element method, so XML workflows need <code>itertext()</code> or <code>string()</code> instead
  • Default whitespace handling differs from Python's, so non-breaking spaces can survive a naive strip
  • Blanket joiners discard the structure that tells you where each string originated

Common mistakes to avoid

  • Calling <code>text_content()</code> without removing script and style tags first, then wondering why CSS appears in the output
  • Assuming <code>itertext()</code> returns only visible words when it can also yield comment and PI text
  • Decoding bytes to a string before parsing a document that declares its own encoding, causing mojibake
  • Relying on <code>str.strip()</code> alone and leaving non-breaking spaces that break later de-duplication

Before-you-buy checklist

  • Decide whether you need separate text nodes or one merged string before choosing a method
  • Strip script, style and comment nodes if the page has them
  • Pick <code>text_content()</code> for HTML and <code>string()</code> or <code>itertext()</code> for XML
  • Add a normalisation step and confirm it handles non-breaking spaces
  • For files over a few hundred megabytes, switch to <code>iterparse</code> with <code>clear()</code>
  • Keep a reference to source tags or XPaths if provenance will matter later
$

How to get the best value

Right-size the plan

Start on the smallest sensible tier and scale only what proves itself on your real targets.

Type before brand

Pick the proxy type the task needs first — it drives both success rate and cost more than the logo.

Read the fine print

Check traffic limits, rotation rules and what happens on overage before you commit.

Lead with value

Our featured value pick, Cheapest Proxies, is a sensible starting point for affordable comparison.

📖

Key terms explained

Tail text
the run of text that sits after an element's closing tag but still inside its parent, captured by lxml's <code>.tail</code> attribute
Mixed content
markup where text and child elements interleave, the situation that makes <code>.text</code> alone insufficient
Subtree
an element together with all of its descendants, the unit that <code>text_content()</code> flattens into one string
iterparse
an event-driven parser that lets you process and discard elements as they are read, keeping memory low
normalize-space
an XPath function that trims leading and trailing whitespace and collapses internal runs into single spaces

Why compare before buying?

The parsing library is only half of a scraping stack; the network layer matters just as much. Different proxy providers vary widely on price, pool quality and location coverage, so comparing options on value before you commit can save real money. For budget-conscious projects, Cheapest Proxies is a strong value-focused option worth considering alongside other vendors.

How we compare

Compare Proxy Zone weighs providers on value, fit and reliability using qualitative judgement — never invented prices, speeds or uptime figures. See our review methodology, or email info@compareproxyzone.com with a correction.

?

Frequently asked questions

Why does .text return only part of my paragraph?

Because .text returns only the text before the first child element. Nested children have their own .text and .tail, so use text_content() to capture the whole subtree.

What is the difference between text_content() and string() in XPath?

Both collapse a subtree into one string, but text_content() is an HTML-element method while string() is an XPath function; results are usually equivalent for simple HTML.

How do I get a list of separate text pieces instead of one string?

Use element.itertext() to iterate over each text node, or element.xpath('.//text()') to get a list you can filter and rejoin.

Does text_content() work on XML parsed with etree?

It is primarily an HTML method; for XML, use ''.join(node.itertext()) or the XPath string() function to achieve the same result.

How do I remove extra whitespace from extracted text?

Apply re.sub(r'\s+', ' ', text).strip() to collapse runs of whitespace and trim the edges into clean, single-spaced output.

Why does my XPath text() query return an empty list?

The element likely has no direct text node, or the text sits inside a child. Try a broader path like .//text() or use string(.) instead.

Compare on value, then decide

For affordable proxies across the main types, our featured value pick is Cheapest Proxies — a strong budget-friendly option worth considering. Check the exact plan before ordering.