All articles
Encoders & Decoders
38 min readBy DevUtilX Team

URL Encoding Explained: Percent-Encoding, Reserved Characters and Pitfalls

Learn how percent-encoding works, which characters to encode in paths and queries, %20 versus +, double-encoding bugs, and URL parsing security pitfalls.

URL Encoding Explained: Percent-Encoding, Reserved Characters and Pitfalls

A search link works on your machine, then breaks in production because a customer typed an ampersand. A redirect parameter arrives with a plus sign where a space should be. A file name with an accented letter returns a 404 from one service and a 200 from another. A webhook signature fails because someone encoded the payload twice. Each of these bugs has the same root: a URL is a string with strict rules about which characters may appear where, and the rules are easy to half-remember.

This guide is for developers who build, call or debug URLs, whether in a browser, a backend service or a shell script. You will learn why URLs need encoding at all, how RFC 3986 sorts characters into classes, how percent-encoding turns characters into UTF-8 bytes, which characters to encode in the path, the query and the fragment, and why a space is sometimes %20 and sometimes +. We will compare encodeURI, encodeURIComponent, URLSearchParams and URL in JavaScript, and quote, quote_plus and urlencode in Python, using code that was actually run, with the real output shown. We will also cover double-encoding, internationalised addresses, the security problems that come from parsers disagreeing, and a troubleshooting table you can keep next to your editor.

What is URL encoding?

URL encoding, formally called percent-encoding, is a way to represent an arbitrary byte inside a URL using only safe characters. A byte is written as a percent sign followed by two hexadecimal digits. The byte 0x20, which is a space, becomes %20. The byte 0x26, an ampersand, becomes %26. A character outside ASCII is first converted to bytes, normally with UTF-8, and then each byte is written the same way. The euro sign € is three bytes in UTF-8 (E2 82 AC), so it becomes %E2%82%AC.

The reason this exists is that a URL has to survive being pasted into an email, printed on a poster, typed into a terminal and parsed by dozens of programs. Early URLs were designed to use a small, portable subset of ASCII. At the same time, the URL syntax gives certain characters jobs of their own. A slash separates path segments, a question mark starts the query, an ampersand separates parameters, and a hash starts the fragment. When your data happens to contain one of those characters, the parser cannot tell your data from the structure. Percent-encoding solves both problems at once: it lets any data travel inside a URL, and it marks a character as data rather than structure.

The Wikipedia article on percent-encoding gives a good overview, and the definitive rules live in RFC 3986, titled "Uniform Resource Identifier (URI): Generic Syntax".

You will see the technique under several names: URL encoding, URI encoding, percent-encoding and, in form submissions, application/x-www-form-urlencoded. They overlap, but they are not identical, and the differences cause real bugs. We will separate them as we go.

A short history

The first formal description of URLs was RFC 1738, published in 1994, which listed which characters were "unsafe" and had to be escaped. RFC 2396 followed in 1998 and generalised the idea from URLs to URIs. RFC 3986 replaced it in 2005 and is still the main reference, issued as an Internet standard. It was written by Tim Berners-Lee, Roy Fielding and Larry Masinter.

Two other threads matter. The first is character sets. In the early web, servers and browsers percent-encoded non-ASCII characters using whatever local encoding they preferred, such as Latin-1 or Shift JIS. The result was ambiguity: %E9 could be é in one system and a different character in another. Over time the web converged on UTF-8, and RFC 3986 and the modern WHATWG URL Standard both say to use it. Older systems that still send Latin-1 bytes are a source of mojibake and of decoding errors, which we cover in troubleshooting.

The second thread is HTML forms. Browsers needed a way to send form fields in the body of a request or in the query string, and they adopted a format with its own quirks, most notably writing a space as +. That format is now specified in the WHATWG URL Standard as application/x-www-form-urlencoded. It resembles percent-encoding but is a separate format, which is the reason the plus sign is a recurring trap.

Why URLs need encoding

Consider this string: https://example.com/search?q=salt & pepper. How many parameters does it hold? A person guesses one, with the value "salt & pepper". A parser sees q=salt , then a separator, then pepper. The ampersand is part of the grammar, so a literal ampersand in data must be written differently from a delimiter. The same holds for =, #, ?, /, + and % itself.

There are three distinct reasons a character gets encoded:

  1. It has no safe representation in a URL. Spaces, control characters, quotation marks, angle brackets and any non-ASCII character fall here. They are not allowed in a URI at all, so they must be encoded.
  2. It is a delimiter used somewhere else in the grammar. A slash inside a file name, or an ampersand inside a query value, must be encoded so it is not treated as structure.
  3. It is the percent sign. Because % introduces an escape, a literal percent sign has to be written as %25. Skipping this step is how 100% sure turns into a broken sequence.

The important idea is that encoding depends on context. The same character can be fine in one part of a URL and dangerous in another. A slash is a delimiter in the path and ordinary data in the query. A plus sign is plain data in a path and, under form encoding, a space in a query. There is no single "encode this string for URLs" function that is correct everywhere, which is why the JavaScript and Python standard libraries offer several.

The anatomy of a URL

RFC 3986 describes a URI as a sequence of components. Here is a full example with every part labelled:

https://user:pass@example.com:8443/docs/guide%20one/index.html?lang=en&q=a%26b#section-2
\___/   \_______/ \_________/ \__/ \___________________________/ \___________/ \_________/
scheme  userinfo     host     port            path                   query      fragment

Each component has its own allowed characters, which is why the context rule matters.

  • Scheme. Letters, digits, plus, hyphen and period. It is never percent-encoded.
  • Authority. Userinfo, host and port. Hosts are either registered names, IP literals in square brackets, or IPv4 addresses. Percent-encoding is allowed in registered names by the RFC, but non-ASCII host names use a different mechanism, covered in the internationalisation section.
  • Path. Segments separated by slashes. Within a segment, the characters that must be encoded include the slash, the question mark and the hash, because each would end the segment or the path.
  • Query. Everything after the first ? up to a #. The RFC leaves the internal structure open, but nearly all software uses key=value pairs joined by &. That convention is not part of RFC 3986 and is the root of many bugs, since the parser needs a second layer of encoding rules.
  • Fragment. Everything after the first #. It is processed by the client and is not sent to the server in an HTTP request.

You can see this structure by running a URL through the DevUtilX decoder, and you can build one safely by encoding each component separately before you join them. The next sections show exactly which characters belong in which class.

The RFC 3986 character classes

