urllib.parse.unquote(text) replaces every %XX escape with the byte it stands for and decodes the bytes as UTF-8. It leaves + alone; unquote_plus reads a + as a space, the way HTML forms and application/x-www-form-urlencoded bodies mean it.
from urllib.parse import unquote, unquote_plus
text = "caf%C3%A9+%26+cake"
print(unquote(text)) # café+&+cake
print(unquote_plus(text)) # café & cake
The tool below decodes the same text both ways: Decoded is unquote, As a form sends it is unquote_plus.
Which one to use
Use unquote_plus for query strings and form bodies, and unquote for paths and anything that came from quote. A + in a path is a literal plus, so unquote_plus would corrupt a file name such as C++ notes.txt. An encoded plus, %2B, comes out of both as +.
Bad escapes don’t raise
Unlike JavaScript’s decodeURIComponent, unquote never raises on input. A % without two hex digits stays as written, and bytes that aren’t valid UTF-8 become � (U+FFFD):
unquote("100%") # '100%'
unquote("%zz") # '%zz'
unquote("%E0%A4") # '�', a 3-byte character cut short
unquote("%E0%A4", errors="strict")
# UnicodeDecodeError: 'utf-8' codec can't decode bytes in position 0-1: unexpected end of data
Pass errors="strict" when you’d rather reject broken input than store a replacement character. The tool below rejects it too and names the escape.
Query strings: parse_qs and parse_qsl
Don’t split a query string by hand. parse_qs returns a dict of lists, parse_qsl a list of pairs in order, and both decode with + as a space:
from urllib.parse import parse_qs, parse_qsl, urlsplit
url = "https://example.com/search?q=caf%C3%A9+%26+cake&tag=a&tag=b&empty="
query = urlsplit(url).query
parse_qs(query) # {'q': ['café & cake'], 'tag': ['a', 'b']}
parse_qs(query, keep_blank_values=True) # {'q': ['café & cake'], 'tag': ['a', 'b'], 'empty': ['']}
parse_qsl(query) # [('q', 'café & cake'), ('tag', 'a'), ('tag', 'b')]
dict(parse_qsl(query)) # {'q': 'café & cake', 'tag': 'b'}: the last tag wins
By default both drop a parameter with an empty value, so empty= disappears; keep_blank_values=True keeps it as ''. Only & separates pairs: parse_qs("a=1;b=2") is {'a': ['1;b=2']} unless you pass separator=";". For a query that comes from strangers, max_num_fields=100 raises ValueError instead of reading more than 100 pairs.
Decode once
unquote(unquote("a%2520b")) is 'a b', though the data meant a%20b. Frameworks such as Django and Flask hand you query values already decoded; don’t decode them again.