Percent-Encoding: The 6 Rules Developers Get Wrong
Most percent-encoding tutorials stop at "encode your spaces as %20 and move on." I've spent the last few weeks auditing encoders across Node and Python codebases, and the same six misconceptions show up everywhere. The RFC 3986 spec is short; the surprises live in the gaps between the spec, the WHATWG URL parser, and what real encoders actually output. Here are the six rules I keep finding broken, each verified with code I ran.
Rule 1: The Unreserved Set Is Smaller Than You Think
RFC 3986 defines exactly 66 unreserved characters: A–Z, a–z, 0–9, -, ., _, ~. Everything else is either reserved (the "gen-delims" : / ? # [ ] @ and "sub-delims" ! $ & ' ( ) * + , ; =) or must be encoded.
Here's the part that trips people: sub-delims are allowed unencoded inside component data, but encoders disagree about which ones they leave alone. I ran both standard encoders on the sub-delim characters:
| Character | encodeURIComponent (Node 24) | quote(..., safe="") (Python 3) |
|---|---|---|
! | ! | %21 |
' | ' | %27 |
( ) | ( ) | %28 %29 |
* | * | %2A |
+ | %2B | %2B |
, ; = | %2C %3B %3D | %2C %3B %3D |
Both outputs are legal per RFC 3986. But "legal" is not the same as "canonical" — if you cache, compare, or sign encoded strings across languages, these differences matter. I once saw a Go-to-Python migration break an analytics pipeline purely because the canonical URL cache keys changed shape. Don't write unit tests that assert one exact encoded form across two languages.
Also note what this means in the other direction: the unreserved set is the only thing that is never encoded. ~, ., -, _ stay raw in every encoder I tested, which is why hand-rolled encoders that also escape them are just wasting bytes. And because the rule is defined on bytes, not characters, anything non-ASCII always gets encoded — that's the UTF-8 pipeline I walk through in the emoji post.
Rule 2: A Space Is %20 in Paths, and + in Form Queries
The +-for-space convention does not come from RFC 3986. RFC 3986 says spaces are %20, and + is an ordinary character. The + convention comes from application/x-www-form-urlencoded — the encoding HTML forms use.
I verified this divergence in both Node and Python:
encodeURIComponent("a b"); // "a%20b"
new URLSearchParams({ q: "a b" }).toString(); // "q=a+b"
from urllib.parse import quote, quote_plus
quote("a b") # 'a%20b'
quote_plus("a b") # 'a+b'
The Asymmetric Decode
The real trap is that decoding is asymmetric. I ran this exact comparison:
decodeURIComponent("a+b"); // "a+b" (plus stays a plus)
new URLSearchParams("q=a+b").get("q"); // "a b" (plus becomes a space!)
from urllib.parse import unquote, unquote_plus
unquote("a+b") # 'a+b'
unquote_plus("a+b") # 'a b'
Same string a+b, two parsers, two different results. A literal plus therefore has to be %2B in both styles: encodeURIComponent("a+b") gives a%2Bb, and Python's quote("a+b") gives a%2Bb. Form serializers agree — I ran new URLSearchParams({ q: "C++" }).toString() and got q=C%2B%2B. The bug I reproduced locally: a search box sends "C++" through an API whose client leaves + unencoded, and the receiving endpoint decodes with the form convention — the server logs a search for "C " (plus turned into space). One line of Python reproduces it:
from urllib.parse import quote, unquote_plus
value = "C++"
sent = quote(value) # 'C%2B%2B' - correct
# but if the client sends 'C++' raw:
unquote_plus("C++") # 'C ' - data destroyed
Rule 3: The Encoder You Pick Changes the Output
Four Encoders, One Input
I fed the same input a b&c+1/2 to every common encoder. Four different strings came out, all of them honestly called "URL-encoded":
| Encoder | Output |
|---|---|
encodeURI("a b&c+1/2?x=1#f") | a%20b&c+1/2?x=1#f |
encodeURIComponent("a b&c+1/2") | a%20b%26c%2B1%2F2 |
new URLSearchParams({q:"a b&c+1/2"}).toString() | q=a+b%26c%2B1%2F2 |
Python quote("a b&c+1/2") | a%20b%26c%2B1/2 |
Python quote_plus("a b&c+1/2") | a+b%26c%2B1%2F2 |
encodeURI leaves &, +, and / alone — harmless in a whole URL, catastrophic inside a query value, where & silently splits your data into a new parameter. encodeURIComponent encodes everything structural. URLSearchParams uses + for spaces. Python's quote keeps / by default, which makes it a path encoder, not a query-value encoder. Here's the runnable version of that comparison:
const input = "a b&c+1/2";
console.log(encodeURI(input)); // a%20b&c+1/2
console.log(encodeURIComponent(input)); // a%20b%26c%2B1%2F2
console.log(new URLSearchParams({ q: input }).toString()); // q=a+b%26c%2B1%2F2
Rule 4: The Percent Sign Is the Most-Encoded Character
A raw % in a URL is not a literal percent — it is the start of an escape. I ran the edge cases:
encodeURIComponent("100%"); // "100%25"
decodeURIComponent("100%"); // throws URIError: URI malformed
decodeURIComponent("a%2"); // throws URIError: URI malformed
The hex digits are just the ASCII code of each character, which is worth computing once by hand so the pattern stops looking magical: space is 0x20 → %20, % is 0x25 → %25, + is 0x2B → %2B, & is 0x26 → %26. A strict parser errors on a dangling %; it doesn't silently pass it through. So "50% off" must always become "50%25 off" on the wire, and any hand-rolled encoder that forgets the %25 step produces URLs that some parsers reject outright.
Rule 5: The URL Parser Has Its Own Opinion
The WHATWG URL parser re-encodes what you put into it using its own percent-encode sets, which do not match encodeURIComponent. I ran this:
new URL("https://ex.com/?q=a!b'()*").search; // "?q=a!b%27()*"
encodeURIComponent leaves ' alone; the URL parser percent-encodes it in a query. So the string the browser actually sends can differ from what encodeURIComponent produced. This is the rule almost no tutorial mentions: when you hand-roll an encoder, you reproduce your mental model of the spec — and it will disagree with the platform in exactly these corner cases. Use the platform's encoder or parser instead of a regex.
Rule 6: Encode Per Component, Not the Whole URL
encodeURIComponent("https://ex.com/a b?q=1"); // "https%3A%2F%2Fex.com%2Fa%20b%3Fq%3D1"
encodeURI("https://ex.com/a b?q=1"); // "https://ex.com/a%20b?q=1"
The first output has destroyed the URL structure entirely; the second works for this particular URL but leaves & in any value untouched. The reliable pattern is to encode each component separately — or build the URL with URL/URLSearchParams and let the platform do it. If you assemble strings by hand, encode the path with one encoder and every query value with another, then join them.
Two neighboring topics keep coming up when I discuss these rules, and each deserves its own post: hex case (%2F vs %2f decodes identically, but servers disagree about what to do with the decoded slash — covered in our server test) and layering (each consumer decodes exactly once, which is why %2520 is sometimes correct by design — covered in our double-encoding guide).
The 60-Second Self-Check
Here is the checklist I now run against any code that touches URLs. Each line takes seconds and has caught a real bug for me:
- Encode the value
"50% off C++"— you should see50%25%20off%20C%2B%2B(or+for spaces if you're deliberately using form encoding). If%or+survived raw, the encoder is incomplete. - Send that same value through the receiving end and decode once — you should get the original back. If you get
50 off Cor an exception, the two sides use different conventions (Rule 2). - Check which characters your encoder leaves alone. If the answer isn't exactly
A–Z a–z 0–9 - . _ ~plus a documented list, write that list down — it's part of your API contract now (Rules 1 and 3). - Feed a dangling
%into your parser. If it doesn't reject or at least flag it, don't trust it with anything untrusted (Rule 4).
Most percent-encoding bugs aren't spec violations; they're two components disagreeing about the spec. Nailing down these six rules turns "it works on my machine" into something you can actually test.
Try Our Tools
The fastest way to check which of these rules your stack is violating is to encode a test string and look at the exact bytes. Try the URL Encoder to see what your data becomes on the wire.
Related reading:
- What Is URL Encoding — the fundamentals behind percent-encoding
- URL Decoding Best Practices — the decoding side of these same rules
— The URL Decode Online Team