RFC 3986 divides all characters into three groups. Learning them removes most of the guesswork.

Unreserved characters

These are safe everywhere and never need encoding: the letters A-Z and a-z, the digits 0-9, and four marks: hyphen, period, underscore and tilde.

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

An encoded unreserved character is equivalent to the plain one, so %41 and A name the same thing. The RFC says producers should not encode them and that normalisers should decode them.

Reserved characters

Reserved characters have special meaning in at least some contexts. RFC 3986 splits them into two sub-groups.

General delimiters (gen-delims) separate the major components:

:  /  ?  #  [  ]  @

Sub-delimiters (sub-delims) are used inside components, typically to separate parts of a value:

!  $  &  '  (  )  *  +  ,  ;  =

A reserved character can appear raw in a component where it has its delimiting role. If you want it as data in a place where it would be read as a delimiter, you must encode it. The classification does not say "always encode reserved characters". It says "encode them when they are data and the context would give them meaning". For example, = is a sub-delimiter, so in a query it separates a key from a value, but inside a path segment it is usually literal.

Everything else

Any other character, including space, double quote, less-than, greater-than, backslash, caret, backtick, curly braces, vertical bar, control characters and all non-ASCII characters, is not allowed in a URI and must be percent-encoded.

The percent sign is a special case. It is not in the reserved list, but it is the escape character, so a literal percent must always be written %25.

A table generated by running code

The table below was produced by a small Node.js script. It encodes each character with encodeURIComponent, then with encodeURI, then with URLSearchParams, which implements the form-encoding rules. The "unchanged" entries show characters the function leaves alone.

Character encodeURIComponent encodeURI URLSearchParams
space %20 %20 +
! unchanged unchanged %21
" %22 %22 %22
# %23 unchanged %23
$ %24 unchanged %24
% %25 %25 %25
& %26 unchanged %26
' unchanged unchanged %27
( and ) unchanged unchanged %28 and %29
* unchanged unchanged unchanged
+ %2B unchanged %2B
, %2C unchanged %2C
/ %2F unchanged %2F
: %3A unchanged %3A
; %3B unchanged %3B
= %3D unchanged %3D
? %3F unchanged %3F
@ %40 unchanged %40
[ and ] %5B and %5D %5B and %5D %5B and %5D
- _ . unchanged unchanged unchanged
~ unchanged unchanged %7E
é %C3%A9 %C3%A9 %C3%A9
€ %E2%82%AC %E2%82%AC %E2%82%AC
日 %E6%97%A5 %E6%97%A5 %E6%97%A5
😀 %F0%9F%98%80 %F0%9F%98%80 %F0%9F%98%80

Several lessons are visible in this table, and each corresponds to a real bug.

First, encodeURIComponent leaves ! ' ( ) * alone, even though RFC 3986 lists the first four as sub-delimiters. JavaScript inherited this behaviour from the older RFC 2396, which treated those characters as harmless "marks". In most contexts it does not matter, but if you need strict RFC 3986 output, for instance to build a signature base string, you must encode them yourself.

Second, encodeURI leaves almost every reserved character alone. It is designed for whole URLs, so it must not touch :, /, ?, #, & and =. It follows that encodeURI is the wrong function for a single value.

Third, URLSearchParams is stricter than encodeURIComponent for the sub-delimiters, encodes ~, and writes the space as +.

Finally, notice the non-ASCII rows. Every function agrees on UTF-8 percent-encoding for characters like é, €, 日 and the emoji. A four-byte character produces four escapes.

How percent-encoding works, step by step

The algorithm has only three steps, and once you can do it by hand you can read any encoded URL.

  1. Decide which characters need encoding, based on the component and the classes above.
  2. Convert each such character to bytes using UTF-8.
  3. Write each byte as % followed by two hexadecimal digits.

Worked example: an accented letter

Take é, Unicode code point U+00E9. In UTF-8, code points from U+0080 to U+07FF use two bytes. The binary form of 0xE9 is 1110 1001, which splits into the pattern 110xxxxx 10xxxxxx as 110 00011 and 10 101001. That gives bytes 0xC3 0xA9. Written as escapes: %C3%A9. Node confirms it:

console.log(new TextEncoder().encode("é"));
// Uint8Array(2) [ 195, 169 ]
console.log(encodeURIComponent("é"));
// %C3%A9

The decimal values 195 and 169 are 0xC3 and 0xA9. This is why a single accented letter becomes two escapes in a URL, and why truncating a string in the middle of an escape sequence yields garbage.

Worked example: the euro sign

U+20AC lies in the range that UTF-8 stores in three bytes. Node printed the bytes as e2, 82, ac, so the encoded form is %E2%82%AC. Characters above U+FFFF, such as emoji, take four bytes, which is how 😀 became %F0%9F%98%80.

Worked example: a query value

Suppose the user searches for rock & roll=fun?#. We want a URL that carries that string as the value of q. A careful build gives:

const p = new URL("https://example.com/search");
p.searchParams.set("q", "rock & roll=fun?#");
console.log(p.href);
// https://example.com/search?q=rock+%26+roll%3Dfun%3F%23

The ampersand, equals sign, question mark and hash are all encoded, so none of them can be mistaken for structure. The spaces are written as + because URLSearchParams uses form encoding. A server that decodes the query as form data gets the original string back. A server that only decodes percent escapes will see rock+&+roll..., and that is the next topic.

Hex digit case

The hexadecimal digits may be upper or lower case, so %e2%82%ac and %E2%82%AC decode to the same bytes. RFC 3986 says uppercase is the normal form, and producers should use it. The difference matters when you compare URLs as strings, for caching or signatures, and we return to it in the normalisation section.

Decoding

Decoding reverses the steps. The decoder scans for %, reads the next two hex digits, turns them into a byte, then interprets the full byte sequence as UTF-8 text. Two failure modes are worth knowing:

  • Truncated escape. A % that is not followed by two hex digits, as in 100%, is malformed. JavaScript's decodeURIComponent throws a URIError. Python's unquote leaves it untouched and does not raise.
  • Invalid UTF-8. A sequence such as %E6%97, which is the start of a three-byte character with one byte missing, cannot be decoded. JavaScript throws URIError: URI malformed. Python's unquote replaces the bad bytes with the replacement character by default.

Both behaviours were observed in the sandbox, and the difference is important: a JavaScript service will crash on bad input unless you catch the error, while a Python service will silently continue with corrupted text.

Which characters to encode in which component

