html.unescape

Not a simple table lookup: it decodes the way an HTML5 browser does — 2231 names, some valid without the semicolon, longest-prefix matching, Windows-1252 for €–Ÿ, and U+FFFD for references that cannot be characters.

html functionPython 3.4+Live demo
Common call
html.unescape('Tom & Jerry')
Returns
'Tom & Jerry'
Replaces
hand-written replace() chains and HTMLParser().unescape (removed in 3.9)
Watch out
it decodes references only — it does not remove tags
html.unescape(ss — Text containing character references. A str without any & is returned unchanged; bytes raise TypeError.type: str · required)
→ str

Demo

Live evaluation
Type HTML text with references.
Try:
Inputs
textstrtext with &...; references
Code
import html
html.unescape('<p>Café © 2026 — €5</p>')
Result
'<p>Café © 2026 — €5</p>'

Only 106 old names (eacute, copy, amp, lt …) are in the table without a semicolon; hearts is not, so "&hearts" stays as it is. When a name is unknown, the longest known prefix wins: "&notit;" decodes the legacy name not and keeps "it;". References 128–159 are taken as Windows-1252 (150 is an en dash), 0, surrogates (D800–DFFF) and values above 10FFFF become U+FFFD (�), and most control characters, such as 7, are dropped. Names are case-sensitive: Lt; is ≪, not <.

Parameters

NameTypeRequiredDescription
sstryesText containing character references. A str without any & is returned unchanged; bytes raise TypeError.

Return value

str — The text with every recognised reference replaced by its character(s). Unrecognised references are left as they are.

Common patterns

Clean up text from a feed or API
Titles and descriptions often arrive HTML-encoded.
import html
title = html.unescape(item["title"]).strip()
Text content of an HTML snippet
HTMLParser decodes references in text for you (convert_charrefs=True is the default).
from html.parser import HTMLParser

class TextOnly(HTMLParser):
    def __init__(self):
        super().__init__()
        self.parts = []
    def handle_data(self, data):
        self.parts.append(data)

p = TextOnly()
p.feed(snippet)
text = "".join(p.parts)
Undo double encoding
Unescape until nothing changes — only for data you know was escaped more than once.
import html
while True:
    decoded = html.unescape(s)
    if decoded == s:
        break
    s = decoded

Examples

1. Named references
import html html.unescape('&copy; 2026 caf&eacute;')
Returns
'© 2026 café'
2. Decimal and hex
import html html.unescape('&#60;b&#62; &#x3C;i&#X3E;')
Returns
'<b> <i>'
3. Astral characters
import html html.unescape('&#x1F600; &#128512;')
Returns
'😀 😀'
4. Some names work without ;
import html html.unescape('&eacute &copy')
Returns
'é ©'
5. Most names need the ;
import html html.unescape('&hearts &hearts;')
Returns
'&hearts ♥'
6. Windows-1252 remapping
import html html.unescape('&#128; &#150; &#153;')
Returns
'€ – ™'
7. Impossible code points → U+FFFD
import html html.unescape('&#0;|&#xD800;|&#x110000;')
Returns
'�|�|�'
8. A bare & is left alone
import html html.unescape('AT&T R&D')
Returns
'AT&T R&D'

Pitfalls

1. Unescaping once is not enough for double-encoded data
Text escaped twice (&amp;lt;) decodes to an escaped string after one pass. Find out where the second escape happens rather than unescaping blindly.
one pass
import html
html.unescape('&amp;lt;b&amp;gt;')
'&lt;b&gt;'
two passes (known double encoding)
import html
html.unescape(html.unescape('&amp;lt;b&amp;gt;'))
'<b>'
2. Unescaping text that is then put back into HTML
unescape() can turn harmless escaped text into live markup. Re-escape before output.
unescape → page
import html
html.unescape('&lt;script&gt;x()&lt;/script&gt;')
'<script>x()</script>'
escape before output
import html
html.escape(html.unescape('&lt;script&gt;x()&lt;/script&gt;'))
'&lt;script&gt;x()&lt;/script&gt;'
3. Expecting a non-breaking space to be a space
&nbsp; decodes to U+00A0, which looks like a space but is not equal to one. str.split() treats it as whitespace; == and replace(" ") do not.
== ' '
import html
html.unescape('&nbsp;') == ' '
False
replace it
import html
html.unescape('a&nbsp;b').replace('\xa0', ' ')
'a b'

When to use

Use it
  • Turning HTML-encoded text (scraped pages, RSS, API fields) back into plain text
  • Comparing or searching text that may contain references
Reach for something else
  • Removing tags → html.parser.HTMLParser (or a full parser library)
  • Percent-encoded URLs (%20) → urllib.parse.unquote
  • XML documents → xml.etree / xml.sax.saxutils.unescape (XML has only five named entities)

Notes

CPython impl
One regex, &(#[0-9]+;?|#[xX][0-9a-fA-F]+;?|[^\t\n\f <&#;]{1,32};?), substituted with a function that looks the match up in html.entities.html5 — see Lib/html/__init__.py
Numeric rules
Following the HTML5 tokenizer: 0x80–0x9F map through Windows-1252, 0 and anything that is not a valid code point give U+FFFD, noncharacters and most C0/C1 controls are removed
Huge numbers
A decimal reference longer than 4300 digits raises ValueError: int() refuses to convert it (the integer string conversion limit); hex references have no such limit
Inverse
unescape(escape(s)) == s always; escape(unescape(s)) == s only if s contained no references that escape() would not produce

FAQ

html.unescape(text). It decodes named references like &copy;, decimal (&#169;) and hex (&#xA9;) references. Older code used HTMLParser().unescape, which was removed in Python 3.9.