JavaScript has two built-in percent-encoders, and the difference between them comes down to one assumption. encodeURI assumes you're giving it a whole URL, so it leaves alone the characters that give a URL its structure. encodeURIComponent assumes you're giving it one piece of data that will sit inside a URL, so it escapes those same characters. Use the wrong one and the URL still looks fine. It just means something else.
You can try every example here in the URL encoder and decoder. Its Encoding menu offers encodeURIComponent, encodeURI, "Spaces as +" (form encoding) and double encoding, and when a decode fails it points to the line and column of the broken escape. The outputs below come from running the code in Node.js 24 and Python 3.14.
What each function leaves unescaped
Both functions convert text to UTF-8 and percent-encode every byte except letters, digits and a fixed set of punctuation. These are the sets, found by passing every printable ASCII character through each one:
| Characters | encodeURI | encodeURIComponent | URLSearchParams |
|---|---|---|---|
A–Z a–z 0–9 - . _ * | kept | kept | kept |
~ ! ' ( ) | kept | kept | encoded |
; , / ? : @ & = + $ | kept | encoded | encoded |
# | kept | %23 | %23 |
| space | %20 | %20 | + |
" % < > [ \ ] ^ ` { | } | encoded | encoded | encoded |
| Non-ASCII | UTF-8 bytes | UTF-8 bytes | UTF-8 bytes |
So encodeURI keeps eleven characters that encodeURIComponent escapes: # $ & + , / : ; = ? @. Those are exactly the characters that separate the parts of a URL, which is the point of the function and the source of every bug it causes.
How this compares with RFC 3986
RFC 3986, the URI standard, splits characters into unreserved ones (A–Z a–z 0–9 - . _ ~), which never need encoding, and reserved ones that act as delimiters: : / ? # [ ] @ and ! $ & ' ( ) * + , ; =. The ECMAScript spec notes that its URI functions are based on RFC 2396, the 1998 predecessor, and don't reflect RFC 3986. That's why encodeURIComponent leaves ! ' ( ) * alone, and why encodeURI escapes the square brackets that RFC 3986 uses for IPv6 hosts:
encodeURI("http://[::1]:8080/")
// "http://%5B::1%5D:8080/" the IPv6 host is now broken
If a signature scheme or a strict server needs the RFC 3986 form, escape the leftovers yourself:
const encodeRFC3986 = (s) =>
encodeURIComponent(s).replace(/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
encodeURIComponent("it's (really) *great*!") // "it's%20(really)%20*great*!"
encodeRFC3986("it's (really) *great*!") // "it%27s%20%28really%29%20%2Agreat%2A%21"
Why encodeURI breaks query strings
The common bug is building a URL with a template string and passing the result through encodeURI to make it "safe":
const q = "salt & pepper";
const tag = "C++";
const url = encodeURI(`https://shop.example/search?q=${q}&tag=${tag}&sort=price`);
// "https://shop.example/search?q=salt%20&%20pepper&tag=C++&sort=price"
[...new URL(url).searchParams]
// [["q", "salt "], [" pepper", ""], ["tag", "C "], ["sort", "price"]]
Both values were damaged. The & in the search term was left alone, so it split the query and created a parameter called " pepper". The + signs survived too, and query parsers read + as a space, so "C++" arrived as "C" followed by two spaces. A # is worse: q=issue #42&sort=new becomes ?q=issue%20#42&sort=new, and everything from the # on is now the fragment, which the browser never sends to the server.
The fix is to encode each value, not the finished URL:
const url = `https://shop.example/search?q=${encodeURIComponent(q)}&tag=${encodeURIComponent(tag)}&sort=price`;
// "https://shop.example/search?q=salt%20%26%20pepper&tag=C%2B%2B&sort=price"
encodeURI has one narrow legitimate use: a complete, correctly structured URL that contains spaces or non-ASCII characters, such as https://example.com/docs/naïve café.html. Even there, new URL(input).href gives the same result for this example and also checks that the URL is valid. Never pass an already-encoded URL through it, because it encodes the % as well: a%20b becomes a%2520b.
URLSearchParams and the plus sign
The safest way to build a query string is not to build it by hand:
const u = new URL("https://shop.example/search");
u.searchParams.set("q", "salt & pepper");
u.searchParams.set("tag", "C++");
u.href
// "https://shop.example/search?q=salt+%26+pepper&tag=C%2B%2B"
The space became +, not %20. URLSearchParams uses the application/x-www-form-urlencoded format from the WHATWG URL Standard, the same format an HTML form submits, and escapes everything except letters, digits and * - . _. A form parser reads both + and %20 as a space, so either output works. Decoding is where things go wrong, because the decoders disagree about +:
decodeURIComponent("salt+%26+pepper") // "salt+&+pepper"
new URLSearchParams("q=salt+%26+pepper").get("q") // "salt & pepper"
new URLSearchParams("email=alice+test@example.com").get("email")
// "alice test@example.com"
The last line is the plus-sign bug that reaches production: someone writes ?email=${email} without encoding, and every address with a + tag arrives with a space in it. A literal plus in a query value has to travel as %2B, which both encodeURIComponent and URLSearchParams produce. When you decode, match the format: values from a query string or form body go through a form parser such as URLSearchParams, not bare decodeURIComponent.
Decoding pitfalls
decodeURIComponent is strict. Every % must be followed by two hex digits, and the decoded bytes must be valid UTF-8, or it throws:
decodeURIComponent("100%") // URIError: URI malformed
decodeURIComponent("100%25") // "100%"
decodeURIComponent("%E9") // URIError: URI malformed (Latin-1 é, not UTF-8)
decodeURIComponent("caf%C3%A9") // "café"
new URLSearchParams("d=100%").get("d") // "100%"
new URLSearchParams("d=%E9").get("d") // "�"
URLSearchParams is lenient: it leaves a stray % as it is and turns invalid UTF-8 into the replacement character. The same bad input can crash one code path and silently corrupt another. Wrap decodeURIComponent in try/catch when the input comes from outside, and answer a URIError with a 400 rather than letting it become a 500. For a lone %, the devprix decoder also reminds you that a literal percent sign is written %25.
Double encoding is the other classic. Encode twice and %20 becomes %2520, because the % itself is encoded as %25:
encodeURIComponent(encodeURIComponent("a b")) // "a%2520b"
decodeURIComponent("a%2520b") // "a%20b" one layer is left
If your logs show %25 followed by two hex digits, a layer encoded a value that was already encoded. Find that layer rather than decoding twice on the other side; a decode-until-nothing-changes loop will eventually turn a user's literal %41 into A. A %25 is legitimate when a URL that already contains escapes travels as a parameter of another URL, such as a redirect_uri, provided each layer encodes exactly once.
Path segments are not query values
A path segment is data too. Put a file name in a path with encodeURI and its slash becomes a real separator:
encodeURI("Q1 report/final.pdf") // "Q1%20report/final.pdf"
encodeURIComponent("Q1 report/final.pdf") // "Q1%20report%2Ffinal.pdf"
Use encodeURIComponent for each segment. Even then, an encoded slash in a path is fragile: some proxies decode %2F before routing, and Apache httpd refuses such URLs with a 404 unless AllowEncodedSlashes is switched on. If you control the design, keep slashes out of path segments: IDs and slugs in the path, free text in the query string.
Non-ASCII characters become UTF-8 bytes
Both functions encode non-ASCII text as UTF-8 and percent-encode each byte, which is what RFC 3986 recommends for text. One character can become four escapes:
encodeURIComponent("café") // "caf%C3%A9"
encodeURIComponent("日本") // "%E6%97%A5%E6%9C%AC"
encodeURIComponent("😀") // "%F0%9F%98%80"
encodeURIComponent("\uD800") // URIError: URI malformed (lone surrogate)
escape("café") // "caf%E9" legacy, not UTF-8
The lone-surrogate error usually means a string was cut in the middle of an emoji, for example by truncating it to a fixed length. escape() is a deprecated legacy function that writes Latin-1 bytes and %uD83D-style escapes that UTF-8 decoders reject; its %E9 is exactly the input that made decodeURIComponent throw above. Hex case doesn't matter (%c3%a9 decodes the same as %C3%A9), although RFC 3986 asks for uppercase.
The same choice in Python
Python's urllib.parse has the same split, with different defaults:
from urllib.parse import quote, quote_plus, urlencode, parse_qs, unquote
s = "salt & pepper/C++"
quote(s) # 'salt%20%26%20pepper/C%2B%2B' keeps / by default
quote(s, safe="") # 'salt%20%26%20pepper%2FC%2B%2B'
quote_plus(s) # 'salt+%26+pepper%2FC%2B%2B'
urlencode({"q": "salt & pepper", "tag": "C++"})
# 'q=salt+%26+pepper&tag=C%2B%2B'
parse_qs("tag=C++&email=alice+test@example.com")
# {'tag': ['C '], 'email': ['alice test@example.com']}
unquote("100%"), unquote("%E9")
# ('100%', '�')
Three differences from JavaScript matter. quote() leaves / alone unless you pass safe="", so pass it for every path segment or query value. With safe="" it keeps only letters, digits and - . _ ~, the RFC 3986 unreserved set, which makes it stricter than encodeURIComponent (Python 3.7 switched quoting from RFC 2396 to RFC 3986). quote_plus() is the form-encoding version, and urlencode() uses it unless you pass quote_via=quote. And unquote() doesn't throw on bad escapes; like URLSearchParams, it keeps bad escapes and replaces invalid UTF-8 with U+FFFD. On the server, parse_qs turns + into a space just as browsers do, so the plus-sign bug looks identical in a Python backend.
Which function for which job
| Job | JavaScript | Python |
|---|---|---|
| Query string from key/value pairs | URLSearchParams or url.searchParams | urlencode() |
| One value inside a template string | encodeURIComponent | quote(v, safe="") |
| One path segment | encodeURIComponent | quote(seg, safe="") |
| Strict RFC 3986, e.g. for request signing | encodeRFC3986 above | quote(v, safe="") |
| Reading query values | url.searchParams.get() | parse_qs() |
Next steps
Paste a misbehaving URL into the URL parser to see how a browser splits it: protocol, host, port, path, each query parameter decoded the way URLSearchParams decodes it, and the fragment. Edit any part and it rebuilds the URL, passing every key and value through encodeURIComponent. For single values or whole lists, the URL encoder works in both directions, line by line if you need it. If the value you're putting in a URL is Base64, Base64 explained shows why its URL-safe alphabet avoids the plus-sign problem.