This is the part developers most often get wrong. The rule of thumb is to encode each piece of data for the component it will live in, and to do it before you join the pieces.

Path segments

In a path segment, the following must be encoded when they are data: the slash (%2F), the question mark (%3F), the hash (%23), the percent sign (%25) and everything in the "everything else" class. Sub-delimiters and the colon and at-sign are allowed as they are, with the caveat that a colon in the first segment of a relative reference can be mistaken for a scheme.

Here is a case that shows why this matters. A user uploads a file named a/b.txt, and your API serves it at /files/{name}. If you insert the name raw, the path becomes /files/a/b.txt, which routes to a different resource. The correct URL is /files/a%2Fb.txt. Be aware that some servers and proxies decode %2F early or refuse to accept it, a point we revisit in troubleshooting.

Query strings

In a query, a slash and a question mark are legal as data. The characters you must encode are the ones the key/value convention gives meaning: &, =, #, + when you use form decoding, and %. Most libraries simply encode every reserved character in a value, which is safe because an encoded character is always decoded back to the same data.

Use the same encoding for both keys and values. A key containing = or & is rare but legal, and an unencoded one corrupts the whole query.

Fragments

The fragment may contain slash and question mark raw. You must still encode spaces, the hash and the percent sign. Single-page applications often store state in the fragment, such as #/users/42?tab=info, and treat it as a miniature URL. In that case you must apply the same encoding discipline inside it.

Userinfo and host

Credentials in a URL are legacy and discouraged, because they leak into logs and browser history. If you must include them, the username and password cannot contain a raw colon, at-sign or slash. Hosts are lowercase ASCII labels, and non-ASCII names use punycode, not percent-encoding, in practice.

A quick reference

Component Always encode Safe raw Notes
Path segment / ? # %, space, non-ASCII sub-delims, :, @ Encode each segment, then join with /
Query key or value & = # %, space, non-ASCII, + for forms / ? : @ and most sub-delims Encode keys and values separately
Fragment # %, space, non-ASCII / ? Apply the same rules inside client-side routes
Userinfo : @ / ? #, space sub-delims Avoid credentials in URLs

The quickest way to check your encoding is to paste a value into the URL Encoder and read the escaped result, or paste a suspicious URL into the URL Decoder and see what it really contains.

Space: %20 or plus?

No topic produces more confusion. The answer depends on which format you are in.

Under RFC 3986, a space in any component is %20. The plus sign is a reserved sub-delimiter with no special meaning, so in a path, a+b means the literal three characters a, + and b.

Under application/x-www-form-urlencoded, which HTML forms use and which URLSearchParams implements, a space is written as +, and a literal plus must be written as %2B. This format is used for form bodies and, by long convention, for query strings produced by forms.

The practical consequences are these. The same text, a+b%2Bc, means different things depending on the decoder, and we can see it directly:

console.log(new URLSearchParams("q=a+b%2Bc").get("q"));
// a b+c
console.log(decodeURIComponent("a+b%2Bc"));
// a+b+c

URLSearchParams treats the first plus as a space and the encoded plus as a plus. decodeURIComponent knows only about percent escapes, so it leaves the first plus as a plus. The same difference exists in Python between unquote and unquote_plus:

from urllib.parse import unquote, unquote_plus
print(unquote("a%20b+c"), "|", unquote_plus("a%20b+c"))
# a b+c | a b c

Which is right? It depends on who produced the string and who will consume it.

  • If you build a query string with a form-style encoder, decode it with a form-style decoder.
  • If you build a path, always use %20. A plus in a path is a literal plus.
  • If you are unsure, use %20 for spaces in everything you generate. Every mainstream decoder, form-style or not, reads %20 as a space. A form decoder also reads + as a space, so %20 is the one spelling that is safe with both kinds.

The mistake to avoid is converting between the two formats by search-and-replace. Replacing + with %20 after the fact breaks any value that contained a real encoded plus unless you are careful about order.

Encoding in JavaScript

JavaScript offers four built-in routes, each with a different purpose. Choosing the right one is half the battle.

encodeURIComponent

Use encodeURIComponent to encode a single piece of data, such as a query value or a path segment. It leaves unreserved characters and ! ' ( ) * alone and encodes everything else, with UTF-8 for non-ASCII characters. The MDN reference for encodeURIComponent documents this behaviour.

const name = "Ada Lovelace";
const url = "/users/" + encodeURIComponent(name) + "/files/" +
  encodeURIComponent("a/b.txt");
console.log(url);
// /users/Ada%20Lovelace/files/a%2Fb.txt

encodeURI

Use encodeURI to clean up an entire URL that may contain spaces or non-ASCII characters but whose structure is already correct. It leaves all reserved characters alone, so it cannot protect a value. The difference is visible when you apply both to the same full URL:

const u = "https://example.com/a b?q=1&r=2#x";
console.log(encodeURI(u));
// https://example.com/a%20b?q=1&r=2#x
console.log(encodeURIComponent(u));
// https%3A%2F%2Fexample.com%2Fa%20b%3Fq%3D1%26r%3D2%23x

encodeURI produced a usable URL. encodeURIComponent produced a string that is only valid as a value inside another URL, for example in a redirect= parameter. Using the wrong one on a whole URL is a common bug, and the symptom is a link that starts with https%3A%2F%2F.

URLSearchParams

URLSearchParams is the best tool for query strings. It encodes keys and values, handles repeated keys, and parses incoming strings.

const q = new URLSearchParams("a=1&a=2&b=%E2%9C%93&c");
console.log([...q]);
// [ [ 'a', '1' ], [ 'a', '2' ], [ 'b', '✓' ], [ 'c', '' ] ]
console.log(q.getAll("a"));
// [ '1', '2' ]
console.log(new URLSearchParams({ a: "1", b: "x y" }).toString());
// a=1&b=x+y

Note that a key without a value, such as c, parses as an empty string. If the distinction between ?c and ?c= matters to your application, URLSearchParams will not preserve it. The class is documented on MDN, and its encoding follows the WHATWG form-urlencoded rules.

The URL class

The URL constructor parses and normalises a full address, and its setters encode on your behalf. It is the safest way to assemble a URL because it applies the right encoding to each component.

const u = new URL("https://example.com/a b/ü?q=a b&x=1#frag ment");
console.log(u.href);
// https://example.com/a%20b/%C3%BC?q=a%20b&x=1#frag%20ment

The parser encoded the space and the umlaut in the path, the space in the query, and the space in the fragment, each by its own component rule. It also handles non-ASCII host names:

