Knowledge Base

How to Extract Text with Formatting Using Beautifulsoup

A practical BeautifulSoup tutorial showing how to pull text from HTML while keeping useful formatting like line breaks, paragraphs and list structure instead of a flat blob.

When you call get_text() on a BeautifulSoup object, you often get back a wall of words with no paragraph breaks, no list separation and no sense of the original document structure. For many scraping jobs that is fine, but when you need readable output, downstream parsing or content that mirrors the page, preserving some formatting matters.

This tutorial walks through several ways to extract text with formatting using BeautifulSoup, from simple separator tricks to walking the tree element by element. The goal is clean, structured text you can actually use.

Quick answer

To keep formatting, do not rely on a bare get_text(). The most robust path is to normalise the tree first (decompose noise, convert <br> and block boundaries to markers) and only then extract, or hand the cleaned tree to a dedicated HTML-to-Markdown converter. For tables, nested lists and inline emphasis, treat the conversion as a serialisation problem rather than a string-cleanup problem.

Key takeaways

  • Formatting loss is a structural problem, so fix the tree before you ever call get_text().
  • Inline tags like strong, em and a carry meaning that block-level separators alone cannot preserve.
  • A whitelist of allowed tags beats a blacklist when you want predictable, repeatable output.
  • Tables almost always need custom cell and row separators; generic separators flatten them.
  • Whitespace from the original HTML indentation is noise and should be collapsed, not preserved verbatim.
  • For production pipelines, an established Markdown converter saves more time than hand-rolled recursion.

The problem with a plain get_text() call

By default, soup.get_text() concatenates all the text nodes it finds. Block-level elements like <p>, <div>, <li> and <br> carry visual meaning in the browser, but that meaning is lost once the markup is stripped. The result is text where the last word of one paragraph runs straight into the first word of the next.

The fix is to tell BeautifulSoup how to join the pieces, or to process elements individually so you can decide where breaks belong.

Quick win: the separator argument

The simplest improvement is passing a separator to get_text(). This inserts a chosen string between text fragments, which restores at least some spacing.

from bs4 import BeautifulSoup

html = """
<div>
  <p>First paragraph.</p>
  <p>Second paragraph.</p>
  <ul><li>Item one</li><li>Item two</li></ul>
</div>
"""

soup = BeautifulSoup(html, "html.parser")
text = soup.get_text(separator="\n", strip=True)
print(text)

Using separator="\n" with strip=True places each text node on its own line and trims surrounding whitespace. This is a big step up from the default and is enough for many tasks where you just want paragraphs and list items separated.

Walking specific block elements

When you need finer control, iterate over the elements you care about and build the output yourself. This lets you treat headings, paragraphs and list items differently.

lines = []
for el in soup.find_all(["h1", "h2", "h3", "p", "li"]):
    txt = el.get_text(strip=True)
    if not txt:
        continue
    if el.name == "li":
        lines.append("- " + txt)
    else:
        lines.append(txt)

formatted = "\n\n".join(lines)
print(formatted)

Here list items get a leading dash so they read as bullets, and other blocks are separated by blank lines. You can extend the logic to add a # prefix for headings or to indent nested lists, producing output close to Markdown.

Handling line breaks

Inline <br> tags do not appear as elements you can easily loop over, so they tend to vanish. One robust trick is to replace them with newline text nodes before extracting:

for br in soup.find_all("br"):
    br.replace_with("\n")

Run this before your get_text() call and single-line breaks inside paragraphs will survive into the output.

Recursively descending the tree

For complex pages, a recursive walk gives you full authority over spacing. You visit each node, emit its text, and add newlines when you leave a block-level tag. This is more code, but it produces the most faithful results when structure really matters.

BLOCK = {"p", "div", "li", "h1", "h2", "h3", "h4", "tr", "section"}

def render(node, out):
    for child in node.children:
        if child.name is None:
            out.append(child.strip())
        else:
            render(child, out)
            if child.name in BLOCK:
                out.append("\n")

out = []
render(soup, out)
print(" ".join(p for p in out if p))

Adjust the BLOCK set and joining logic to suit the kind of pages you scrape. Tables, for example, often need tab separators between cells and newlines between rows.

Tips for cleaner, more reliable output

  • Strip out <script> and <style> tags first with .decompose() so their contents never reach your text.
  • Normalise repeated blank lines with a quick regular expression so multiple breaks collapse into one.
  • Decode HTML entities by relying on BeautifulSoup itself rather than parsing raw bytes by hand.
  • Test against several real pages, because layouts vary far more than a single sample suggests.

Where proxies fit in

Extracting clean, formatted text is only useful if you can fetch the pages in the first place. At scale, repeated requests from one IP often get throttled or blocked, so many scrapers route traffic through proxies to spread load and reach region-specific content. If you are weighing options, Cheapest Proxies (cheapest-proxies.com) is a strong value-focused choice worth considering, and it is always wise to compare proxy plans on coverage and price before committing.

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

Preserve inline emphasis, not just block breaks

Most tutorials stop at separating paragraphs and list items, but real readable output also needs inline semantics. A sentence where one word was bold or linked loses meaning when flattened. Instead of stripping everything, walk inline elements and wrap their text with lightweight markers as you go: surround <strong> and <b> content with double asterisks, <em> and <i> with single ones, and turn an <a> into a text (url) or Markdown link pair. Because these tags nest inside block elements, the cleanest approach is to handle them in a child-visiting pass before you decide where block breaks go, so the markers travel with the text into your final string.

