URL-encode or decode a string
Percent-encode a string for safe inclusion in a URL query, or decode an already-percent-encoded string back to its original characters — for building API query parameters, decoding `Location:` redirect headers, and inspecting OAuth callback URLs.
How to url-encode or decode a string in each shell
printf '%s' "hello world & friends" | jq -sRr @uriThe jq `@uri` filter is the cleanest one-liner if jq is available — `-s` slurps stdin into a single string, `-R` raw input (no JSON parsing), `-r` raw output (no JSON quoting). Output: `hello%20world%20%26%20friends`. No-jq alternative: `python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read(), safe=\"\"))"` (the `safe=""` is critical — default `safe="/"` leaves slashes unencoded, wrong for query values). Pure-bash hack (slow, ugly, no deps): `printf %s "$str" | xxd -p -c1 | while read x; do case $x in [4-7][0-9a-f]) printf "\\x$x" ;; *) printf %%%s "$x" ;; esac; done` — almost always reach for python or jq instead. DECODE: `printf %b "${str//%/\\x}"` (bash-only printf-trick), or `python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read()))"`.
print -rn -- "hello world & friends" | jq -sRr @uriSame external jq / python options. Zsh-specific: the `zsh/net/url` builtin module ships URL helpers — `zmodload zsh/net/url; url_encode "hello world"` returns `hello%20world` with no fork. Not in default zsh on all distros — `zmodload -L | grep url` to check. For OAuth redirect parsing, `print -P` doesn't interpret URL encoding (use the decode path same as bash).
string escape --style=url "hello world & friends"Fish has it built in — `string escape --style=url` encodes, `string unescape --style=url` decodes. No external dependency. Note fish uses `+` for space (the `application/x-www-form-urlencoded` style) NOT `%20` (the RFC 3986 style) — fine for form bodies, WRONG for path segments and most query values. If you need strict RFC 3986: fall back to `jq @uri` or python `urllib.parse.quote`. Fish is the only shell whose built-in defaults to form-encoding rather than RFC 3986.
[Uri]::EscapeDataString("hello world & friends")**Pick the right method**: `[Uri]::EscapeDataString(s)` is **RFC 3986 conformant** — encodes everything except `A-Za-z0-9-_.~` (the unreserved set). Use this for query VALUES, path SEGMENTS, anywhere strict spec compliance matters. `[Uri]::EscapeUriString(s)` is **DEPRECATED** since .NET Core 2.0 — left untouched: `/`, `?`, `#`, `:`, etc — almost never what you want today. `[Web.HttpUtility]::UrlEncode(s)` is the **`application/x-www-form-urlencoded`** variant (space → `+`, not `%20`) — use only for form POST bodies; requires `Add-Type -AssemblyName System.Web` on pwsh 5.1 / Windows (auto-loaded on pwsh 6+). DECODE: `[Uri]::UnescapeDataString(s)` (RFC 3986) or `[Web.HttpUtility]::UrlDecode(s)` (form-style, handles `+` → space too).
powershell -NoProfile -Command "[Uri]::EscapeDataString('hello world & friends')"cmd has NO native URL-encoding. Shell out to pwsh (above) — `[Uri]::EscapeDataString` for RFC 3986, `[Uri]::UnescapeDataString` for the inverse. If pure-cmd is mandatory (rare but happens in legacy `.bat` builds), the `SET /A` arithmetic + character-table approach exists but is fragile; just don't. For embedding INTO a `.bat`: `for /f "delims=" %i in ('powershell -NoP -C "[Uri]::EscapeDataString('%RAW%')"') do set ENCODED=%i`.
Equivalents listed for Bash, Zsh, Fish, PowerShell, cmd.exe.
Gotchas & notes
- **RFC 3986 vs `application/x-www-form-urlencoded` is the #1 confusion**: RFC 3986 (the URL spec) encodes space as `%20`, period. The form-encoding (`application/x-www-form-urlencoded`, what `<form method=POST>` sends) encodes space as `+` AND a literal `+` in input as `%2B`. They differ ONLY in the space-vs-`+` rule, but it breaks things subtly. Rule: query VALUES in a URL path go through RFC 3986 (`%20`); form BODIES POSTed with content-type `application/x-www-form-urlencoded` use the form style (`+`). When in doubt, `%20` works in BOTH contexts; `+` works only as form-encoding.
- **Reserved vs unreserved characters (RFC 3986 §2.3)**: unreserved = `A-Za-z0-9-_.~` (never encoded). Reserved = `:/?#[]@!$&'()*+,;=` (encoded depending on context: path segment vs query). `[Uri]::EscapeDataString` encodes reserved chars in addition to all non-unreserved bytes — safe for use ANYWHERE in a URL. The deprecated `[Uri]::EscapeUriString` left reserved chars untouched, which broke when reserved chars appeared inside values (a `?` inside a query VALUE looks like the start of the query to a parser).
- **Decoding traps**: a decoded string can contain newlines, NUL bytes, shell metacharacters — never feed decoded output directly into `eval` or unquoted into `xargs`. The `Location:` header from a 302 is URL-encoded (`Location: /search?q=hello%20world`); decoding it before logging is fine, decoding before re-passing to `curl -L` would be wrong (curl handles encoding/decoding itself). For UTF-8 multi-byte characters: each byte encodes separately (`%E2%9C%93` for ✓), and decoders MUST treat the byte sequence as UTF-8 — `[Uri]::UnescapeDataString` is UTF-8 by default; jq `@uri` (encoder) is UTF-8 by default.
- **Idempotence**: encoding an already-encoded string DOUBLE-encodes it (`%20` becomes `%2520`) — a classic source of "the URL has %2520 in it" bug reports. Always know whether the string in hand is raw or already encoded before re-encoding. The `%` character is itself reserved and must be encoded as `%25` — so any time you see `%25` followed by two hex digits, suspect double-encoding. The inverse decode is safe: decoding an already-decoded string is a no-op (no `%`-sequences to expand).
Related commands
Related tasks
- Encode a string to base64— Convert a string (or file) to its base64 representation — for HTTP basic auth headers, JWT payloads, embedded credentials in YAML, or any text-only transport of binary.
- Decode a base64 string— Convert a base64-encoded string back to its original bytes — for inspecting JWT payloads, decoding kubectl secret values, reading basic-auth headers, and pulling binary out of YAML / JSON configs.