URL Decode and Percent-Encoding Errors: A Practical Developer Guide

Learn when to decode a full URL, when to decode only a query value, why plus signs sometimes become spaces, and how to avoid the bugs that appear after one decode too many.

18 min read URL Encoding

URL decoding looks simple until a production bug proves otherwise. Someone copies a callback URL from logs, a query value contains %26, a search term arrives with plus signs, or a redirect parameter is decoded twice and suddenly points somewhere else. The operation is small. The context around it is where the bugs live.

This guide is for developers debugging API calls, OAuth redirects, tracking links, webhooks, analytics URLs, search forms, and pasted log snippets. The goal is not just to turn %20 into a space. The goal is to decode the right URL part exactly once, with the right rules for that part.

Quick answer

URL decoding converts percent-encoded byte sequences back to characters. In JavaScript, use URLSearchParams for query strings, decodeURIComponent for a single encoded value, and decodeURI only for a whole URL. In Python, use urllib.parse.unquote for percent encoding and unquote_plus for form-style query values where + means space.

What URL decoding actually decodes

Percent-encoding represents bytes using a percent sign followed by two hexadecimal digits. A decoded character may come from one byte, such as %2F for slash, or several bytes, such as %E2%9C%93 for a check mark in UTF-8. Modern web URLs should be treated as UTF-8 unless a legacy system explicitly documents another encoding.

EncodedDecodedWhy it matters
%20spaceMost URL contexts encode a space as %20.
+space in form query stringsOnly application/x-www-form-urlencoded treats plus as space.
%2F/A slash inside data, not necessarily a path separator.
%3F?A literal question mark inside a value.
%26&A literal ampersand inside a query value.
%3D=A literal equals sign inside a query value.
%25%The percent sign itself, often seen in double-encoding bugs.
%E2%9C%93check markA UTF-8 character encoded as three bytes.

The important detail is that decoding can create structural URL characters. If %26 becomes & before you parse the query string, one value can accidentally become two parameters. That is why order matters: parse structure first when you have a full URL; decode values after the structure is known.

decodeURI vs decodeURIComponent vs URLSearchParams

JavaScript gives you more than one decoder because different URL parts reserve different characters. The shortest rule is: decodeURIComponent decodes data, decodeURI decodes a URL while keeping its separators meaningful, and URLSearchParams should be your default for query strings.

FunctionBest forKeeps URL structure?Plus-space rule
decodeURIComponent(value)One query value, one form field, one path segment.NoDoes not turn + into space.
decodeURI(url)A whole URL that is already structurally valid.YesDoes not turn + into space.
new URL(url).searchParamsReading query parameters in browsers or Node.js.Parses structure firstTurns + into space for query strings.
urllib.parse.unquote(value)Python decoding for path segments or percent-encoded values.NoDoes not turn + into space.
urllib.parse.unquote_plus(value)Python decoding for form-style query values.NoTurns + into space.
const raw = "https://example.com/search?q=red%20shoes%20%26%20socks&next=%2Fcheckout%3Fstep%3D1";

const url = new URL(raw);

console.log(url.searchParams.get("q"));
// red shoes & socks

console.log(url.searchParams.get("next"));
// /checkout?step=1

console.log(decodeURI(raw));
// https://example.com/search?q=red shoes %26 socks&next=%2Fcheckout%3Fstep%3D1

console.log(decodeURIComponent("red%20shoes%20%26%20socks"));
// red shoes & socks

In real code, I rarely decode a full URL string manually. I construct a URL, read the exact field, and let the platform parser preserve the boundary between path, query, and fragment. It is less dramatic than string splitting, and that is the point.

Runnable JavaScript: decode query values safely

This Node.js example shows the safest default workflow for a full URL: parse first, then read values. It also shows the common mistake of decoding the whole query text before splitting on &.

// decode-url.js
const rawUrl =
  "https://api.example.test/items?q=red%20shoes%20%26%20socks&tag=a%2Fb&debug=true";

const url = new URL(rawUrl);

console.log("q:", url.searchParams.get("q"));
console.log("tag:", url.searchParams.get("tag"));
console.log("debug:", url.searchParams.get("debug"));

// Bad idea: decoding the whole query can create new delimiters.
const rawQuery = rawUrl.split("?")[1];
console.log("decoded raw query:", decodeURIComponent(rawQuery));

// Run:
// node decode-url.js

// Expected output:
// q: red shoes & socks
// tag: a/b
// debug: true
// decoded raw query: q=red shoes & socks&tag=a/b&debug=true

The decoded raw query output is misleading. It visually suggests that socks might be a separate parameter because %26 has already become &. That is fine for display, but not for parsing. When parsing, keep delimiters encoded until the URL parser has done its job.

Runnable Python: unquote vs unquote_plus

Python has the same distinction, but it is named differently. Use unquote when decoding ordinary percent-encoded text. Use unquote_plus for form-encoded query values where plus is a space.

# decode_url.py
from urllib.parse import parse_qs, unquote, unquote_plus, urlparse

raw_url = "https://example.test/search?q=red+shoes%2Bsocks&path=a%2Fb"

parsed = urlparse(raw_url)
params = parse_qs(parsed.query)