console.log(new URL("https://münchen.example/straße").href);
// https://xn--mnchen-3ya.example/stra%C3%9Fe

The host went through punycode and the path through percent-encoding. Those are different mechanisms for different components, as the internationalisation section explains.

A helper that builds URLs correctly

Putting the pieces together, this function encodes each path segment separately and lets URLSearchParams handle the query:

function buildUrl(base, path, params) {
  const u = new URL(base);
  u.pathname = "/" + path.map(encodeURIComponent).join("/");
  for (const [k, v] of Object.entries(params)) u.searchParams.append(k, v);
  return u.href;
}

console.log(buildUrl(
  "https://api.example.com",
  ["users", "Ada Lovelace", "files", "a/b.txt"],
  { q: "x&y=1", lang: "fr é" }
));
// https://api.example.com/users/Ada%20Lovelace/files/a%2Fb.txt?q=x%26y%3D1&lang=fr+%C3%A9

The slash inside a/b.txt stayed encoded as %2F, the ampersand and equals sign inside the query value are encoded, and the query's space became +, because that is how URLSearchParams serialises. If your server expects %20 in queries, replace plus signs in the serialised query, taking care that real plus signs were already written as %2B, which they are.

Decoding and error handling

Always wrap decodeURIComponent on untrusted input, because malformed sequences throw:

function safeDecode(s) {
  try {
    return decodeURIComponent(s);
  } catch (e) {
    return null; // or reject the request with a 400
  }
}

In the sandbox, decodeURIComponent("100%") and decodeURIComponent("%E6%97") both threw URIError: URI malformed, and so did encodeURIComponent on a lone surrogate such as "\uD800", which is not valid Unicode text. A request handler that does not catch the error returns a 500 where a 400 would be correct.

Encoding in Python

Python's standard library puts these functions in urllib.parse. The Python documentation for urllib.parse describes each one.

quote and quote_plus

quote percent-encodes a string for use in a URL component. By default its safe argument is /, so slashes pass through, which suits a whole path but not a single segment. quote_plus is the form-style variant: it writes spaces as + and also treats / as unsafe by default.

from urllib.parse import quote, quote_plus

print(quote("a b/c?d=é&"))
# a%20b/c%3Fd%3D%C3%A9%26
print(quote("a b/c", safe=""))
# a%20b%2Fc
print(quote_plus("a b/c+d"))
# a+b%2Fc%2Bd

The first call kept the slash, because safe defaults to it. The second passed safe="" to encode the slash as well, which is what you want for one segment. The third used the form rules. When you encode a path segment that may contain a slash, always pass safe="".

Characters that RFC 3986 calls reserved sub-delimiters are encoded by default:

print(quote("~-._!*'()"))
# ~-._%21%2A%27%28%29
print(quote("日本語"))
# %E6%97%A5%E6%9C%AC%E8%AA%9E
print(quote("é", encoding="latin-1"))
# %E9

The last line shows that you can choose another character set. Do this only when you must talk to a legacy system, and note that the same letter é becomes the single byte %E9 instead of %C3%A9.

urlencode

urlencode builds a whole query string from a dictionary or a list of pairs. It uses quote_plus by default, so spaces become +.

from urllib.parse import urlencode, quote

print(urlencode({"q": "rock & roll", "tags": ["a", "b"]}, doseq=True))
# q=rock+%26+roll&tags=a&tags=b
print(urlencode({"q": "a b"}, quote_via=quote))
# q=a%20b

doseq=True expands a list into repeated keys. Without it, the list would be converted with str() and encoded as one value, which is a surprise. Passing quote_via=quote switches to %20.

unquote, unquote_plus and parse_qs

from urllib.parse import unquote, unquote_plus, parse_qs

print(unquote("a%20b+c"), "|", unquote_plus("a%20b+c"))
# a b+c | a b c
print(parse_qs("q=a+b%2Bc&x=1&x=2"))
# {'q': ['a b+c'], 'x': ['1', '2']}
print(unquote("%E6%97"))
# prints the replacement character (a diamond with a question mark)

parse_qs applies form decoding, so it turns + into a space and %2B into a plus, and it collects repeated keys into lists. As noted earlier, unquote on invalid UTF-8 does not raise an exception. If you need to reject malformed input, call unquote(s, errors="strict"), which raises UnicodeDecodeError on bad byte sequences.

Splitting a URL

urlsplit parses a URL into scheme, network location, path, query and fragment, without decoding them:

from urllib.parse import urlsplit
print(urlsplit("https://example.com@evil.test:8080/p?q=1#f"))
# SplitResult(scheme='https', netloc='example.com@evil.test:8080',
#             path='/p', query='q=1', fragment='f')

The network location here is example.com@evil.test:8080. The text before the at-sign is userinfo, and the real host is evil.test. Remember this example, because it reappears in the security section.

Double-encoding and double-decoding

Double-encoding happens when an already-encoded string is encoded again. Every % becomes %25, so %20 turns into %2520.

const v = "100% sure & more";
console.log(encodeURIComponent(v));
// 100%25%20sure%20%26%20more
console.log(encodeURIComponent(encodeURIComponent(v)));
// 100%2525%2520sure%2520%2526%2520more

The first line is correct. The second is what you get when two layers of your code both think encoding is their job. The server decodes once and receives 100%25%20sure%20%26%20more, a string full of escapes instead of the original text. The same effect appears in Python: quote(quote("100% sure")) produced 100%2525%2520sure.

Typical sources of double-encoding:

  • A helper encodes a value, and the HTTP client library encodes the whole URL again.
  • A value taken from location.search or an incoming request is already encoded and is passed to encodeURIComponent before being put in a new link.
  • A redirect parameter is encoded when it is created and again when the login service appends it to its own URL. This one is sometimes intentional, because a URL inside a URL can legitimately require two layers, but each layer must be decoded exactly once on the way out.
  • A template engine encodes output automatically, while your code also encodes.

The reverse problem, double-decoding, is a security issue. If code decodes a value twice, then %2520 becomes %20 and then a space, and an attacker can smuggle characters past a filter that checked the value after the first decoding. The rule is simple: decode exactly once, at the boundary where the data enters your program, and then treat the result as plain text. Never decode a value just to check it and then pass the original along, and never decode data that you got from a parsed API that already did the work for you.

console.log(decodeURIComponent("%2520"));
// %20
console.log(decodeURIComponent(decodeURIComponent("%2520")));
// (a single space)

