URL paths and query strings both use percent-encoding, but they follow different rules about which characters are safe to leave unencoded. In a path segment you may use the unreserved characters (letters, digits, - . _ ~), the sub-delimiters, and additionally : and @; a query string also allows / and ? unencoded. The characters that carry structural meaning — the / between path segments and the & and = between query parameters — must be percent-encoded (%2F, %26, %3D) when they appear inside a value. A space is %20 everywhere, but in a form-encoded query string it can also be written as +. In JavaScript, encode each individual value with encodeURIComponent() and reserve encodeURI() for a whole, already-assembled URL.
That paragraph is the summary an AI overview gives you. The rest of this article is what it can't: the exact per-component character table you can act on, why the +-means-space rule bites people, the one JavaScript function to reach for and the one bug that keeps encoding right, and how a missing %2F turns into a path-traversal vulnerability.
The anatomy of a URL (and why the boundaries matter)
Every rule below comes from one fact: a URL is not one string, it is a sequence of components with hard boundaries, and the same character can be a delimiter in one spot and data in another. A ? starts the query; a & separates parameters; a / separates path segments; a # starts the fragment. When you put user data into one of those components, your job is to make sure the data can't be mistaken for a delimiter.
When you examine a URL you see the pattern scheme://host:port/path?query#fragment. The path comes before the ? and identifies the resource. The query comes after and carries parameters. They look like one string but are distinct components with distinct encoding requirements — which is exactly where bugs and vulnerabilities creep in.
Path vs query string: the comparison table
This is the table to bookmark. It contrasts how each component treats the characters that trip people up, per RFC 3986.
| Concern | URL Path (a segment) | Query String (a value) |
|---|---|---|
| Always-safe unencoded | Unreserved: A–Z a–z 0–9 - . _ ~ | Unreserved: A–Z a–z 0–9 - . _ ~ |
| Also allowed unencoded | sub-delims ! $ & ' ( ) * + , ; = plus : and @ | everything a path allows, plus / and ? |
| Space | %20 (only valid encoding) | %20, or + in application/x-www-form-urlencoded |
Forward slash / | Delimiter between segments; encode a data slash as %2F | Data only — safe unencoded, or %2F |
& and = | Ordinary data in a path (rare); usually left as-is | Delimiters — encode inside a value as %26 / %3D |
? | Ends the path — encode a data ? as %3F | Data only after the first ? — safe, or %3F |
# | Starts the fragment — always encode as %23 | Starts the fragment — always encode as %23 |
+ | Literal plus sign | Literal plus in raw URLs; means space in form-encoded data |
[ ] | Not allowed in a segment — encode as %5B %5D | Not allowed — encode as %5B %5D |
| Right JS function | encodeURIComponent(segment) | encodeURIComponent(name) and encodeURIComponent(value) |
The single most important row is the last one: you don't hand-apply this table. You call encodeURIComponent() on each individual value, and it encodes every reserved delimiter for you. The table explains why the output looks the way it does — and why encodeURI() (which leaves / ? & = # alone) is the wrong tool for a value.
The +-means-space trap
The plus sign causes more confusion than any other character because its meaning is contextual. In the original URL specification, + is a literal plus sign everywhere. But the application/x-www-form-urlencoded serialization — the format an HTML <form> submits, and the format nearly every server framework assumes when it parses a query string — defines + as an encoded space.
The consequences are concrete:
- A value of
C++placed in a form-encoded query becomesC%2B%2B. If you leave the pluses raw asC++, the server decodes them toC(two spaces). - A
%20in a query string is always safe: both the raw-URL rules and the form-encoded rules decode it to a space. That's whyencodeURIComponent()emits%20and never+— it's the one encoding that can't be misread. - In the path, a
+is never a space./a+bis a segment containing a literal plus. Servers that turn+into a space in the path are technically wrong, though some legacy stacks do it.
Rule of thumb: encode spaces as %20 and let the receiver's form parser handle + on the way in. Never emit + for a space yourself unless you're deliberately building a form-encoded body.
encodeURIComponent vs encodeURI: pick the right layer
JavaScript ships two encoders and getting them backwards is the number-one URL bug.
encodeURIComponent(str)— for a single component: one path segment, one parameter name, one parameter value. It encodes the reserved delimiters/ ? : @ & = + $ , #, so the value can't break out of its slot. This is your default.encodeURI(str)— for a whole, already-structured URL. It deliberately leaves/ ? & = # : @untouched so it doesn't corrupt the URL's own structure. Use it only to make an otherwise-complete URL valid (escaping spaces, non-ASCII), never on a value.
// RIGHT: encode each value, then assemble
const url = `/api/users/${encodeURIComponent(userId)}` +
`?q=${encodeURIComponent(searchTerm)}`;
// WRONG: encodeURIComponent on the whole URL mangles the :// and the ? & =
encodeURIComponent("https://x.com/a?q=1&n=2");
// -> "https%3A%2F%2Fx.com%2Fa%3Fq%3D1%26n%3D2" (now unusable as a URL)
// WRONG: encodeURI on a value fails to escape & and =, so the value
// "a&b=c" injects an extra parameter
`?q=${encodeURI("a&b=c")}`; // -> "?q=a&b=c" (parameter pollution)
Equivalents in other languages: Python's urllib.parse.quote() (path-safe, keeps /) vs quote_plus() (query-safe, encodes /, emits + for space); PHP's rawurlencode() (RFC 3986, %20) vs urlencode() (form-style, +); Java's URLEncoder.encode() (form-style, +) — reach for the component-level, %20-emitting variant unless you specifically want form encoding.
Why this is a security problem, not just a formatting one
Missing encoding lets an attacker change what a URL means. Three classic failures:
- Path traversal. If a user-supplied identifier is dropped into a path unencoded, a value like
../../etc/passwdwalks up the directory tree. Encoding the slashes (..%2F..%2Fetc%2Fpasswd) keeps the value in one segment — but the real fix is server-side validation that the identifier contains only allowed characters. - HTTP parameter pollution. An unencoded
&or=inside a query value injects extra parameters:?user=alice&role=adminsmuggled in as a singleuservalue.encodeURIComponent()turns the injected&/=into%26/%3D, confining them to the value. - Injection smuggling. Encoded payloads (
%27for',%3Cfor<) are a standard way to slip SQL-injection or XSS characters past naive filters that only inspect the raw text. Correct encoding is defense-in-depth, never the whole defense: always decode, validate against an allow-list, and use parameterised queries and context-aware output escaping regardless of what the URL looked like.
Encoding confines a value to its field. Validation decides whether the value is allowed at all. You need both.
Common mistakes and how to avoid them
Double-encoding. Encoding an already-encoded value turns %20 into %2520 (the % itself becomes %25). The server decodes once and gets literal %20 back instead of a space. Encode exactly once, at the single point where the raw value enters the URL, and never re-encode a string that already contains percent-escapes.
Mixing encoders across the codebase. If some code uses encodeURIComponent() and other code uses encodeURI() (or a home-grown function) on the same values, you get inconsistent output and hard-to-trace bugs. Standardise on one approach — component-level encoding at the assembly point.
Assuming "trusted" data is safe. Internal identifiers, database keys, and generated filenames can all contain /, &, spaces, or Unicode. Encode every value that goes into a URL, regardless of source.
Hand-rolling encoding. Don't write your own percent-encoder. The standard library functions already handle the UTF-8-then-percent step for non-ASCII characters (€ → %E2%82%AC) and the exact reserved sets. Reinventing them is how edge cases become vulnerabilities.
The bottom line
Paths and query strings share one encoding scheme (percent-encoding) but not one rulebook: a path segment may keep : and @ and treats / as structure; a query value must encode & and = but may keep / and ?; a space is %20 everywhere, and + only means space inside form-encoded query data. You almost never apply those rules by hand — you call encodeURIComponent() on each individual value, reserve encodeURI() for a finished URL, encode exactly once, and validate on the server no matter how clean the URL looks. Get those four habits right and the entire table above takes care of itself.