urllib.parse.quote() Double-Encoding Corrupts Already-Encoded Values (tested)
urllib.parse.quote() percent-encodes a string for safe use in a URL. We measured what happens when it's applied to a value that's already percent-encoded — which happens easily when a URL fragment gets copied or re-processed — and quote() has no way to tell the difference.
1. quote() isn't idempotent — calling it twice changes the result again
from urllib.parse import quote
s = "a b"
print(quote(s))
print(quote(quote(s)))
a%20b
a%2520b
Measured: the second quote() call doesn't leave an already-safe string alone. It encodes the % character from the first pass into %25, since % is itself not URL-safe. There's no detection of "this is already encoded" — quote() just encodes whatever string it's given, every time.
2. The realistic trigger: re-quoting a value pulled from an existing URL
from urllib.parse import quote, unquote
already_encoded = "caf%C3%A9" # "café" as it appears in a URL path
print(quote(already_encoded))
print(unquote(quote(already_encoded)))
caf%25C3%25A9
caf%C3%A9
Measured: this is the common real-world path — code that extracts a path segment or query value from one URL and passes it through quote() again before building a new request, "just to be safe." The result decodes back to the literal string caf%C3%A9, not café. No exception anywhere in the chain; the server on the other end just receives the wrong value, with % characters where there should be decoded bytes.
3. Fix: quote exactly once, from the unencoded source
raw = "café"
print(quote(raw))
# if you only have the already-encoded form, decode first, then quote once
normalized = quote(unquote(already_encoded))
print(normalized)
caf%C3%A9
caf%C3%A9
Measured: encoding directly from the original, unencoded string works correctly. If the only value available is already percent-encoded and you need to normalize or re-encode it, quote(unquote(value)) confirmed round-tripping correctly — decode back to the real characters first, then encode exactly once. The bug is specifically calling quote() on something that's already been through quote() (or arrived pre-encoded), not quote() itself.
Frequently asked questions
Why does calling quote() twice break my URL value?
We measured the second quote() call re-encoding the % character left over from the first pass, turning a correctly-encoded string into a double-encoded one that decodes back to the wrong value.
Is urllib.parse.quote() idempotent?
No — we confirmed quote(quote(s)) produces a different, incorrect result from quote(s) whenever the string contains characters that needed encoding the first time.
How do I safely re-encode a URL value that might already be percent-encoded?
We verified unquote() first, then quote() exactly once (quote(unquote(value))), correctly round-trips back to the original encoded form without double-encoding.
Does quote() raise an error on an already-encoded string?
No — in our test it ran silently and returned a syntactically valid but wrong percent-encoded string, with no indication the input was already encoded.
What's a common real-world cause of double-encoding bugs with quote()?
We measured the typical trigger being code that extracts a value from an existing URL (already percent-encoded) and runs it through quote() again before building a new URL, instead of using the value as-is or decoding it first.
Written with AI assistance. Measured on 2026-10-04 with Python 3.13.5's built-in urllib.parse module: ran quote() twice on the same value, and separately on a value that was already percent-encoded (as if copied from an existing URL), then decoded the result with unquote(). PlainHeaders is our extension; Python's urllib.parse module is built in.