To find double-encoding by eye, look for %25 followed by two hex digits. A well-formed URL rarely has %2520 or %252F. Paste the URL into the URL Decoder and decode it once. If the result still looks like an encoded string, something encoded it twice.

Internationalised URLs: punycode and percent-encoding

URLs must be ASCII on the wire, but people use many writing systems. Two separate mechanisms bridge the gap, one for the host and one for everything else.

Host names: IDN and punycode

Internationalised Domain Names are converted to ASCII with an encoding called punycode, specified in RFC 3492. Each non-ASCII label is turned into a string starting with xn--. Both runtimes did this in the sandbox:

print("münchen.example".encode("idna"))
# b'xn--mnchen-3ya.example'
print("bücher.example".encode("idna").decode())
# xn--bcher-kva.example

The URL class in Node gave the same xn--mnchen-3ya.example result for the same name. Punycode is not percent-encoding. Do not percent-encode a host name, and do not punycode a path. The Wikipedia article on punycode explains the algorithm.

Python's built-in idna codec implements an older version of the IDNA specification, and the newer rules treat certain characters, such as the German ß, differently. If your application depends on precise behaviour for such characters, test with real data, and consider a library that implements the current standard.

Paths and queries: UTF-8 percent-encoding

For everything after the host, non-ASCII characters are written as percent-encoded UTF-8 bytes. A browser may display https://example.com/straße in the address bar, but on the wire it sends /stra%C3%9Fe. Standards for internationalised resource identifiers, such as RFC 3987, define the readable form and how to map it to a URI.

The consequence for your code is that you should compare and store the ASCII form. If you store the readable form in one place and the encoded form in another, string comparisons fail even though both name the same resource. Pick one form as canonical, usually the encoded one, and convert at the edges.

Look-alike characters

Because many scripts contain characters that resemble Latin letters, an attacker can register a host name that looks like a legitimate one. Browsers defend against this with display rules that fall back to the xn-- form in suspicious cases. If your application shows user-submitted links, consider displaying the ASCII form for non-trivial hosts, so users can see what they are really visiting.

Real-world use cases

Search and filter URLs. Every search box that puts text in a query string needs value encoding. Filters with multiple selections use repeated keys, such as tag=a&tag=b, or a delimited value, in which case the delimiter must itself be safe.

Redirect and callback URLs. Login flows and payment providers take a redirect_uri or next parameter that contains a complete URL. That URL must be encoded as a value, so its own ? and & do not split the outer query. OAuth redirect URIs must also match registered values exactly, which makes consistent encoding critical.

File and object paths. Cloud storage keys, download endpoints and static sites serve names that include spaces, accents and slashes. Encode each segment.

Deep links and share links. Mobile deep links, social share buttons and QR codes carry URLs inside URLs. Count the layers, and decode each one the same number of times.

API signatures. Some signing schemes require a canonical query string with strict RFC 3986 encoding, sorted keys and uppercase hex digits. A library that leaves ! or ' unencoded produces a different signature from one that encodes them, and the request is rejected.

Email and marketing links. Tracking parameters often include whole URLs and campaign names with spaces. Link checkers and email clients can rewrite them, so correct encoding up front avoids damage.

Webhooks and form posts. HTML forms submit as application/x-www-form-urlencoded by default, so back-end frameworks decode + as a space. A custom client posting that content type must follow suit.

Command line and scripts. curl sends what you type. Shell quoting and URL encoding are separate layers, and an unquoted ampersand in a shell command runs the command in the background before the URL is ever built. Quote the URL, and use curl --data-urlencode to encode individual fields.

For related text transformations, our guide to JSON formatting is useful when a URL parameter carries JSON, and the piece on JSON minification and payload size explains why a compact serialisation matters when data is embedded in a link, where every escaped character costs three bytes.

Common mistakes and pitfalls

  • Encoding the whole URL with encodeURIComponent. The scheme separator and slashes become %3A%2F%2F, and the link is unusable. Encode the parts, not the whole.
  • Using encodeURI on a value. It leaves &, =, # and + alone, so a value containing them corrupts the query.
  • Concatenating raw user input into a URL. Any user-controlled value can add parameters or change the path. Encode every dynamic value.
  • Forgetting that a space is + in forms. Decode with the matching function, and be careful with literal plus signs such as phone numbers like +1 555 0100, which must be sent as %2B1.
  • Double-encoding. Check whether a library or framework already encodes before you do.
  • Encoding the delimiters you meant to keep. Encoding a full path with quote(path, safe="") turns every slash into %2F, and the server sees a single huge segment.
  • Mixing character sets. A legacy system that sends %E9 for é will not decode under UTF-8. Agree on UTF-8 and test with accented text.
  • Assuming %2F equals /. Servers differ. Some decode it before routing and some reject it, so avoid slashes in identifiers where you can.
  • Decoding too early. If you decode a full URL and then split it on &, a value containing an encoded ampersand will be split. Split first, then decode each piece.
  • Trusting the first decoding for security checks. Validate after the final decode, on the same representation that will be used.
  • Ignoring the fragment. It is not sent to the server, so server-side code never sees it, and tracking or authentication logic that relies on it will fail.
  • Not encoding %. A bare percent breaks decoders. Always write %25.
  • Relying on length. An encoded string is up to three times longer than the original for ASCII punctuation and up to twelve times for a four-byte character written as %XX triples, which affects limits on URL length.

Step-by-step: encode and decode with DevUtilX

When you want to check a value without writing code, use the browser tools. They run locally, so the text you paste is not uploaded, which matters when a URL includes tokens, e-mail addresses or internal paths.

To encode a value

  1. Open the URL Encoder.
  2. Paste the raw text, such as rock & roll=fun?# or a file name with accents, into the input.
  3. Run the encoder and read the output. Spaces, ampersands, equals signs and non-ASCII letters should appear as %XX sequences.
  4. Copy the result and place it into the right component of your URL, such as a query value or a path segment.
  5. If the tool offers a choice between encoding a component and encoding a full URL, choose the component mode for a single value and the full-URL mode only for a complete address.

To decode a URL

  1. Open the URL Decoder.
  2. Paste the encoded string, such as https%3A%2F%2Fexample.com%2Fa%20b%3Fq%3D1.
  3. Run the decoder and read the result. If you see %25 in the output, the input was double-encoded, and a second pass will reveal the next layer.
  4. If the decoder reports an error, the input has a truncated escape or bytes that are not valid UTF-8. Look for a stray %, or for text that was encoded in Latin-1.