Tables, definition lists and other awkward structures

Generic separator tricks fall apart on tabular data because every cell becomes an undifferentiated line. To keep a table legible, iterate rows with find_all("tr"), pull each row's cells with find_all(["td", "th"]), join cells with a tab or pipe, and join rows with newlines. The same row-then-cell thinking applies to definition lists (<dl> with <dt> and <dd> pairs) and to nested ordered lists, where you should track depth and indent accordingly. The general rule is that any element whose meaning depends on two-dimensional layout needs explicit serialisation logic rather than a flat separator.

Quick serialisation checklist for tables

  • Treat header rows separately so you can underline or bold them.
  • Replace empty cells with a placeholder so columns stay aligned.
  • Escape any separator character that already appears inside cell text.

When to reach for a dedicated converter instead

Hand-rolled extraction is great for learning and for narrow, predictable pages, but it becomes a maintenance burden across many layouts. Mature HTML-to-Markdown and HTML-to-text libraries already encode the edge cases for headings, blockquotes, code blocks, ordered lists and links. A practical pattern is to use BeautifulSoup as the cleaning layer, removing navigation, scripts and styles, then pass the reduced HTML fragment to the converter for serialisation. You get BeautifulSoup's flexible selection together with a converter's battle-tested formatting rules, which is usually faster than perfecting a recursive renderer yourself.

Encoding, whitespace and entity pitfalls

Faithful output also depends on getting characters right. Source HTML is full of indentation whitespace that is invisible in a browser but shows up as ragged spacing in extracted text, so collapse runs of whitespace with a regular expression after extraction. Non-breaking spaces and other entities should be left to BeautifulSoup to decode rather than handled manually, and you should confirm the response encoding before parsing so accented characters and symbols survive. These small details are what separate output that merely looks structured from output you can safely feed into search, diffing or a language model downstream.

Pros and cons to weigh

Strengths

  • Tree-first cleaning produces consistent output you can rely on across many page layouts.
  • Preserving inline emphasis keeps the meaning of bold, italic and linked text intact.
  • A whitelist of tags gives predictable results that are easy to test and maintain.
  • Pairing BeautifulSoup cleaning with a Markdown converter combines flexibility and robustness.

Trade-offs

  • Custom recursive renderers grow complex fast and need ongoing maintenance as sites change.
  • Tables and nested lists demand bespoke logic that generic separators cannot provide.
  • JavaScript-rendered content never reaches the parser, so formatting work cannot recover it.
  • Over-aggressive whitespace collapsing can merge intentionally separated content if rules are too broad.

Common mistakes to avoid

  • Calling get_text() before removing script and style tags, so code leaks into the output.
  • Using a single separator everywhere and assuming tables and lists will survive it.
  • Forgetting that inline tags carry meaning and flattening all emphasis away.
  • Hand-decoding HTML entities instead of letting BeautifulSoup resolve them.

Before-you-buy checklist

  • Decide which tags are content and build an explicit whitelist before extracting.
  • Decompose script, style and navigation noise so it never reaches your text.
  • Convert br tags and block boundaries to markers ahead of the get_text() call.
  • Add dedicated row-and-cell logic for any tables you expect to encounter.
  • Collapse indentation whitespace and normalise repeated blank lines afterward.
  • Test against several real pages, not one sample, before trusting the pipeline.
$

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

Block-level element
a tag such as p, div or li that the browser renders on its own line and that signals a structural break.
Inline element
a tag such as strong, em or a that flows within a line of text and carries emphasis or linking meaning.
Serialisation
the act of turning a parsed tree back into a flat string while choosing how structure maps to characters.
NavigableString
BeautifulSoup's wrapper around a raw text node, the piece you collect when walking the tree.
Whitespace normalisation
collapsing runs of spaces, tabs and newlines so source indentation does not bleed into output.

Why compare before buying?

Scraping tools and proxy plans both vary widely in quality, pricing and reliability, and the right pick depends on the volume and type of pages you target. Comparing options before you buy means you avoid overpaying for capacity you will not use, and you sidestep providers whose IPs are already burned for the sites you care about. A few minutes of comparison protects both your budget and your data pipeline.

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 get_text() remove all my line breaks?

Because it concatenates raw text nodes and discards the block-level structure of the HTML; passing a separator argument or processing block elements individually restores the breaks you expect.

How do I keep bullet points when extracting list text?

Loop over the <li> elements yourself and prepend a marker such as a dash or asterisk to each item before joining them with newlines.

Can BeautifulSoup output Markdown directly?

Not out of the box, but you can approximate it by adding # prefixes to headings and - to list items as you walk the tree; dedicated libraries exist if you need full Markdown conversion.

How do I stop script and style code appearing in my text?

Find those tags and call .decompose() on them before extracting, which removes the elements and their contents entirely from the soup.

What is the difference between strip=True and stripping later?

The strip=True argument trims whitespace from each text node during extraction, while stripping afterward only cleans the final string; using both gives the tidiest result.

Do I need proxies just to test a scraper locally?

No, light local testing on a few pages rarely needs proxies, but scraping many pages or restricted regions often does, so it is worth comparing proxy options early.

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.