URL Encoding and Percent-Encoding: What Actually Gets Escaped and Why

How percent-encoding actually works at the byte level, why + means space in forms but not paths, and the double-encoding bug that produces %2520 in your logs.

Updated July 2026 · 9 min read

A query string that worked fine locally arrives at production with a mangled parameter — a + became a space, an & split a value in half, or a %20 somehow turned into %2520. Every one of these is percent-encoding behaving exactly as specified. The bug is almost always that some layer of the stack applied the wrong rules, applied them twice, or didn't apply them at all. This guide covers the mechanics precisely enough that you can spot which of those three happened.

The actual mechanic

A URL is defined as a sequence of characters drawn from a limited, mostly-ASCII set. Anything outside that set — a space, a non-ASCII letter, a raw control character, or a reserved delimiter used as literal data — cannot appear as-is. Percent-encoding is the escape mechanism: take the byte value of the character, and replace it with % followed by that byte's two-digit hexadecimal value, written in uppercase.

A space is byte 0x20, so it becomes %20. That's the whole rule for a single-byte ASCII character. It gets more interesting once the character isn't ASCII at all.

Character:        é
UTF-8 bytes:      0xC3 0xA9
Percent-encoded:  %C3%A9

Percent-encoding operates on bytes, not on characters. Before anything gets encoded, the character has to be converted to a byte sequence using some encoding — on the modern web that's always UTF-8. The letter é is not one byte; in UTF-8 it's the two bytes 0xC3 and 0xA9. Each of those bytes gets its own %XX group, which is why encoding a single accented letter, emoji, or CJK character produces multiple percent-encoded groups in the URL — two for most Latin-adjacent accented characters, three for most CJK characters, four for characters outside the Basic Multilingual Plane. If you ever see a URL like %E4%BD%A0%E5%A5%BD, that's two Chinese characters, six UTF-8 bytes, six %XX groups — not six separate characters.

Reserved vs. unreserved characters

RFC 3986 splits the characters a URL can use into two groups, and the distinction is the basis for almost every encoding decision you'll make.

Unreserved characters — A-Z, a-z, 0-9, -, _, ., ~ — never carry any structural meaning. They never need to be encoded, and an encoder is never required to touch them. If you see one of these percent-encoded (like %7E for ~), it's harmless but unnecessary.

Reserved characters — : / ? # [ ] @ ! $ & ' ( ) * + , ; = — are reserved precisely because they're used as delimiters that give a URI its structure: : separates scheme from the rest, / separates path segments, ? starts the query, # starts the fragment, & and = separate query parameters and their values, and so on.

The rule that trips people up: a reserved character is only safe to leave bare when it's actually playing its delimiter role. The moment it appears as literal data inside a component, it must be percent-encoded, or the parser reading the URL will misinterpret it as structure. A query value that contains a literal ampersand — say, a search term of "salt & pepper" — has to encode that ampersand as %26. Left bare, q=salt & pepper reads as two separate parameters, q=salt and a stray pepper, and the second half of your search term is silently dropped.

The + character is not part of RFC 3986

This is the single most common source of confusion in this entire topic, so it's worth being exact about where the rule comes from. RFC 3986 says nothing special about + — it's just a reserved character, and in most contexts (notably a URL path) a literal + is a literal plus sign and doesn't need encoding at all.

The "+ means space" convention comes from a completely different, older spec: application/x-www-form-urlencoded, the encoding HTML forms use for their submitted data (originally an early HTML/CGI convention, now formalized in the WHATWG URL Living Standard). That format was designed to encode form field values compactly, and it made a deliberate substitution: a space is written as + instead of %20, and any literal + in the data has to be escaped as %2B so it isn't misread as an encoded space.

The two rules only collide because both encodings show up in the same place — a URL's query string — depending on how that query string was built. A query string generated by serializing an HTML form (or by URLSearchParams, or by most server-side form parsers) follows x-www-form-urlencoded rules, where + is space. A query string built by hand with plain RFC 3986 percent-encoding treats + as a literal plus sign, and expects spaces to be %20. Mixing the two — encoding a space as %20 but then having a downstream parser decode the query using form rules, or vice versa — is exactly the bug behind "why did my + turn into a space" and its mirror image, "why is my space showing up as a literal plus."

