URL エンコード入門 - パーセントエンコーディングの仕組みと、+ と %20・encodeURIComponent の落とし穴

URL エンコード入門 - パーセントエンコーディングの仕組みと、+ と %20・encodeURIComponent の落とし穴

作成日:
読了:約26分
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは 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 の区切りとして意味を持っています。

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つのパラメーターになってしまいます。区切りとしてではなくデータとしての & を送るには、別の表現に置き換える必要があります。これがパーセントエンコーディングの役割です。

パーセントエンコーディングの仕組み

手順は単純です。

  1. 文字を UTF-8 のバイト列にする
  2. 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 controlC0 制御文字と、~ より大きいすべてのコードポイントホスト以外の基本
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% を入れてみてください。

▶ URL エンコードの関数ごとの違い
文字列を入力すると、ブラウザ標準の関数でエンコードした結果と、1文字ごとに残るかどうかを表示します。下の欄では、同じ文字列を2通りの方法でデコードした結果を比べられます。
encodeURI - URL 全体向け。; , / ? : @ & = + $ # を残す
a%20b&c=d/e?f#g+h~!'()*%E6%97%A5%E6%9C%AC
encodeURIComponent - 値1つ向け。英数字と - _ . ! ~ * ' ( ) だけ残す
a%20b%26c%3Dd%2Fe%3Ff%23g%2Bh~!'()*%E6%97%A5%E6%9C%AC
RFC 3986 厳密版 - encodeURIComponent に加えて ! ' ( ) * もエンコード
a%20b%26c%3Dd%2Fe%3Ff%23g%2Bh~%21%27%28%29%2A%E6%97%A5%E6%9C%AC
URLSearchParams - フォーム形式。空白は + になる(q= の後ろが値)
q=a+b%26c%3Dd%2Fe%3Ff%23g%2Bh%7E%21%27%28%29*%E6%97%A5%E6%9C%AC
文字UTF-8 を %XX にした形encodeURIencodeURIComponentRFC 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 バイト)変換変換変換変換
decodeURIComponent: a+b&c=d/日
URLSearchParams(フォーム形式): a b&c=d/日
+ の扱い(そのまま残すか、空白にするか)と、壊れた %(例: %E6%97 や 100%)への反応が違います。

JavaScript のエンコード関数

JavaScript には目的の違う関数が複数あります。

関数そのまま残す文字用途
encodeURIComponent英数字と - _ . ! ~ * ' ( )値1つ(クエリの値、パスの1セグメント)
encodeURI上に加えて ; / ? : @ & = + $ , #すでに組み立てた URL 全体
URLSearchParams英数字と * - . _。空白は +フォーム形式のクエリ
escape(非推奨)英数字と @ * _ + - . /。非 ASCII は %uXXXX使わない

(出典: MDN の encodeURIComponent・encodeURI・escape、WHATWG URL Standard)

encodeURI は値に使わない

encodeURI は「URL 全体を受け取り、区切り文字を壊さずに日本語や空白だけを変換する」関数です。そのため & = / ? # を残します。値に使うと、データとしての & が区切りのまま残ってしまいます。

値には encodeURIComponent を使う
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 が紹介している次の関数を使います。

RFC 3986 に合わせる(MDN の encodeRFC3986URIComponent)
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 に任せるほうが安全です。

URL オブジェクトで組み立てる
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 encodeURIa%20b&c=d/e?f#g+h~!'()*%E6%97%A5%E6%9C%AC
JS encodeURIComponenta%20b%26c%3Dd%2Fe%3Ff%23g%2Bh~!'()*%E6%97%A5%E6%9C%AC
JS URLSearchParamsq=a+b%26c%3Dd%2Fe%3Ff%23g%2Bh%7E%21%27%28%29*%E6%97%A5%E6%9C%AC
JS encodeRFC3986URIComponenta%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 urlencodea+b%26c%3Dd%2Fe%3Ff%23g%2Bh%7E%21%27%28%29%2A%E6%97%A5%E6%9C%AC
PHP rawurlencodea%20b%26c%3Dd%2Fe%3Ff%23g%2Bh~%21%27%28%29%2A%E6%97%A5%E6%9C%AC
Go url.QueryEscapea+b%26c%3Dd%2Fe%3Ff%23g%2Bh~%21%27%28%29%2A%E6%97%A5%E6%9C%AC
Go url.PathEscapea%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節)。

ホストは Punycode、パス・クエリ・フラグメントはパーセントエンコード
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.pdf

filename* を理解するブラウザはこちらを優先し、理解しない古い実装は 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* が実務の定番トラブル

参考リンク

Base64 の仕組み入門 - 3バイトを4文字にする変換と、base64url・btoa の落とし穴

Base64 の仕組み入門 - 3バイトを4文字にする変換と、base64url・btoa の落とし穴

約27分

バイナリをテキストに載せる符号化 Base64 の仕組みを、ブラウザで動くデモ付きで説明します。3バイトを24ビットにまとめて6ビットずつ4文字に置き換える手順、パディング = の規則、サイズが約4/3になる計算、MIME の76文字改行、RFC 4648 の base64url・base32・base16、data URI・HTTP Basic 認証・JWT での使われ方、btoa が日本語で InvalidCharacterError になる理由と ES2026 の Uint8Array.prototype.toBase64、Node.js の Buffer、macOS と GNU の base64 コマンドの違いまでまとめます。

文字コードと UTF-8 / Unicode の基礎 - 文字化けの原因と、length が合わない理由

文字コードと UTF-8 / Unicode の基礎 - 文字化けの原因と、length が合わない理由

約11分

文字コードを実務目線で整理します。Unicode(文字集合・コードポイント)と UTF-8/UTF-16(符号化方式)の違い、UTF-8 の可変長バイト構造、JavaScript の文字列が UTF-16 ベースゆえに String.length が見た目の文字数と合わない理由、結合文字・絵文字・正規化(NFC/NFD)、そして文字化けの典型原因(エンコーディング不一致・MySQL の utf8 と utf8mb4・BOM・meta charset)と対策まで、Unicode 公式・RFC 3629・WHATWG を一次ソースにまとめます。

タイムゾーンと日付の正しい扱い方 - UTC・ISO 8601・夏時間の罠と JavaScript Date

タイムゾーンと日付の正しい扱い方 - UTC・ISO 8601・夏時間の罠と JavaScript Date

約9分

日付とタイムゾーンの扱いを実務目線で整理します。UTC とオフセットとIANAタイムゾーン(Asia/Tokyo)の違い、ISO 8601 / RFC 3339 の推奨フォーマット、Unixエポック、JavaScript の Date のハマりどころ(月が0始まり・日付のみ文字列はUTC解釈・getTimezoneOffsetの符号)、夏時間(DST)で生じる「存在しない時刻」と「重複する時刻」、そして「保存はUTC・表示時に変換・IANA識別子で扱う」などのベストプラクティスを、MDN・RFC 3339・IANA を一次ソースにまとめます。