URL decode in Python

urllib.parse.unquote(text)

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.

Try it in the URL Encoder & Decoder

Decoded

Every escape read as bytes, with a + left as a plus.

As a form sends it

The same text with + read as a space, which is how an HTML form posts a query string.

Runs in your browser. Nothing you enter is sent anywhere.