encodeURIComponent vs. encodeURI

JavaScript ships two built-in encoders, and they exist for genuinely different jobs — using the wrong one is a very common, very quiet bug because it doesn't throw, it just corrupts structure.

encodeURIComponent escapes everything except unreserved characters. It assumes the string you're passing it is a single opaque value — a query parameter's value, a path segment, a fragment payload — with no URL structure of its own. That's why it aggressively encodes &, =, ?, /, and #: as far as it knows, those are just data.

encodeURI assumes the opposite: that you're handing it an already-structured, complete URI and just want to clean up stray unsafe characters (spaces, non-ASCII text) without breaking the structure that's already there. So it deliberately leaves reserved delimiters — : / ? # & = and a few others — unescaped.

const value = "a=b&c d";

encodeURIComponent(value);
// "a%3Db%26c%20d"   -- every reserved char and the space are escaped

encodeURI(value);
// "a=b&c%20d"       -- only the space is escaped; = and & pass through untouched

Run encodeURI on something that's actually a single query value, and any & or = the value contains passes straight through unescaped, so it gets parsed as extra parameters instead of part of your data. The fix is almost always: use encodeURIComponent on each value individually before assembling the query string yourself, or skip manual encoding altogether and build the query with URLSearchParams, which handles this correctly.

Double-encoding

Percent-encoding is not idempotent, and that's the root of the second most common bug in this space. If a value is already percent-encoded and gets passed through an encoding function again, the % character itself — byte 0x25 — gets encoded, because as far as the encoder is concerned, % is just another character it doesn't recognize as safe.

Original space:        " "
Encoded once:          "%20"
Encoded again:         "%2520"   (the % in %20 became %25)

The concrete symptom is diagnostic: if you're staring at server logs or a captured request and you see literal %25XX sequences — %2520, %252F, %253D — something in the pipeline encoded a value that was already encoded. Common culprits are a proxy or framework that automatically encodes outgoing URLs combined with application code that also calls encodeURIComponent manually, or a value getting encoded once when it's stored and again when it's later placed into a new URL. The fix is to find the layer doing redundant work and encode the raw, undecoded value exactly once, at the point where it's inserted into the URL.

Try it

If you're debugging a request that has a suspicious query string — plus signs, stray ampersands, or %2520-looking noise — the cURL to fetch/axios Converter is a fast way to turn a captured request into code you can actually step through and see exactly which value is getting encoded, and how many times.

Frequently asked questions

Why did a + in my query string turn into a space?+

Because it was decoded using application/x-www-form-urlencoded rules, where + means an encoded space. That convention comes from HTML form submission, not from RFC 3986. If you actually meant a literal plus sign in a form-encoded value, you had to send %2B.

Should I use encodeURIComponent or encodeURI?+

Almost always encodeURIComponent, and only on one value at a time — a query parameter, a path segment, a fragment. Use encodeURI only when you already have a complete, structurally valid URI and just want to escape a few stray characters like spaces, since it deliberately leaves : / ? & = # unescaped.

Why do I see %2520 in my server logs instead of %20?+

That is double-encoding. Something took an already-encoded %20 and ran it through a percent-encoding function a second time, which encodes the % character itself into %25, leaving %2520. If you see repeated %25 sequences, trace back through your code for a spot where a value gets encoded twice.

Why does encoding é produce two %XX groups instead of one?+

Percent-encoding works on bytes, not characters. é is not one byte — in UTF-8 it is the two bytes 0xC3 0xA9 — so encoding it produces %C3%A9. A single non-ASCII character can expand into two, three, or four %XX groups depending on how many bytes its UTF-8 representation uses.

Do I need to encode a literal & inside a query parameter value?+

Yes. & is a reserved character that separates key=value pairs in a query string. If a value legitimately contains &, it must be percent-encoded to %26, otherwise the parser will read it as the start of a new parameter and split your value in the wrong place.

Is it safe to percent-encode unreserved characters like letters and digits?+

It is not wrong, but it is pointless and can trip up naive string-matching code. Unreserved characters (A-Z, a-z, 0-9, -, _, ., ~) are guaranteed never to need encoding under RFC 3986, and encoders like encodeURIComponent already leave them alone.