URL encoder/decoder · Guide

URL Encoding Characters: The Complete Percent-Encoding Cheat Sheet

URL encoding replaces characters that would otherwise confuse a parser with a percent sign followed by their hex byte value. Which characters need it is not a fixed list — it depends on where in the URL the character appears, and the same character can be perfectly legal in one position and destructive in another. Here is the full table, plus the rules that make it usable.

The complete reserved table

These characters carry structural meaning in a URL. When they appear as data rather than as syntax, they must be encoded.

Text
space  %20      &  %26      ;  %3B      [  %5B
  !    %21      '  %27      <  %3C      ]  %5D
  "    %22      (  %28      =  %3D      ^  %5E
  #    %23      )  %29      >  %3E      `  %60
  $    %24      *  %2A      ?  %3F      {  %7B
  %    %25      +  %2B      @  %40      |  %7C
  ,    %2C      /  %2F      \\  %5C      }  %7D
  :    %3A

Three of these break things far more often than the rest. & separates query parameters, = separates a key from its value, and # starts the fragment — which the browser never sends to the server at all. An unencoded # inside a value silently truncates everything after it, and because the request still succeeds with a shorter value, it is easy to misread as a server-side bug.

% deserves its own note. It introduces every escape sequence, so a literal percent sign must become %25. Miss it and the two characters that follow are read as a hex code — 100% followed by anything can decode into a completely different byte, or throw a malformed-URI error.

The characters you never encode

The unreserved set is safe in every part of a URL and should be left alone.

Text
A-Z   a-z   0-9   -   _   .   ~

A hyphen never needs encoding. It is unreserved in RFC 3986 and legal anywhere in a URL — in the path, in a query key, in a value, in a fragment. If an encoder turns - into %2D, the result is still valid and decodes identically, but it is unnecessary and makes URLs harder to read and to match in logs.

The same applies to the period, the underscore and the tilde. The tilde is the one with history: older RFCs treated it as unsafe, so some legacy encoders still emit %7E. Both forms work, but current implementations should leave it alone.

This is why encodeURIComponent in JavaScript leaves - _ . ! ~ * ' ( ) untouched, and why server-side encoders sometimes differ on the last five. For anything involved in a signature — OAuth, AWS SigV4, a webhook HMAC — that difference is not cosmetic: two encoders producing different strings produce different signatures, and the request is rejected.

Space is the special case

A space has two valid encodings, and which one is correct depends entirely on context.

Text
%20   in a path or query string (RFC 3986)
+     in an application/x-www-form-urlencoded body
%2B   a literal plus sign, in either context

This is the one place the two schemes genuinely conflict. In a form-encoded body, + means space, so a literal plus must be sent as %2B. Get it wrong and a value like +44 20 7946 arrives as 44 20 7946 — a phone number quietly stripped of its country prefix, with no error anywhere.

Most server frameworks decode + as a space in query strings too, for historical compatibility. Using %20 everywhere avoids the ambiguity entirely and is always correct.

Non-ASCII, emoji and why strings get long

Anything outside ASCII is first encoded to UTF-8 bytes, and then each byte becomes its own %XX pair.

Text
é       -> %C3%A9              2 bytes
ж       -> %D0%B6              2 bytes
中      -> %E4%B8%AD           3 bytes
😀      -> %F0%9F%98%80        4 bytes

So one character can legitimately expand to twelve characters of output. A Cyrillic or Chinese query string looks enormous once encoded, and that is correct rather than a bug.

The step that gets skipped is the UTF-8 conversion. Encoding byte-by-byte from a different code page produces sequences that decode to the wrong characters — the classic é in place of é. If accented characters survive one system and break in another, compare the raw percent-encoded bytes rather than the rendered text.

Context changes the rules

The same character needs different treatment depending on which part of the URL it lands in. This is the source of most encoding bugs that survive review.

Text
https://host.com/path/seg?key=value#frag
                 ^^^^^^^^^ ^^^ ^^^^^ ^^^^
                 path      key value fragment

In a **path segment**, / is the separator, so a slash inside a value must be %2F. Be warned that many servers and proxies reject or normalise %2F in paths before your application sees it — nginx and Apache both do by default. Where you control the design, avoid slashes in path values rather than fighting the stack.

In a **query string**, encode each key and each value separately, then join them with & and =. Encoding the assembled string in one pass destroys the structure: the separators themselves get encoded and the server receives a single parameter with a very long name.

In a **fragment**, encoding is looser because the fragment never reaches the server. It still matters to client-side routers, which parse it the same way a server parses a query string.

Encoding correctly in code

Every language ships the right function; the mistake is almost always choosing the wrong one of a near-identical pair.

JavaScript
// encodeURI       — for a whole URL, preserves : / ? & = #
// encodeURIComponent — for one value, encodes them

encodeURI('https://a.com/x y?q=1&r=2');
// 'https://a.com/x%20y?q=1&r=2'   structure intact

encodeURIComponent('a&b=c');
// 'a%26b%3Dc'                     safe as a value

// Best: let the URL API assemble it
const u = new URL('https://a.com/search');
u.searchParams.set('q', 'a&b=c');
u.toString();  // '...?q=a%26b%3Dc'
Python
# Python — quote() keeps '/' by default, quote_plus() does not
from urllib.parse import quote, quote_plus, urlencode

quote('a/b c')        # 'a/b%20c'   — '/' preserved for paths
quote('a/b c', safe='')  # 'a%2Fb%20c'
quote_plus('a b')     # 'a+b'       — for form bodies
urlencode({'q': 'a&b'})  # 'q=a%26b'  — builds the whole string
PHP
// PHP — rawurlencode is RFC 3986, urlencode is form-style
rawurlencode('a b');   // 'a%20b'   use for paths and query values
urlencode('a b');      // 'a+b'     use for form bodies
http_build_query(['q' => 'a&b']);  // 'q=a%26b'

The pattern repeats across languages: one function is RFC 3986 and gives %20, the other is form-style and gives +. Picking by name rather than by behaviour is how plus signs end up in URLs and spaces end up in form bodies.

Double-encoding is the other recurring bug. Encoding a value that is already encoded turns %20 into %2520, which decodes to the literal text %20. If you see %25 anywhere unexpected, something in the chain encoded twice — usually a framework that encodes automatically plus application code doing it again.

Frequently asked questions

Do I need to URL encode a dash?

No. The hyphen is an unreserved character in RFC 3986 and is legal anywhere in a URL — path, query key, value or fragment. Encoding it as %2D is valid and decodes identically, but it is unnecessary and makes URLs harder to read.

Which characters must always be encoded?

The reserved set when used as data rather than syntax: space, ! " # $ % & ' ( ) * + , / : ; < = > ? @ [ \ ] ^ ` ` { | }. The three that cause the most damage are &, = and #`, because they silently change how the URL is parsed rather than producing an error.

Should a space be %20 or +?

%20 in a path or query string, + only inside an application/x-www-form-urlencoded body. A literal plus sign must be %2B in a form body, otherwise it decodes as a space — the usual cause of a phone number losing its country prefix.

How do I encode a percent sign?

As %25. The percent sign starts every escape sequence, so a literal one must be encoded first. If it is not, the two characters after it are read as a hex code and either decode to the wrong byte or raise a malformed-URI error.

Why is my encoded string full of %25?

It was encoded twice. %20 encoded again becomes %2520, which decodes back to the literal text %20 rather than a space. This normally happens when a framework encodes automatically and application code encodes as well — remove one of the two.

Why does my accented character arrive as é?

The text was encoded from a non-UTF-8 code page. Percent-encoding operates on UTF-8 bytes, so é must become %C3%A9; encoding from Latin-1 gives %E9, which a UTF-8 decoder then renders as mojibake. Compare the raw percent-encoded bytes rather than the displayed characters.

Ready to try it?

Open the free browser-based URL encoder/decoder and apply what you just read — no sign-up, runs locally.

Open the URL encoder/decoder tool