print(params["q"][0])
print(params["path"][0])

print(unquote("red+shoes%2Bsocks"))
print(unquote_plus("red+shoes%2Bsocks"))

# Run:
# python decode_url.py

# Expected output:
# red shoes+socks
# a/b
# red+shoes+socks
# red shoes+socks

The subtle part is %2B. It decodes to a literal plus. A raw + in a form query decodes to a space. When search terms, math expressions, email aliases, or phone numbers contain plus signs, you need to know whether the plus was raw or encoded.

Common URL decoding mistakes

Most URL decode bugs are not caused by the decoder being broken. They happen because code decodes at the wrong layer. Treat decoding as a boundary decision, not a random cleanup step.

Decoding the whole URL when only one value should be decoded

If you decode an entire URL, encoded delimiters such as %26 and %3D can become real separators. Parse the URL first, then decode the field you need.

Double decoding

A value like %252F becomes %2F after one decode and / after the second. That can change an ID into a path delimiter or a harmless string into a redirect target.

Using the wrong space rule

A plus sign is a space in form-encoded query strings, but it is a literal plus in many other contexts. This is the source of many broken search and OAuth callback bugs.

Treating malformed percent escapes as normal text

Inputs like %E0%A4%A or %ZZ are not valid encoded data. JavaScript throws URIError; Python may replace invalid bytes depending on the error handling you choose.

Decoding before security checks

Access-control, redirect, and path checks should be explicit about whether they inspect raw text or decoded text. Mixing the two is how bypasses happen.

Double decoding: the bug that hides in logs

Double decoding is especially easy to miss because logs, proxies, frameworks, and browsers may already decode some parts for display. If you decode again in application code, the value can change meaning. Here is the small example that I use when reviewing redirect or file-path code:

const value = "%252Fadmin%252Fsettings";

const once = decodeURIComponent(value);
const twice = decodeURIComponent(once);

console.log(once);
// %2Fadmin%2Fsettings

console.log(twice);
// /admin/settings

After one decode, the value is still text that contains encoded slashes. After two decodes, it is a path. That difference matters for redirects, file downloads, object keys, cache keys, route matching, and authorization checks. The safest practice is to decode once as close as possible to the request boundary, store that boundary choice in a variable name, and avoid helper functions that silently decode again.

Production habit

In request handlers, name values by state: rawNext, decodedNext, validatedNextPath. That sounds fussy, but it prevents the same string from being decoded, validated, and mutated in different places.

Malformed percent escapes and UTF-8 errors

Not every percent sign starts valid encoded data. JavaScript is strict: decodeURIComponent throws URIError for malformed sequences. That is a useful signal when a URL came from user input, logs, old email clients, or manual copy-paste.

Throws

decodeURIComponent("%E0%A4%A");
// URIError: URI malformed

decodeURIComponent("%ZZ");
// URIError: URI malformed

Handled

function tryDecode(value) {
  try {
    return { ok: true, value: decodeURIComponent(value) };
  } catch {
    return { ok: false, value };
  }
}

Do not fix malformed input by repeatedly replacing percent signs until the exception disappears. That can turn invalid data into different valid data. Decide whether the correct behavior is to reject the request, display the raw value, or decode with a documented replacement strategy.

When not to decode a URL

There are times when decoding makes debugging easier but the application less correct. The raw encoded form is sometimes the data you need to preserve.

Signed URLs

Changing encoding can invalidate a signature because the byte-for-byte canonical string changes.

Cache keys

Raw and decoded forms may map differently. Normalize intentionally, not by accident.

Redirect parameters

Decode once, validate the destination, and reject cross-origin or protocol-relative surprises.

Object storage keys

A slash may be data inside an object key, not a folder separator.

Debugging checklist

  • Identify whether you have a full URL, a query string, one query value, a path segment, or a form body.
  • Parse the full URL before manually decoding values.
  • Use the decoder that matches the context: component, whole URL, or form query.
  • Check for double decoding by testing values containing %252F, %2526, and %253D.
  • Reject, preserve, or safely display malformed input instead of forcing it through a decoder.

Frequently asked questions

What does URL decode mean?

URL decoding converts percent-encoded byte sequences such as %20, %2F, %3F, and UTF-8 sequences back into readable characters.

Should I use decodeURI or decodeURIComponent?

Use decodeURIComponent for one encoded value. Use decodeURI only when decoding a full URL while preserving URL structure.

Why does plus become a space in some decoded URLs?

A plus sign means space in form-encoded query strings and form bodies. In other URL contexts, plus can be a literal plus. Use URLSearchParams or unquote_plus when you are intentionally reading form-style query values.

What causes URIError: malformed URI sequence?

It usually means the input has invalid percent escapes or invalid UTF-8 after percent decoding. Examples include %ZZ and incomplete multi-byte sequences.

Is double decoding dangerous?

Yes. Double decoding can turn safe-looking text into delimiters, paths, or redirect targets. Decode once at the boundary and validate the decoded value before using it.

Authoritative references

Use primary references when behavior differs across platforms:

Related tutorials

Z

Written by Zhisan

Independent Developer - Last updated July 2026