A debugging routine

When a link misbehaves, decode it once, read it, and then re-encode the corrected value. Compare the result to what your code generated. The difference usually points straight at the bug: a missing %26, an extra layer of %25, or a + where %20 was expected. For URLs buried inside other URLs, decode one layer at a time and write down what each layer contains.

If the URL carries a JSON payload, decode it first and then validate the JSON, as described in our JSON formatting guide.

Code examples that were run

All of the snippets above were executed in Node.js and Python 3 in a sandbox, and the outputs shown are the actual results. Here is one more complete example in each language, a round trip that confirms nothing is lost.

const tricky = "a b+c&d=e/f?g#h%i é €";
const encoded = encodeURIComponent(tricky);
console.log(encoded);
console.log(decodeURIComponent(encoded) === tricky);
from urllib.parse import quote, unquote

tricky = "a b+c&d=e/f?g#h%i é €"
encoded = quote(tricky, safe="")
print(encoded)
print(unquote(encoded) == tricky)

Both print a%20b%2Bc%26d%3De%2Ff%3Fg%23h%25i%20%C3%A9%20%E2%82%AC followed by true or True. Whenever you write an encoding helper, add a round-trip test like this one with the worst characters you can think of, including a percent sign, a plus sign, a non-ASCII letter and an emoji.

A command line check with curl is also handy. The --data-urlencode option encodes a field for you, and -G appends the fields to the URL as a query:

curl -G "https://example.com/search" --data-urlencode "q=rock & roll"

Checked with curl's -w "%{url_effective}" option, the request URL ended in ?q=rock+%26+roll, so this version of curl writes the space as +. The spelling is the tool's choice, so do not depend on one form when you design the server.

Normalisation and canonicalisation

Two URLs can be different strings and still name the same resource. Normalisation rewrites a URL into a standard form, so that equivalent addresses compare equal. It matters for caching, deduplication, signatures and security checks.

RFC 3986 describes several safe normalisations:

  • Case. The scheme and host are case-insensitive, so HTTPS://Example.COM equals https://example.com. The path and query are case-sensitive.
  • Hex digit case. %e2%82%ac and %E2%82%AC are equivalent. Uppercase is the preferred form.
  • Unreserved decoding. %7E and ~ are equivalent, and so are %41 and A. A normaliser should decode escapes for unreserved characters.
  • Dot segments. /a/b/../c means /a/c, and /a/./b means /a/b.
  • Default ports. https://example.com:443/ equals https://example.com/.
  • Empty path. For HTTP, an empty path is equivalent to /.

Reserved characters are different. %2F and / are not equivalent, because the first is data and the second is a delimiter. A normaliser must never decode them.

The sandbox shows that real implementations differ in how far they go. Node's URL class resolves dot segments but leaves the hex digits as given:

console.log(new URL("https://example.com/a/../b/./c").pathname);
// /b/c
console.log(new URL("https://example.com/%7euser/%7Euser").pathname);
// /%7euser/%7Euser
console.log(new URL("https://example.com/a%2Fb/c").pathname);
// /a%2Fb/c
console.log(new URL("https://example.com/a/%2e%2e/c").pathname);
// /c

The third line shows that %2F stayed encoded, which is correct. The last line shows something more interesting: the URL class treated %2e%2e as a dot segment and removed it, because the specification says a percent-encoded dot counts as a dot. Hand-written checks that look for the literal text .. miss this form.

A small normaliser for the case and unreserved rules is short. This Python function uppercases hex digits and decodes only unreserved characters:

import re

def normalize_pct(s):
    unreserved = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
    def fix(m):
        ch = chr(int(m.group(1), 16))
        return ch if ch in unreserved else "%" + m.group(1).upper()
    return re.sub(r"%([0-9A-Fa-f]{2})", fix, s)

for s in ["/%7euser/a%2fb", "/%41%62c/%e2%82%ac", "/a%2Fb"]:
    print(s, "->", normalize_pct(s))

The output:

/%7euser/a%2fb -> /~user/a%2Fb
/%41%62c/%e2%82%ac -> /Abc/%E2%82%AC
/a%2Fb -> /a%2Fb

%7e became ~, %41%62 became Ab, the lowercase %2f stayed encoded but was uppercased, and the euro sign's escapes were uppercased without being decoded. Use a tested library for production work, since the full rules for hosts, ports and dot segments are longer than this sketch.

Canonicalisation, a stricter idea, means choosing one form that your system treats as the single true spelling, and rejecting or redirecting everything else. A cache key built from a normalised URL will have a better hit rate. A signature computed over a canonical form will be reproducible by both sides. Decide on the rules once and apply them in one place.

Security considerations

URL handling is a rich source of vulnerabilities, mostly because two components parse the same string differently. The following is a conceptual overview, not an exploitation guide.

Open redirects

An open redirect happens when an application takes a destination from a parameter, such as ?next=..., and redirects to it without checking. Attackers use a trusted domain's own redirect endpoint to send victims to a hostile site, making phishing links look legitimate. The fix is to accept only relative paths or to compare the host against an allow-list after parsing.

Validation that works on raw strings is fragile. A check that only blocks the text http:// misses //evil.test, which a browser treats as a link to another host that uses the current scheme. It also misses a backslash variant, because browsers following the WHATWG rules treat a backslash like a slash in special schemes, as the sandbox showed:

console.log(new URL("/\\evil.test", "https://example.com").href);
// https://evil.test/

A safer approach is to allow only values that start with a single slash and contain no backslash or control characters:

from urllib.parse import urlparse

def is_safe_redirect(target):
    if "\\" in target or any(ord(c) < 32 for c in target):
        return False
    p = urlparse(target)
    if p.scheme or p.netloc:
        return False
    return target.startswith("/") and not target.startswith("//")

for t in ["/dashboard", "//evil.test", "/\\evil.test", "https://evil.test", "/ok?next=%2Fa"]:
    print(repr(t), is_safe_redirect(t))

Run in the sandbox, it printed True for /dashboard and /ok?next=%2Fa, and False for the other three. This is a minimal sketch. A production check should also consider your framework's own redirect helpers and any proxies that sit in front of the application.

Parser differentials

The deeper problem is that different parsers give different answers for the same string. In the sandbox, the input https://example.com\@evil.test/ produced these results:

  • Node's URL class, following the WHATWG rules, read the host as example.com, because it treats the backslash as the end of the authority.
  • Python's urlsplit kept the backslash inside the network location and, with the at-sign, concluded that example.com\ was userinfo and the host was evil.test.

