
URL エンコード入門 - パーセントエンコーディングの仕組みと、+ と %20・encodeURIComponent の落とし穴
URL・フォーム・ヘッダーの実際を歴史から追える。
エスケープ処理を含む Web セキュリティの定番教科書。
HTTP と DNS を含むプロトコルの全体像に。
当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。
検索フォームに「東京 カフェ」と入れて送信すると、アドレスバーには ?q=%E6%9D%B1%E4%BA%AC+%E3%82%AB%E3%83%95%E3%82%A7 のような文字列が現れます。これがパーセントエンコーディング(一般に「URL エンコード」と呼ばれるもの)です。
毎日目にしているわりに、「空白は %20 なのか + なのか」「encodeURI と encodeURIComponent のどちらを使うのか」「なぜ %25 が混ざって壊れるのか」といった点で迷いやすい仕組みでもあります。この記事では、仕様(RFC 3986 と WHATWG URL Standard)を根拠に仕組みを整理し、ブラウザで試せるデモと、各言語の関数の実行結果の比較で違いを確かめます。
URL に書ける文字は限られている
URL の文法を定める RFC 3986 では、URL に書ける文字は ASCII の一部だけです。日本語はもちろん、空白も URL にそのまま書くことはできません。
さらに、ASCII の記号の多くは URL の区切りとして意味を持っています。
https://user@example.com:8080/docs/page?q=url&lang=ja#section-2
\___/ \__/ \_________/ \__/\________/ \___________/ \_______/
scheme user host port path query fragment
:// @ : ? #検索語として「a&b」を送りたいとき、?q=a&b と書くと & がパラメーターの区切りと解釈され、q=a と b という2つのパラメーターになってしまいます。区切りとしてではなくデータとしての & を送るには、別の表現に置き換える必要があります。これがパーセントエンコーディングの役割です。
パーセントエンコーディングの仕組み
手順は単純です。
- 文字を UTF-8 のバイト列にする
- 1バイトずつ
%と16進数2桁で表す
たとえば & は ASCII で 0x26 なので %26、空白は 0x20 なので %20 になります。日本語の「日」は UTF-8 で E6 97 A5 の3バイトなので、%E6%97%A5 の9文字になります。
| 文字 | UTF-8 のバイト列 | エンコード後 |
|---|---|---|
& | 26 | %26 |
| 空白 | 20 | %20 |
% | 25 | %25 |
日 | E6 97 A5 | %E6%97%A5 |
À | C3 80 | %C3%80 |
RFC 3986 は次の点を定めています。
- 16進数の
A〜Fは大文字・小文字どちらでも同じ意味だが、作る側は大文字を使うべき(2.1節) %自体をデータとして使うときは必ず%25にする(2.4節)- 同じ文字列を2回以上エンコード・デコードしてはいけない(2.4節)
- 非 ASCII の文字は、まず UTF-8 にしてからバイトごとに
%XXにする(2.5節。新しいスキームへの推奨)
日本語1文字が9文字に膨らむので、日本語の多い URL は長くなります。Base64 が3バイトを4文字にするのに比べると、効率はかなり悪い方式です(Base64 の仕組み)。文字とバイト列の関係は文字コードと UTF-8 の記事で詳しく書いています。
予約文字と非予約文字
RFC 3986 は、ASCII の記号を2種類に分けています。
| 分類 | 文字 | 意味 |
|---|---|---|
| 非予約文字(unreserved) | A-Z a-z 0-9 - . _ ~ | どこに書いても特別な意味がない。エンコードする必要はない |
| 予約文字 gen-delims | : / ? # [ ] @ | URL の大きな区切り |
| 予約文字 sub-delims | ! $ & ' ( ) * + , ; = | 部品の中での区切りとして使われうる |
ルールは次のとおりです。
- 非予約文字はそのまま書く。わざわざ
%7Eのようにエンコードするべきではなく、正規化するときはデコードしてよい(2.3節) - 予約文字は、区切りとして使うならそのまま、データとして使うならエンコードする
- それ以外(空白、
"、<、日本語など)は常にエンコードする
ここで大事なのは、「予約文字をエンコードするかどうか」は文字だけでは決まらないという点です。/ はパスの区切りとして使うならそのまま、ファイル名の一部としてデータに含めるなら %2F にします。つまり、エンコードは「URL 全体」ではなく「部品に入れる値」に対して行うのが基本です。
場所によって必要なエンコードが違う
RFC 3986 では、クエリとフラグメントに / と ? をそのまま書けます(3.4節・3.5節)。?q=a/b の / はパスの区切りとは解釈されません。
ブラウザの実装の基準になっている WHATWG URL Standard は、これを「percent-encode set」という形で部品ごとに定義しています。小さいセットに文字を足していく積み上げ式です。
| セット | 前のセットに加える文字 | 使われる場所 |
|---|---|---|
| C0 control | C0 制御文字と、~ より大きいすべてのコードポイント | ホスト以外の基本 |
| fragment | 空白 " < > ` | # 以降 |
| query | 空白 " # < >(C0 control に加える) | ? 以降 |
| special-query | ' | http / https などのクエリ |
| path | ? ^ ` { } | パス |
| userinfo | / : ; = @ [ \ ] | | ユーザー情報 |
| component | $ % & + , | 値1つを丸ごと入れるとき |
| application/x-www-form-urlencoded | ! ' ( ) ~ | フォームの送信 |
仕様には、component のセットで UTF-8 エンコードした結果は JavaScript の encodeURIComponent() と一致する、と注記されています。
空白は %20 か + か
URL の話でいちばん混乱しやすいのがここです。結論から言うと、+ を空白として扱うのは application/x-www-form-urlencoded という形式だけです。
- RFC 3986 では
+は sub-delims の1つにすぎず、空白の意味はありません。空白は%20です。 - HTML のフォームを GET や POST で送るときの形式
application/x-www-form-urlencodedでは、空白を+にします。由来は1995年の HTML 2.0(RFC 1866 8.2.1節)で、空白を+に置き換えると定められていました。 - 現在の定義は WHATWG URL Standard の5節にあり、エンコード時は空白を
+に、デコード時は+を空白に戻すと書かれています。
WHATWG URL Standard 自身が、この形式を "an aberrant monstrosity"(異常な怪物)と評しています。長年の実装の事故と妥協の積み重ねでできた形式だから、という説明です。
実務で困るのは、同じクエリ文字列を、どちらの規則で読むかで結果が変わることです。
const qs = 'q=C++'
new URLSearchParams(qs).get('q') // 'C '(C と空白2つ)
decodeURIComponent('C++') // 'C++'
new URL('https://example.com/?q=a b').search // '?q=a%20b'URLSearchParams はフォーム形式なので + を空白として読みます。decodeURIComponent は RFC 3986 寄りなので + をそのまま残します。送る側が encodeURIComponent で + を %2B にしておけば、どちらで読んでも + のまま届きます。
デモ: 関数ごとの違いを確かめる
文字列を入力すると、ブラウザ標準の関数でエンコードした結果と、1文字ごとに「そのまま残すか、変換するか」を表示します。下の欄では、同じ文字列を decodeURIComponent とフォーム形式の2通りでデコードした結果を比べられます。C++ や 100% を入れてみてください。
| 文字 | UTF-8 を %XX にした形 | encodeURI | encodeURIComponent | RFC 3986 厳密版 | URLSearchParams |
|---|---|---|---|---|---|
| a | %61 | 残す | 残す | 残す | 残す |
| (空白) | %20 | 変換 | 変換 | 変換 | 変換 |
| b | %62 | 残す | 残す | 残す | 残す |
| & | %26 | 残す | 変換 | 変換 | 変換 |
| c | %63 | 残す | 残す | 残す | 残す |
| = | %3D | 残す | 変換 | 変換 | 変換 |
| d | %64 | 残す | 残す | 残す | 残す |
| / | %2F | 残す | 変換 | 変換 | 変換 |
| e | %65 | 残す | 残す | 残す | 残す |
| ? | %3F | 残す | 変換 | 変換 | 変換 |
| f | %66 | 残す | 残す | 残す | 残す |
| # | %23 | 残す | 変換 | 変換 | 変換 |
| g | %67 | 残す | 残す | 残す | 残す |
| + | %2B | 残す | 変換 | 変換 | 変換 |
| h | %68 | 残す | 残す | 残す | 残す |
| ~ | %7E | 残す | 残す | 残す | 変換 |
| ! | %21 | 残す | 残す | 変換 | 変換 |
| ' | %27 | 残す | 残す | 変換 | 変換 |
| ( | %28 | 残す | 残す | 変換 | 変換 |
| ) | %29 | 残す | 残す | 変換 | 変換 |
| * | %2A | 残す | 残す | 変換 | 残す |
| 日 | %E6%97%A5(3 バイト) | 変換 | 変換 | 変換 | 変換 |
| 本 | %E6%9C%AC(3 バイト) | 変換 | 変換 | 変換 | 変換 |
JavaScript のエンコード関数
JavaScript には目的の違う関数が複数あります。
| 関数 | そのまま残す文字 | 用途 |
|---|---|---|
encodeURIComponent | 英数字と - _ . ! ~ * ' ( ) | 値1つ(クエリの値、パスの1セグメント) |
encodeURI | 上に加えて ; / ? : @ & = + $ , # | すでに組み立てた URL 全体 |
URLSearchParams | 英数字と * - . _。空白は + | フォーム形式のクエリ |
escape(非推奨) | 英数字と @ * _ + - . /。非 ASCII は %uXXXX | 使わない |
(出典: MDN の encodeURIComponent・encodeURI・escape、WHATWG URL Standard)
encodeURI は値に使わない
encodeURI は「URL 全体を受け取り、区切り文字を壊さずに日本語や空白だけを変換する」関数です。そのため & = / ? # を残します。値に使うと、データとしての & が区切りのまま残ってしまいます。
const keyword = 'a&b=c'
'?q=' + encodeURI(keyword) // '?q=a&b=c' -> q と b の2つに分かれる
'?q=' + encodeURIComponent(keyword) // '?q=a%26b%3Dc' -> q が1つencodeURIComponent は RFC 3986 と完全には一致しない
encodeURIComponent は ! ' ( ) * をエンコードしません。これは古い RFC 2396 で「unreserved marks」とされていた文字で、RFC 3986 では sub-delims(予約文字)に移っています。厳密に RFC 3986 に合わせたい場合は、MDN が紹介している次の関数を使います。
function encodeRFC3986URIComponent(str) {
return encodeURIComponent(str).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
)
}普段のクエリの値であれば encodeURIComponent のままで問題になることは少ないですが、署名付き URL(OAuth 1.0 や AWS Signature Version 4 など)のように「エンコード後の文字列を署名の材料にする」場面では、この差で署名が一致しなくなります。
組み立てるなら URL と URLSearchParams
文字列連結で URL を組み立てるより、URL と URLSearchParams に任せるほうが安全です。
const url = new URL('https://example.com/search')
url.searchParams.set('q', '東京 カフェ')
url.searchParams.set('tag', 'C++')
url.href
// 'https://example.com/search?q=%E6%9D%B1%E4%BA%AC+%E3%82%AB%E3%83%95%E3%82%A7&tag=C%2B%2B'searchParams はフォーム形式なので空白は +、データの + は %2B になり、受け取る側がフォーム形式で読めば正しく戻ります。MDN も、URLSearchParams を「動的に埋め込んだ文字列」から作らないよう注意しています。値は set() や append() で渡します。
なお、URL の search に直接書いた場合は空白が %20 になり、あとから searchParams を操作すると全体が + 形式で書き直されます。同じオブジェクトでも経路によって形式が変わる点は覚えておくとよいでしょう。
デコードと例外
decodeURIComponent は、% の後ろが16進2桁でない場合や、バイト列が正しい UTF-8 にならない場合に URIError を投げます。
decodeURIComponent('100%') // URIError: URI malformed
decodeURIComponent('%E6%97') // URIError(「日」の3バイト目が足りない)
new URLSearchParams('q=100%').get('q') // '100%'(そのまま残す)ユーザーが入力した値や外部から来た URL をデコードするときは、try / catch で囲むか、例外を投げない URLSearchParams を使います。逆にエンコードでは、対になっていないサロゲート('\uD800' など)を encodeURIComponent に渡すと URIError になります。不正な文字列が混ざる可能性がある場合は、ES2024 の String.prototype.isWellFormed() で確認するか、toWellFormed() で置き換えてから渡します。
各言語の関数を比べる
「URL エンコード」という名前の関数でも、言語やライブラリによって残す文字が違います。同じ入力 a b&c=d/e?f#g+h~!'()*日本 を、Node.js v24.11.0、Python 3.13.7、PHP 8.5.10、Go 1.25.6 で実際に変換した結果です。
| 言語・関数 | 出力 |
|---|---|
JS encodeURI | a%20b&c=d/e?f#g+h~!'()*%E6%97%A5%E6%9C%AC |
JS encodeURIComponent | a%20b%26c%3Dd%2Fe%3Ff%23g%2Bh~!'()*%E6%97%A5%E6%9C%AC |
JS URLSearchParams | q=a+b%26c%3Dd%2Fe%3Ff%23g%2Bh%7E%21%27%28%29*%E6%97%A5%E6%9C%AC |
JS encodeRFC3986URIComponent | a%20b%26c%3Dd%2Fe%3Ff%23g%2Bh~%21%27%28%29%2A%E6%97%A5%E6%9C%AC |
Python quote(s) | a%20b%26c%3Dd/e%3Ff%23g%2Bh~%21%27%28%29%2A%E6%97%A5%E6%9C%AC |
Python quote(s, safe='') | a%20b%26c%3Dd%2Fe%3Ff%23g%2Bh~%21%27%28%29%2A%E6%97%A5%E6%9C%AC |
Python quote_plus(s) | a+b%26c%3Dd%2Fe%3Ff%23g%2Bh~%21%27%28%29%2A%E6%97%A5%E6%9C%AC |
PHP urlencode | a+b%26c%3Dd%2Fe%3Ff%23g%2Bh%7E%21%27%28%29%2A%E6%97%A5%E6%9C%AC |
PHP rawurlencode | a%20b%26c%3Dd%2Fe%3Ff%23g%2Bh~%21%27%28%29%2A%E6%97%A5%E6%9C%AC |
Go url.QueryEscape | a+b%26c%3Dd%2Fe%3Ff%23g%2Bh~%21%27%28%29%2A%E6%97%A5%E6%9C%AC |
Go url.PathEscape | a%20b&c=d%2Fe%3Ff%23g+h~%21%27%28%29%2A%E6%97%A5%E6%9C%AC |
表から次のことが読み取れます。
- RFC 3986 どおりなのは、PHP の
rawurlencode、Python のquote(s, safe='')、MDN のencodeRFC3986URIComponentで、3つとも出力が完全に一致する - 空白を
+にするのはURLSearchParams、Python のquote_plus(と既定のurlencode)、PHP のurlencode、Go のQueryEscape - Python の
quoteは既定で/を残す(引数safeの既定値が'/')。パス向けの既定なので、クエリの値にはsafe=''かquote_plusを使う ~の扱いも分かれる。PHP のurlencodeとURLSearchParamsは%7Eにし、ほかは残すURLSearchParamsだけは*を残す(WHATWG のフォーム形式の定義どおり)- Go の
PathEscapeはパスの1セグメント用なので、/はエンコードし、&=+は残す
各関数の仕様は、Python の urllib.parse、PHP の urlencode と rawurlencode、Go の net/url を参照してください。デコード側も同じように分かれていて、Python の unquote と Go の PathUnescape は + を空白に戻さず、unquote_plus と QueryUnescape は戻します。
ドメイン名はパーセントエンコードしない
日本語ドメイン名(国際化ドメイン名、IDN)は、パーセントエンコードではなく Punycode という別の方式で ASCII に変換されます。DNS が ASCII の名前しか扱えないためで、RFC 3986 も名前解決の前に IDNA 形式へ変換するよう定めています(3.2.2節)。
new URL('https://日本語.jp/パス?q=a b&x=1+1#フラグ').href
// 'https://xn--wgv71a119e.jp/%E3%83%91%E3%82%B9?q=a%20b&x=1+1#%E3%83%95%E3%83%A9%E3%82%B0'1つの URL の中で、ホストは xn-- で始まる Punycode、パス・クエリ・フラグメントはパーセントエンコードと、部品ごとに方式が変わっています。クエリの + が空白に変換されずに残っている点にも注目してください。名前解決の流れは DNS の記事で書いています。
実務でよく起きるトラブル
二重エンコードで %25 が混ざる
エンコード済みの値をもう一度エンコードすると、% が %25 になります。
const once = encodeURIComponent('日本 語') // '%E6%97%A5%E6%9C%AC%20%E8%AA%9E'
encodeURIComponent(once) // '%25E6%2597%25A5...%2520...'URL に %25E6 のような並びが見えたら、ほぼ二重エンコードです。よくあるのは、エンコード済みの値を searchParams.set() に渡すケース、フレームワークが自動でエンコードするのに自前でもエンコードしているケースです。RFC 3986 が「2回以上エンコード・デコードしてはいけない」と書いているとおり、エンコードは URL を組み立てる直前に1回だけ、どこでエンコードするかを1か所に決めておくのが対策です。
このブログでも経験があります。Next.js の静的書き出し(output: "export")に移行したとき、タグページの generateStaticParams がタグ名を encodeURIComponent してから返していました。Next.js 自身もパスの値をエンコードするため二重エンコードになり、「Claude Code」というタグの値が Claude%2520Code になってタグ名と一致しなくなりました。その結果、空白や日本語を含むタグのページが、HTTP ステータスは 200 のまま中身だけ「ページが見つかりません」になっていました。フレームワークがパスの値をどう扱うかを確認しないまま「念のため」エンコードするのは危険です。
+ が空白に化ける
前述のとおり、フォーム形式で読む側は + を空白にします。C++、電話番号の +81、Base64 の文字列などをクエリに載せると壊れます。送る側で encodeURIComponent や URLSearchParams を使えば %2B になるので、手で文字列を連結しないことが対策です。Base64 を URL に載せる場合は、+ と / を使わない base64url を使うのが定番です(Base64 の記事)。
パスに / を含む値
2026/10/report.pdf のような値をパスの1セグメントとして渡すと、/ が階層の区切りとして解釈されます。encodeURIComponent で %2F にすれば区切りではなくなりますが、今度はサーバー側で拒否されることがあります。たとえば Apache は既定(AllowEncodedSlashes Off)で、パスに %2F を含むリクエストを 404 で返します(Apache のドキュメント)。/ を含みうる値は、パスではなくクエリで渡すほうが無難です。
ダウンロードするファイル名の日本語
Content-Disposition ヘッダーで日本語のファイル名を指定するときにも、パーセントエンコードを使います。filename* パラメーターに文字コードを明示して書く形式です(RFC 6266。書式は RFC 8187 で定義。RFC 8187 は旧 RFC 5987 を置き換えたもの)。
Content-Disposition: attachment; filename="estimate.pdf"; filename*=UTF-8''%E8%A6%8B%E7%A9%8D%E6%9B%B8.pdffilename* を理解するブラウザはこちらを優先し、理解しない古い実装は ASCII の filename を使います。
16進数の大文字と小文字
%7e と %7E は同じ意味ですが、文字列として比較すると別物です。new URL('https://example.com/%7e').href は %7e のまま保持し、大文字に直しません。キャッシュのキーや署名の比較で URL を文字列として扱う場合は、比較する前に正規化(16進数を大文字にそろえ、非予約文字の %XX はデコードする)しておく必要があります。
まとめ
- パーセントエンコーディングは、文字を UTF-8 のバイト列にして、1バイトずつ
%XXで表す方式 - RFC 3986 の非予約文字(英数字と
- . _ ~)はそのまま、予約文字は「区切りとして使うか、データとして使うか」で変わる。エンコードは URL 全体ではなく部品の値に対して行う - 空白を
+にするのはapplication/x-www-form-urlencodedだけ。RFC 3986 では空白は%20、+はただの記号 - JavaScript では値に
encodeURIComponent、組み立てはURLとURLSearchParams。encodeURIは値に使わない - 言語ごとに残す文字が違う(
/、~、*、空白)。RFC 3986 どおりなのは PHP のrawurlencode、Python のquote(s, safe='')など - ドメイン名は Punycode。二重エンコード、
+の化け、パスの%2F、ファイル名のfilename*が実務の定番トラブル
参考リンク
- RFC 3986: Uniform Resource Identifier (URI): Generic Syntax
- RFC 3987: Internationalized Resource Identifiers (IRIs)
- URL Standard(WHATWG)
- RFC 1866: Hypertext Markup Language - 2.0
- HTML Standard: URL-encoded form data
- encodeURIComponent()(MDN)
- encodeURI()(MDN)
- decodeURIComponent()(MDN)
- URLSearchParams(MDN)
- urllib.parse(Python ドキュメント)
- urlencode / rawurlencode(PHP マニュアル)
- net/url(Go)
- RFC 6266: Use of the Content-Disposition Header Field in HTTP
- RFC 8187: Indicating Character Encoding and Language for HTTP Header Field Parameters
- AllowEncodedSlashes(Apache HTTP Server ドキュメント)


