DevKitLab Logo DevKitLab
URL Encoding / Encoding / Debugging

Why Is My URL Encoding Broken? Percent-Encoding and + vs %20

You put café in a URL and it arrives as café. A space is + in one place and %20 in another. An & inside a value splits your query in half. Something got encoded twice and now reads %2520. These all look like different bugs, but they're one idea with two twists: percent-encoding escapes bytes a URL can't spell literally, the rules change by component, and a space has two legal encodings.

You put café in a URL and it comes out the other end as café. You encode a space and get a + in one place, a %20 in another, and now you can’t tell which is correct. A value with an & in it splits your query string in half and the second half quietly disappears. Something got encoded twice and you’re staring at %2520. Each of these looks like its own bug, so you patch each with its own hack — decode here, .replace() there, encode one more time — and something else breaks a layer downstream.

Here’s the fact underneath all of them: percent-encoding is how a URL carries bytes it isn’t allowed to spell literally. A URL’s serialized form uses a restricted set of ASCII characters, and anything outside it — non-ASCII text, or a character with a structural job like & or / when it appears inside a value — has to be escaped as a % followed by the two hex digits of each byte. That’s the entire mechanism. The confusion comes from two twists layered on top: the rules for which characters must be escaped change depending on which part of the URL you’re in, and a space has two legal encodings — %20 and + — that mean the same thing in exactly one context and different things everywhere else.

This article spends a minute on what percent-encoding actually is, then walks the handful of ways it goes wrong: the +/%20 split, encoding a whole URL when you meant one piece of it, the UTF-8 step that turns café into %C3%A9 (or café when it’s mishandled), and the double-encoding that turns %20 into %2520. By the end you’ll have a checklist for any URL that comes out mangled.

The one idea: escape a byte as % plus its two hex digits

A URL’s serialized form may only contain a limited set of ASCII characters — so non-ASCII text has to be turned into bytes (via UTF-8) and escaped before it can travel. Percent-encoding — a.k.a. URL-encoding — is the escape hatch: take any byte that isn’t allowed here, and write it as % followed by that byte’s value in two hexadecimal digits. A space is byte 0x20, so it becomes %20; the / byte is 0x2F, so when it’s data rather than a path separator it becomes %2F. Two families of characters set the rules:

  • UnreservedA–Z a–z 0–9 - . _ ~ — always safe, never need encoding.
  • Reserved: / ? # [ ] @ ! $ & ' ( ) * + , ; = — these carry structural meaning: separating the query from the path, one parameter from the next, a key from its value. They’re legal as delimiters, but when one appears inside a value, it must be encoded — or it will be read as structure.

That second point is the whole “& split my query” bug in one sentence: an unencoded & inside a value is indistinguishable from the & between two parameters, so everything after it is parsed as a new parameter.

+ versus %20: the two faces of a space

The single most confusing thing about URL encoding is that both are correct — in different places. A space has two encodings, and which one is right depends on the context:

  • In the path and most of a URL, a space is %20. A literal + there is just a plus sign.
  • In form-encoded data (application/x-www-form-urlencoded) — most often a query string — a space is +, and a literal plus must be written %2B.

So in general URL components %20 is the unambiguous way to write a space, while +-means-space is the application/x-www-form-urlencoded convention — the rule form encoders follow, most visibly in query strings but also in a form request body. The bugs live at that boundary: a decoder that treats + as a space in the path corrupts a real plus, and a decoder that doesn’t treat + as a space in form-encoded data leaves you with literal + signs where spaces belonged. You can watch the two conventions diverge on the same input:

encodeURIComponent("a b")                      // "a%20b"
new URLSearchParams({ q: "a b" }).toString()   // "q=a+b"

encodeURIComponent targets general URL components, so it emits %20; URLSearchParams serializes as a form, so it emits +. This is also exactly the trap that reaches back into Base64 — a Base64 string carried through form-encoded data can have its + characters silently turned into spaces by form decoding, which is a “why won’t my Base64 decode?” bug that is really this +/space rule in disguise. When in doubt, encode spaces as %20 and pluses as %2B, and the ambiguity is gone.

Encode the piece, not the whole URL