Two components that disagree about the host can be exploited. Imagine a validator written with one parser that approves example.com, and a fetcher written with another that connects to evil.test. The attacker never needs a bug in either piece alone; the gap between them is the vulnerability.

The defences are practical:

  • Parse a URL exactly once, with one library, and pass the parsed object, not the original string, to every later step.
  • Reject ambiguous input outright: backslashes, control characters, embedded credentials and unusual escapes in the host.
  • Where two systems must agree, test them against the same set of tricky inputs.

Server-side request forgery

SSRF occurs when a server fetches a URL supplied by a user, such as an image preview or a webhook target, and the attacker points it at an internal address. URL parsing is only one layer of the defence, since redirects and DNS can change the destination after validation, but weak parsing makes things worse. Encoded characters, alternative forms of the same address, and userinfo tricks can all disguise the real host. Parse once, resolve the host, check the resolved address against a deny-list of internal ranges, and refuse to follow redirects blindly. Run the fetch from a network segment that cannot reach sensitive internal services, so that a parsing mistake is not fatal.

Path traversal and encoded dots

Path traversal attacks use ../ to climb out of an intended directory. Filters that look for the literal string miss %2e%2e/ and doubly encoded forms such as %252e%252e/. The Python call unquote("%2e%2e/%2E%2E/etc") produced ../../etc, which shows what a later decoding step can reveal. Decode once, normalise the path, then check that the final resolved location stays inside the permitted directory. Do not try to blacklist character sequences.

Injection through unencoded values

If you concatenate user input into a URL, the user can add parameters of their own. A value of 1&admin=true appended to ?id= turns one parameter into two. Encoding every value closes this hole. The same applies to header injection, where a newline in a redirect URL can split an HTTP response if the framework does not reject it, and to link injection into HTML, where an unescaped quote can break out of an href attribute. Encode for URLs, then escape for the HTML context separately.

Sensitive data in URLs

Everything in a URL, encoded or not, can end up in server logs, browser history, referrer headers and analytics. Percent-encoding is not encryption and offers no confidentiality. Do not put passwords, session tokens or personal data in query strings. If you must, keep them short-lived and single-use.

Performance considerations

URL encoding itself is cheap, and for typical request sizes, you will never see it in a profile. A few points are still worth knowing.

  • Length limits. Browsers, proxies and servers cap URL length, and the cap varies by implementation. Percent-encoding inflates text, with each escaped byte taking three characters. A query value full of non-ASCII text can be several times longer than it looks. When data is large, send it in a request body instead.
  • Encode once. Repeated encode and decode passes over large strings waste CPU, and in a hot path you can build the URL once and reuse it.
  • Cache keys. If you cache by URL, normalise first so equivalent spellings share a cache entry. Sort query parameters only if your server treats order as insignificant.
  • Streaming and bodies. Large form posts are better as multipart/form-data, which does not pay the three-bytes-per-byte tax on binary content.

A rough sense of size helps. The string 日本語 is three characters and nine bytes in UTF-8, and %E6%97%A5%E6%9C%AC%E8%AA%9E is 27 characters. That is a threefold growth over the byte size, as the Python output above shows.

Accessibility and usability

URLs are part of the user interface, and encoding choices affect real people.

  • Readable links. A link full of %XX sequences is hard to read aloud, hard to share and hard to check for phishing. Prefer short ASCII slugs for public pages, and keep the original text as the page title.
  • Link text. Screen readers announce link text rather than the address, so write meaningful text and do not paste a raw encoded URL into visible copy.
  • Non-Latin languages. Browsers show decoded characters in the address bar for readability, but copy-and-paste may produce the encoded form. Users who paste a link into a chat should still get a working URL. Test copy and paste in the browsers you support.
  • Sharing in print. Long encoded URLs are painful to type from a printed page. Use a short redirect, or a QR code, which is one more place where compact encoding helps.
  • Form behaviour. Make sure form fields tolerate spaces and plus signs without rewriting what the user typed, and show errors in plain language rather than a decoding exception.

Troubleshooting guide

When a URL behaves strangely, work from symptom to cause to fix.

Cause. encodeURIComponent or quote(..., safe="") was applied to a whole URL instead of one value.
Fix. Encode only the dynamic parts, or use encodeURI for a whole URL. Better, build the address with the URL class and set its components separately.

Symptom: spaces appear as plus signs in the output, or plus signs vanish

Cause. One side uses form encoding and the other uses plain percent-decoding.
Fix. Use matching functions: URLSearchParams with URLSearchParams, quote_plus with unquote_plus, and %20 for paths. Encode a real plus as %2B.

Symptom: the server receives %2520 or %25 followed by hex digits

Cause. Double-encoding, because two layers each encoded the value.
Fix. Find the second encoder, usually in an HTTP client wrapper or a template helper, and remove it. Decode once at the boundary.

Symptom: URIError: URI malformed

Cause. decodeURIComponent received a lone %, a truncated multi-byte sequence, or bytes that are not UTF-8, such as Latin-1 %E9.
Fix. Wrap decoding in try/catch and return a 400. If the source is a legacy system, ask it to send UTF-8, or decode with the right character set on your side.

Symptom: accented letters turn into é or a diamond with a question mark

Cause. UTF-8 bytes were interpreted as Latin-1, or Latin-1 bytes were interpreted as UTF-8. The two-byte sequence %C3%A9 read as Latin-1 gives é.
Fix. Make both ends use UTF-8, and set the right charset on the response. In Python, pass encoding to quote and unquote when you must support the older set.

Symptom: a file name with a slash returns 404

Cause. The slash was sent raw and split the path, or it was sent as %2F and an intermediary decoded or rejected it.
Fix. Encode each segment with the slash escaped. If a proxy rejects %2F, avoid slashes in identifiers or move the value into the query string.

Symptom: a query parameter is cut off at an ampersand or hash

Cause. The value contained an unencoded & or #, so the parser ended the value or the query early.
Fix. Encode values with encodeURIComponent, URLSearchParams, quote or urlencode. Never concatenate raw input.

Symptom: an OAuth redirect fails with a mismatch error

Cause. The redirect_uri is compared as a string, and the encoded form differs from the registered form in hex case, or in encoding of / or :.
Fix. Use the same encoder for registration and requests, use uppercase hex, and avoid unnecessary escapes.

Symptom: the signature check fails only for some requests

