Python's urllib.parse splits encoding across several functions rather than one, and two of the defaults are easy to get wrong in ways that don't error — they just quietly produce the wrong string. The general encoding rules are unchanged from URL Encoding Explained; this is about where Python's API surface differs from what encodeURIComponent does in one call.
quote() leaves / unescaped by default
quote() takes a safe argument — characters to leave alone even though they'd normally be escaped — and it defaults to safe="/", not an empty string:
from urllib.parse import quote
quote("a/b/c")
# 'a/b/c' — the / is left alone by default
quote("a/b/c", safe="")
# 'a%2Fb%2Fc' — escaped, once you override the defaultThis is the opposite default from JavaScript's encodeURIComponent, which always escapes /. It makes sense for encoding a whole path at once (where / is the segment separator you want preserved), but it's a real bug if you use plain quote() on a single path segment that happens to contain a literal / — pass safe="" explicitly when encoding one value, not a whole path.
quote() vs. quote_plus(): the space question again
Same split as covered in URL Encoding in Node.js for Node's two APIs — Python has the equivalent split, just as two separate functions instead of two modules:
quote("hello world") # 'hello%20world'
quote_plus("hello world") # 'hello+world'quote_plus is the one built for query strings — it follows the same application/x-www-form-urlencoded convention discussed in URL Encoding Explained, and it's what urlencode (below) uses internally.
The decode-side mismatch this creates
The two encoders have matching decoders — unquote for quote, unquote_plus for quote_plus — and using the wrong one on the way back out doesn't error, it just leaves a stray + in the result instead of turning it into a space:
from urllib.parse import unquote, unquote_plus
unquote("hello+world") # 'hello+world' — unchanged, + is not special to unquote
unquote_plus("hello+world") # 'hello world' — decoded correctlyIf a value was encoded with quote_plus (or built via urlencode) but decoded with plain unquote, every encoded space silently survives as a literal + character in the output. Match the pair you used to encode.
urlencode() and the doseq trap
urlencode builds a full query string from a dict, using quote_plus under the hood. For a value that's a list — meant to become a repeated key — it needs an explicit flag, or it encodes the list's Python string representation instead of repeating the key:
from urllib.parse import urlencode
urlencode({"tag": ["js", "css"]})
# 'tag=%5B%27js%27%2C+%27css%27%5D' — wrong: that's "['js', 'css']", encoded as one string
urlencode({"tag": ["js", "css"]}, doseq=True)
# 'tag=js&tag=css' — right: doseq=True expands the list into repeated keysForgetting doseq=True doesn't raise anything — the query string comes out syntactically valid, just carrying a nonsense value for that key.
parse_qs groups repeated keys automatically
On the parsing side, both parse_qs and parse_qsl handle repeated keys without any extra flag — the same behavior Node's querystring.parse has, and the opposite of what URLSearchParams.get() does in JavaScript (see How Query Parameters Work):
from urllib.parse import parse_qs, parse_qsl
parse_qs("tag=js&tag=css")
# {'tag': ['js', 'css']}
parse_qsl("tag=js&tag=css")
# [('tag', 'js'), ('tag', 'css')]One character-set difference worth knowing
Python's quote() escapes ! * ' ( ) by default; JavaScript's encodeURIComponent leaves that exact set unescaped (covered in What Characters Need URL Encoding?). Both outputs are valid and decode to the same value — but if you're diffing an encoded string produced by a Python service against one produced by a JS client, don't expect them to match byte-for-byte even for identical input.
Try it yourself
Check what a value encodes to with URL Encode and URL Decode, or build a full query string — including repeated keys — with Query Parameter Builder. All run entirely in your browser.