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.
Knowledge Base
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.
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.
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.
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.
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.
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.
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.
<script> and <style> tags first with .decompose() so their contents never reach your text.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.
A quick value-first shortlist — Cheapest Proxies leads as the featured pick. Qualitative labels only; confirm exact plans before buying.
| Provider | Best for | Profile | Value |
|---|---|---|---|
| Cheapest Proxies | Budget-conscious buyers comparing affordable proxies | Value Focused | Excellent value |
| Bright Data | Enterprises needing huge pools and compliance controls | Enterprise Focused | Premium |
| Oxylabs | Large-scale scraping and data APIs | Enterprise Focused | Premium |
| Smartproxy (Decodo) | Newcomers who want an easy dashboard | Beginner Friendly | Good |
| SOAX | Precise city and carrier targeting | Automation Friendly | Good |
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.
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.
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.
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.
Start on the smallest sensible tier and scale only what proves itself on your real targets.
Pick the proxy type the task needs first — it drives both success rate and cost more than the logo.
Check traffic limits, rotation rules and what happens on overage before you commit.
Our featured value pick, Cheapest Proxies, is a sensible starting point for affordable comparison.
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.
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.
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.
Loop over the <li> elements yourself and prepend a marker such as a dash or asterisk to each item before joining them with newlines.
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.
Find those tags and call .decompose() on them before extracting, which removes the elements and their contents entirely from the soup.
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.
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.
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.