Cause. The signer and verifier canonicalise differently, usually around ! ' ( ) *, ~, hex case, or + versus %20.
Fix. Define the canonical form precisely, such as strict RFC 3986 with uppercase hex, and test both sides with a shared list of awkward characters.

Symptom: the fragment is missing on the server

Cause. Browsers do not send the fragment in HTTP requests.
Fix. Read it in client-side code, or move the data into the query string.

Cause. The browser repaired the URL, for example by encoding spaces and Unicode, while the script sent the raw string.
Fix. Pass the address through the URL class or an encoding helper before sending it.

Best practices and checklist

  • Encode data, not URLs. Encode each value for its own component, then join the parts.
  • Prefer high-level APIs. Use URL and URLSearchParams in JavaScript, and urlencode and urlsplit in Python, instead of string concatenation.
  • Use UTF-8 everywhere. Declare it, test it with accented text and emoji, and avoid legacy character sets.
  • Choose one spelling for spaces. Generate %20 unless you are deliberately writing form data.
  • Encode a real plus as %2B. Also encode &, =, #, ? and % in values.
  • Encode once, decode once. Put these steps at the boundaries, and log what comes in.
  • Handle malformed input. Catch URIError and decide whether to reject or repair.
  • Use uppercase hex digits and normalise before you compare URLs or sign them.
  • Parse once, with one library. Pass the parsed object around, and reject backslashes, control characters and embedded credentials where they are not expected.
  • Validate redirects against an allow-list after parsing, never with string matching on the raw value.
  • Keep secrets out of URLs. They persist in logs and history.
  • Test with hostile values. A good test string contains a space, plus, ampersand, equals sign, hash, percent sign, slash, accented letter and emoji.

Before shipping a feature that builds links, run through these questions:

  1. Is every dynamic value encoded for the component where it is placed?
  2. Is any value encoded more than once, or decoded more than once?
  3. Do the producer and the consumer agree on form encoding versus percent-encoding?
  4. Is the character set UTF-8 on both ends?
  5. Are redirect and fetch targets parsed once and checked against an allow-list?
  6. Is there a round-trip test with the worst characters?

Comparison: ways to encode URL data

Method Best for Encodes &, =, #? Space becomes Watch out for
encodeURIComponent One value or path segment Yes %20 Leaves ! ' ( ) * raw
encodeURI A whole URL that is already structured No %20 Does not protect values
URLSearchParams Building and parsing query strings Yes + Strict about ~ and sub-delimiters, drops the ?c versus ?c= difference
URL class Assembling and normalising a full address Per component %20 in paths WHATWG rules differ from other parsers
urllib.parse.quote A path or segment (set safe="" for segments) Yes %20 Keeps / by default
urllib.parse.quote_plus One form value Yes + Encodes / by default
urllib.parse.urlencode A whole query from a dict Yes + Needs doseq=True for lists
Manual string replacement Nothing Unreliable Varies Misses cases and invites double-encoding
Online tool (URL Encoder) One-off checks Yes Depends on mode Do not paste secrets into any tool you do not trust

FAQ

What is the difference between URL encoding and percent-encoding?
They are usually the same thing. Percent-encoding is the formal name in RFC 3986 for writing a byte as % and two hex digits. "URL encoding" is the informal name. Form encoding, application/x-www-form-urlencoded, is a close relative that writes spaces as +.

Should I use %20 or + for a space?
Use %20 unless you are producing form data. It is correct in every component, and form decoders accept it as well. A plus sign means a space only inside form-encoded data, such as the output of URLSearchParams or quote_plus.

What is the difference between encodeURI and encodeURIComponent?
encodeURI is for a complete URL and leaves reserved characters such as /, ?, & and # alone. encodeURIComponent is for a single piece of data and encodes them. For anything that goes inside a query value or a path segment, use encodeURIComponent or, better, URLSearchParams and the URL class.

Why does my URL contain %25?
The percent sign itself is encoded as %25. If you see %2520 or %252F, the value was encoded twice, because %20 and %2F were encoded again.

Which characters never need encoding?
The unreserved set: letters, digits, hyphen, period, underscore and tilde. Reserved characters are safe raw only where they play their delimiting role.

How are non-ASCII characters encoded?
They are converted to bytes with UTF-8, and each byte is written as %XX. The letter é becomes %C3%A9, and an emoji becomes four escapes. Host names are different. They use punycode with an xn-- prefix.

Is percent-encoding a form of security or encryption?
No. Anyone can decode it, and it offers no confidentiality. It exists to keep data from being confused with URL structure. It also does not by itself prevent injection, so validate input separately.

Why do decodeURIComponent and Python's unquote behave differently on bad input?
JavaScript throws a URIError on a malformed sequence. Python's unquote is lenient and substitutes a replacement character for invalid UTF-8, unless you pass errors="strict". Handle both cases explicitly in code that takes untrusted input.

Are %2F and / the same?
No. In a path, / separates segments and %2F is a literal slash inside a segment. Normalisation must never decode it. Servers and proxies do vary in how they treat %2F, so avoid depending on it.

Is hex case significant, as in %2f versus %2F?
For decoding, no, both mean the same byte. For comparing strings, caching and signing, yes, so normalise to uppercase.

Does the fragment get sent to the server?
No. Browsers keep it on the client, so the server never sees it in the request.

How long can a URL be?
There is no limit in RFC 3986 itself, but browsers, proxies and servers impose their own, and these differ. Keep URLs short, and send large data in a request body.

Can I put JSON in a query string?
You can, if you encode it as a value, but it grows quickly because braces, quotes and colons each become three characters. Prefer a request body for anything beyond a few small fields.

Why does URLSearchParams encode ~ when RFC 3986 says it is unreserved?
It follows the WHATWG form-urlencoded rules, which have their own set of characters left unescaped. The result is still decodable by any compliant decoder, but it differs from strict RFC 3986 output, which matters for signatures.

Conclusion

URL encoding looks like a small detail, yet it sits where data meets structure, and that is where bugs and security holes appear. Learn the three character classes, encode each value for the component that will hold it, keep forms and plain percent-encoding apart, encode once and decode once, and let URL, URLSearchParams and urllib.parse do the work instead of concatenating strings. When you need a quick answer, paste a value into the URL Encoder or inspect a suspicious link with the URL Decoder; both run in your browser and send nothing to a server. To keep going, read our guides to JSON formatting, for payloads that travel inside URLs, and JSON minification and payload size, for keeping what you send compact.

Further reading

Try the tools

Further reading

Related articles