A huge share of “broken encoding” is encoding at the wrong granularity. There are two jobs and two different tools:

  • Encoding a whole URL leaves the structural characters (: / ? # & =) alone, because they’re doing their job. In JavaScript that’s encodeURI.
  • Encoding one component — a single query value, one path segment — must escape everything reserved, including / ? # & =, because here they’re data, not structure. That’s encodeURIComponent.

Run the whole-URL encoder over a value and its & and = sail through unescaped and blow your query apart. Run the component encoder over a whole URL and its :// and ? get escaped into gibberish that no longer routes. The rule: build the URL from encoded components; never encode the finished URL as one string. Encode each value with the component encoder, then join them with the literal ?, &, and = that you want to remain structural.

Non-ASCII: UTF-8 first, then percent-encode

café does not become %café. Percent-encoding works on bytes, and a character like é isn’t one byte — so there’s a hidden step: the text is first encoded to bytes with UTF-8, and then each byte is percent-encoded. é is the two UTF-8 bytes 0xC3 0xA9, so it becomes %C3%A9, and café becomes caf%C3%A9. One CJK character is usually three bytes, hence three %XX groups. This is exactly why café turns into café: the bytes were encoded as UTF-8 but something downstream decoded them as Latin-1, where 0xC3 0xA9 reads as é. Mojibake in a URL is almost always a UTF-8-versus-something-else mismatch, not a percent-encoding failure. And note that those %XX pairs are just hex%C3 is the byte value 0xC3 — which is why reading hex fluently makes an encoded URL suddenly legible.

One more thing worth separating: the host part of a URL does not use percent-encoding for non-ASCII. café.com becomes xn--caf-dma.com through Punycode / IDN, a different mechanism entirely — so a non-ASCII domain and a non-ASCII path are escaped by two different systems, and mixing them up is its own source of confusion.

Double-encoding: how %20 becomes %2520

The classic downstream mess. Percent-encoding is not idempotent: encode an already-encoded string and the % signs themselves get encoded, because % is byte 0x25%25. So %20 (an encoded space) run through an encoder a second time becomes %2520, and a reader now sees a literal %20 in the text instead of a space. The tell is %25 appearing where you didn’t put it — %2520, %253A, %2526. It happens when a value is encoded by your code and then encoded again by a framework, an HTTP client, or a redirect that assumed the value was still raw. The fix is to encode exactly once: find the layer that’s double-wrapping and let only one of them do the job. To see the layers, paste the string into a URL encoder/decoder and decode it repeatedly — each pass peels one layer, and when the %25s turn back into %s you’ve found how many times it was wrapped. Repeated decoding is a diagnostic here, not a fix: don’t loop-decode blindly in production, because a single correctly-encoded value can legitimately contain literal %25 or %20 text of its own.

The corners that still bite

A few smaller ones that hide in the specs:

  • encodeURIComponent doesn’t encode ! ' ( ) *. Some servers, following RFC 3986 strictly, want those escaped too — so if a picky endpoint rejects them, encode them by hand.
  • A + in a path is a literal plus. Only application/x-www-form-urlencoded data reads + as a space, so don’t “fix” a path by turning + into a space.
  • # truncates silently. An unencoded # in a value starts the URL fragment, and everything after it never reaches the server — a # in data must be %23.

The checklist for mangled URL encoding

When a URL comes out wrong, don’t start replacing characters at random. Ask these in order:

  1. Which component? A whole URL keeps : / ? # & = structural; a single value must escape them. Use the component encoder for values (encodeURIComponent), the URL encoder for whole URLs (encodeURI).
  2. Space showing as + or %20? In application/x-www-form-urlencoded data, + means a space; other URL components use %20. To stay safe, encode spaces as %20 and plus as %2B.
  3. Accents or CJK garbled? That’s a UTF-8-versus-charset mismatch, not percent-encoding — make both ends agree on UTF-8. Host names use Punycode, not %XX.
  4. Seeing %25 you didn’t add? It’s double-encoded — decode until the %25s become %, then encode exactly once.
  5. A value cutting off, or the query splitting? An unencoded #, &, or = inside a value is being read as structure — encode it.

Underneath all five is the one idea: percent-encoding escapes a byte as % plus two hex digits, and the only hard parts are telling data from structure, and the one context where a space is +. Name which of those you’re on the wrong side of, and the URL that looked mangled resolves cleanly.