application/x-www-form-urlencoded は、キーと値を = と & でつなぎ、空白を +、英数字と *-._ 以外の文字を %XX に置き換えて送る Content-Type です。JavaScript を使わずに HTML フォームを送信したときの既定値でもあります。application/json は数値・真偽値・null・配列・オブジェクトの型を保ったまま送れる形式で、型やネストを保ちたい API 通信に向いています。この記事では両者の書式を仕様に沿って説明し、同じデータを axios 1.21.0・Express 5.3.0・Chrome 156 で送ったときに何が変わるかを実測で比べます。ファイルを送る multipart/form-data との違い、ページの文字コードで送信内容が変わる問題、どちらを選ぶかの判断基準まで扱います。
まとめ:form-urlencoded・JSON・multipartの違いと選び方
form-urlencoded は文字列のキーと値の並びしか表せず、数値も真偽値も文字列で届きます。JSON は型とネストをそのまま運べます。HTML フォームの標準送信でファイルの内容を送るには、multipart/form-data を使います。
| 比較項目 | x-www-form-urlencoded | application/json | multipart/form-data |
|---|---|---|---|
| 本文の例 | name=taro&age=20 |
{"name":"taro","age":20} |
boundaryで区切った部分の列 |
| 値の型 | 文字列のみ | 6つの型 | 文字列とファイル |
| ネスト・配列 | 仕様なし(括弧記法は慣習) | 仕様で定義 | 仕様なし |
| 「あ」1,000字の本文(今回の実測) | 9,002バイト | 3,008バイト | 3,120バイト |
| HTMLフォームから | 既定値 | 送れない | enctypeで指定 |
| CORSのプリフライト | 条件を満たせば不要 | 別オリジンでは必要 | 条件を満たせば不要 |
| 主な用途 | フォーム・OAuthのトークン取得 | REST API・SPA | ファイルアップロード |
選び方は3行で済みます。自分で設計する API は JSON にする。HTML フォームの素の送信と OAuth 2.0 のトークンエンドポイントのように相手が form-urlencoded を指定しているときは form-urlencoded で送る。ファイルを含むなら multipart/form-data を使う。
application/x-www-form-urlencodedの書式とエンコード規則
キーと値を=と&でつなぐ本文の形
form-urlencoded の本文は、URL のクエリ文字列と同じ見た目をしています。POST ではこの文字列がリクエスト本文に入ります。
POST /login HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 50
user=taro&q=a+b%26c&jp=%E3%83%86%E3%82%B9%E3%83%88
書式を定めているのは WHATWG の URL Standard です。キーと値を = で結び、組同士を & で区切ります。同じキーを複数回書くことは許されていて、受信側がそれを配列として扱うかどうかは実装が決めます。
空白は+、記号と日本語はUTF-8の%XX
URL Standard のシリアライザは、文字列をまず文字コード(既定は UTF-8)のバイト列にし、英数字と *-._ 以外のバイトを %XX にし、空白(0x20)だけは + にします。Node.js 26.5.0 の URLSearchParams では次のようになりました。
new URLSearchParams({ q: 'a b&c=d', jp: 'テスト', sym: "*-._~!'()" }).toString()
// q=a+b%26c%3Dd&jp=%E3%83%86%E3%82%B9%E3%83%88&sym=*-._%7E%21%27%28%29
encodeURIComponent('a b&c=d') // a%20b%26c%3Dd
encodeURIComponent("*-._~!'()") // *-._~!'()
落とし穴は2つあります。1つ目は、encodeURIComponent が form-urlencoded のエンコーダではないことです。空白を %20 にし、~!'() をそのまま残します。受信側の多くはどちらも同じ値に戻せますが、本文の文字列を比較するテストや、生の本文でハッシュや署名を計算する処理では別物になります。本文は URLSearchParams で組み立てるのが確実です。
2つ目はデコード側です。new URLSearchParams('q=a+b%2Bc').get('q') は a b+c を返し、+ を空白に戻します。一方 decodeURIComponent('a+b') は a+b のままです。本文を decodeURIComponent で自前分解すると空白が + のまま残ります。値に + そのもの(電話番号の国番号など)を入れるなら、送信側で %2B にします。
GETで送るとURLに載る:長さの目安
フォームの method="get" では、同じ文字列が URL の ? 以降に付きます。Chrome 156 で a b を送ると /form?q=a+b へ移動し、Content-Type ヘッダーは付きませんでした。URL の長さについて RFC 9110 4.1節は「送信者と受信者は最低でも 8000 オクテットの URI をサポートすることを推奨する(RECOMMENDED)」と書いており、上限を定めてはいません。UTF-8 で3バイトになる「あ」などは、パーセントエンコード後に1文字が9バイトになります。検索語程度なら GET、本文や長い入力は POST と分けるのが無難です。GET の URL はアクセスログや閲覧履歴に残るため、パスワードや個人情報は長さに関係なく POST で送ります。
application/jsonの書式とデータ型
JSONが持つ6つの型と数値の精度
application/json は RFC 8259 が定めるテキスト形式で、値は文字列・数値・真偽値(true/false)・null・配列・オブジェクトのいずれかです。配列とオブジェクトは入れ子にできるので、注文と明細、ユーザーと住所のような階層をそのまま表せます。
{
"name": "taro",
"age": 20,
"active": true,
"tags": ["a", "b"],
"addr": { "city": "Tokyo" },
"note": null
}
数値の範囲は RFC 8259 が実装に委ねています。JavaScript の JSON.parse は IEEE 754 の倍精度で読むため、絶対値が 253−1 を超える整数は丸められることがあり、Node.js 26.5.0 では JSON.parse('9007199254740993') が 9007199254740992 を返しました。大きな ID は文字列で送ります。型の詳細と TypeScript での扱いはJSONの型定義とデータ型一覧、コメントを書けない点はJSONのコメントとJSONC・JSON5で解説しています。
charsetパラメータとapplication/*+json
RFC 8259 は、閉じたエコシステムの外で交換する JSON に UTF-8 を要求しており、application/json の登録に charset パラメータは定義されていません。application/json; charset=utf-8 と付けても準拠した受信側には効果がなく、害もありません。
application/ld+json(JSON-LD)や application/problem+json(RFC 9457 のエラー応答)のように +json で終わる型は、中身が JSON であることを示します。受信側が application/json との完全一致で判定していると読み飛ばします。Express の express.json() も既定の type は application/json なので、+json を受けるなら type オプションで追加します。
同じデータを両形式で送った結果の比較
axios 1.21.0 から Express 5.3.0 のサーバーへ、{ name: 'taro', age: 20, flag: true, n: null, tags: ['a','b'], addr: { city: 'Tokyo' } } を、Content-Type だけ変えて送った結果です。
| 値 | JSONで届いた値 | form-urlencodedの本文 | Express 5の既定で届いた値 |
|---|---|---|---|
| age: 20 | 20(数値) | age=20 |
“20”(文字列) |
| flag: true | true(真偽値) | flag=true |
“true”(文字列) |
| n: null | null | 項目ごと消える | キー自体が無い |
| tags: [‘a’,’b’] | [“a”,”b”] | tags%5B%5D=a&tags%5B%5D=b |
“tags[]”: [“a”,”b”] |
| addr.city | {“city”:”Tokyo”} | addr%5Bcity%5D=Tokyo |
“addr[city]”: “Tokyo” |
form-urlencoded 側では3つのことが起きています。数値と真偽値は文字列になり、受信側で変換を書かない限り "20" + 1 が "201" になります。null は axios のシリアライザが項目ごと落とすため、「値を null で消す」更新 API を form-urlencoded で作ると、未送信と null の区別がつきません。配列とネストは [] を使った括弧記法になりますが、これは PHP や qs ライブラリ由来の慣習で、URL Standard に定義はありません。受信側が括弧記法を解釈しなければ "tags[]" という名前のキーとして届きます。表の右列がその状態です。ネストを送る必要が出た時点で JSON に切り替える方が、変換コードを両端に書き続けるより確実です。
multipart/form-dataとの違い:ファイルと送信サイズ
enctypeの3つの値とファイル欄の落とし穴
HTML Standard では、<form> の enctype が取れる値は application/x-www-form-urlencoded・multipart/form-data・text/plain の3つで、省略時も不正な値のときも既定は form-urlencoded です。Chrome 156 で enctype="application/json" と書いたフォームを送ると、本文は q=1、Content-Type は form-urlencoded でした。フォームの入力内容を JSON で送るには、JavaScript でデータを組み立て、fetch や axios、XMLHttpRequest などで送信します。
見落としやすいのはファイル欄です。<input type="file"> を置いたフォームで enctype を指定し忘れると、エラーにはならず、本文は up=4610_up.txt&q=1 とファイル名だけが届きました。サーバーで「ファイルが空」になったら、まず enctype を確かめてください。text/plain は q=a=b&c のように区切り文字をエスケープせずに改行でつなぐだけです。値の中の改行と項目の終わりの改行を区別できないので、値に改行が入ると機械では一意に読み戻せません(HTML Standard も機械で確実に解釈できる形式ではないと明記しています)。
日本語とバイナリで比べた本文サイズ
form-urlencoded は、ASCII英数字と *-._ をそのまま残し、空白を + に、それ以外を1バイトにつき3文字(%E3 など)へ変換するため、日本語やバイナリでは本文が膨らみます。Node.js 26.5.0 で、文字列はキー名 t、ファイルはキー名 f・ファイル名 a.bin として計測した本文のバイト数です。ファイルにはランダム生成した10,000バイトを使いました。form-urlencoded のファイル欄のサイズは Base64 に含まれる記号の数で、multipart のサイズは boundary やファイル名の長さで変わります。
| 送った内容 | form-urlencoded | JSON | multipart/form-data |
|---|---|---|---|
| 英数字600字 | 602 | 608 | – |
| 日本語「あ」1,000字 | 9,002 | 3,008 | 3,120 |
| 10,000バイトのファイル | 14,198(Base64化) | 13,344(Base64化) | 10,178(そのまま) |
英数字600字の例では、form-urlencoded と JSON の差は6バイトです。「あ」1,000字の例では、form-urlencoded が JSON の約3倍になります。この比較では、ファイルを form-urlencoded と JSON に格納するために Base64 を使い、Base64 の時点で 13,336 文字に増え、form-urlencoded ではさらに + / = が %2B などに化けて 14,198 バイトになりました。multipart は境界文字列と各部のヘッダーぶん(この例で178バイト)が増えるだけで、ファイルの中身はそのまま載ります。multipart の書式は RFC 7578 が定めています。axios なら axios.postForm()、fetch なら FormData を渡すと multipart になります。
ページの文字コードで送信内容が変わる:Shift_JISと_charset_
HTMLフォームの送信文字コードとaccept-charsetによる指定
URLSearchParams や axios は常に UTF-8 でエンコードしますが、HTML フォームは違います。HTML Standard のフォーム送信の手順は、まず「文書の文字コード」を使い、accept-charset 属性があればそれで上書きすると定めています。Chrome 156 で「テスト」を送った結果です。
| ページ | 届いた本文 |
|---|---|
| charset=utf-8 | q=%E3%83%86%E3%82%B9%E3%83%88&_charset_=UTF-8 |
| charset=shift_jis | q=%83e%83X%83g&_charset_=Shift_JIS |
| shift_jis+accept-charset=”utf-8″ | q=%E3%83%86%E3%82%B9%E3%83%88&_charset_=UTF-8 |
Shift_JIS のページから送ると、Content-Type は同じ form-urlencoded のまま中身が Shift_JIS になります。Content-Type に charset は付かないため、受信側が UTF-8 を前提にデコードすると文字化けします。古い Shift_JIS のサイトから新しい UTF-8 の API へ送信先だけ移したときに起きる不具合です。対処は <form accept-charset="UTF-8"> を付けることです(HTML Standard は accept-charset に UTF-8 以外を書くことを認めていません)。
表の _charset_ は、name="_charset_" で value を持たない hidden 欄を置くと、送信時にブラウザが使った文字コード名を入れてくれる仕組みです。HTML Standard と RFC 7578 の4.6節に記述があり、受信側はこの値を見てデコードする文字コードを切り替えられます。
Shift_JISで表せない文字の数値文字参照への置換
Shift_JIS のページから絵文字「😀」を送ると、本文は q=%26%23128512%3B でした。デコードすると 😀 という HTML の数値文字参照で、Shift_JIS に無い文字を URL Standard の手順が「%26%23」+10進数+「%3B」に置き換えた結果です。エラーにならないため、サーバー側で「&#」で始まる文字列が保存されて初めて気づきます。これも UTF-8 で送れば起きません。
送信側の書き方:fetch・axios・curl・Python
fetchのbody型とContent-Typeの自動設定
fetch は、Content-Type を指定しなかったとき、body に渡した値の種類からヘッダーを決めます。Node.js 26.5.0 での結果です。
// URLSearchParams → application/x-www-form-urlencoded;charset=UTF-8
await fetch(url, { method: 'POST', body: new URLSearchParams({ a: '1' }) });
// 文字列 → text/plain;charset=UTF-8(JSON文字列でも application/json にはならない)
await fetch(url, { method: 'POST', body: JSON.stringify({ a: 1 }) });
// FormData → multipart/form-data; boundary=…
// type無しのBlob → Content-Typeヘッダー自体が付かない
// JSONとして送るならヘッダーを明示する
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ a: 1 }),
});
2つ目はよくある不具合の原因です。ヘッダーを付け忘れると text/plain で届き、Express 5 の express.json() は処理を飛ばすので req.body は undefined になりました。サーバーのコードを疑う前に、開発者ツールで実際の Content-Type を確かめます。通信をそのまま再現する手順はCopy as cURLの使い方にまとめています。
axiosのオブジェクト自動変換と送信形式の切り替え
axios 1.21.0 は、プレーンなオブジェクトを JSON にして application/json で送ります。URLSearchParams を渡すと application/x-www-form-urlencoded;charset=utf-8、axios.postForm() は multipart/form-data です。
import axios from 'axios';
// JSONで送る(既定)
await axios.post('/api/users', { name: 'taro', age: 20 });
// form-urlencodedで送る(方法1:URLSearchParams)
await axios.post('/login', new URLSearchParams({ user: 'taro', pass: 'secret' }));
// form-urlencodedで送る(方法2:ヘッダー指定でオブジェクトを自動変換)
await axios.post('/login', { user: 'taro', pass: 'secret' }, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
方法2は手軽ですが、前の比較表のとおり配列は tags[]=、ネストは addr[city]= になり、null と undefined は消えます。送り先が括弧記法を読めるか分からないときは、方法1でフラットなキーだけを組み立てる方が結果を予測できます。axios の版の確認とインストールはAxios(アクシオス)とはで扱っています。
curlとPythonのrequestsでの指定
curl 8.7.1 の -d は Content-Type を form-urlencoded にしますが、値をエンコードしません。-d 'q=a b&c' は空白も & も生のまま q=a b&c で届き、受信側では c という別のキーになります。値に空白や & を含むなら --data-urlencode を使います。--json(curl 7.82.0 以降)は Content-Type と Accept の両方を application/json にします。
curl -d 'name=taro' https://example.com/login
curl --data-urlencode 'q=a b&c' https://example.com/search # q=a+b%26c で届く
curl --json '{"name":"taro"}' https://example.com/api/users
Python 3.13 と requests 2.34.2 では、data= に辞書を渡すと form-urlencoded、json= に渡すと application/json になります。data= ではリストが tags=a&tags=b と同じキーの繰り返しになり、値が None のキーは送られません。標準ライブラリの urllib.parse.urlencode でリストを送るときは doseq=True が必要で、付け忘れると tags=%5B%27a%27%2C+%27b%27%5D(Python のリスト表記の文字列)が1つの値として届きます。
import requests
requests.post(url, data={'q': 'a b', 'tags': ['a', 'b'], 'n': None}) # q=a+b&tags=a&tags=b
requests.post(url, json={'q': 'a b', 'n': None}) # {"q": "a b", "n": null}
GUI で試すなら、Postman の Body タブで x-www-form-urlencoded と raw(JSON)を切り替えます(Postmanとは?APIテスト自動化とNewmanでのCI組み込み)。
受信側の読み方と上限:Express・PHP
Express 5のexpress.urlencodedとextended・上限値
Express では express.urlencoded() が form-urlencoded、express.json() が JSON を読みます。どちらも Content-Type が一致しないリクエストは素通りさせ、そのとき req.body は undefined のままです。
const express = require('express');
const app = express();
app.use(express.urlencoded()); // Express 5 の既定は extended: false
app.use(express.json());
app.post('/echo', (req, res) => res.json(req.body));
app.listen(3000, '127.0.0.1');
Express 4 から移行するときに影響するのが extended の既定値で、Express 5(body-parser 2 系)では false です。tags[]=a&tags[]=b&addr[city]=Tokyo は既定では {"tags[]":["a","b"],"addr[city]":"Tokyo"} と括弧がキー名に残り、extended: true にすると qs ライブラリで {"tags":["a","b"],"addr":{"city":"Tokyo"}} に戻りました。移行全体はExpress.jsとは?Express 5.3の変更点と4系からの移行で解説しています。
受け取りには既定の上限があり、超えるとエラー応答になります。body-parser 2.4.0 の README に書かれた既定値どおりに動きました。
| 上限(オプション) | 既定値 | 超えたときの応答 |
|---|---|---|
| 本文サイズ(limit) | 100kb | 413 request entity too large |
| パラメータ数(parameterLimit) | 1000 | 413 too many parameters |
| ネストの深さ(depth・extendedのみ) | 32 | 400 The input exceeded the depth |
1,001個のキーを並べた送信や、33段のネストは実際に弾かれました。多数の行を form-urlencoded で一括送信する画面は、件数が増えた日に突然 413 で失敗します。行の配列を送るなら JSON で受けてスキーマで検証する方が上限も意図も明確になります。400 と 413 の切り分けはHTTP error 400(Bad Request)とはが参考になります。
PHPの$_POSTが埋まる条件と名前の書き換え
PHP の $_POST が埋まるのは、Content-Type が form-urlencoded か multipart/form-data のときだけです。PHP 8.5.8 の組み込みサーバーでは、b[]=x&b[]=y&c[d]=e が配列とネストに展開され、JSON を送ると $_POST は空配列でした。JSON は本文を直接読みます。
<?php
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
exit;
}
フロントエンドを axios に替えた途端に $_POST が空になるのは、axios の既定が JSON だからです。もう1つ、PHP マニュアルのとおり、名前の中のドットと空白は下線に変わります。a.b=1&c d=2 は $_POST['a_b'] と $_POST['c_d'] で届きました(括弧の中の e[f.g] はそのまま)。キー数の上限は max_input_vars(既定1000)で、1,001個送ると「Input variables exceeded 1000」の警告が出ます。display_errors が有効だとこの警告が応答本文の先頭に混ざり、JSON を返す API なら応答が壊れます。
仕様で形式が決まっている場面:CORSとOAuth 2.0
application/jsonのCORSプリフライト条件
ブラウザから別オリジンへ POST するとき、Fetch Standard は Content-Type が form-urlencoded・multipart/form-data・text/plain のいずれかで、ほかのヘッダーもセーフリストの条件に収まり、さらに XMLHttpRequest のアップロードイベント監視や ReadableStream の本文など、プリフライトを要求する条件がなければ、プリフライトなしで本送信します。form-urlencoded でも Authorization ヘッダーを付ければプリフライトの対象です。
application/json はこの3つに入らないため、別オリジンへ送ると、有効なプリフライトのキャッシュがない限り本送信の前に OPTIONS で許可を確認します。サーバーは Access-Control-Allow-Headers で Content-Type を許可する必要があり、Access-Control-Max-Age でその結果のキャッシュ時間を延ばせます。
安全面の意味もあります。form-urlencoded はプリフライトなしで別サイトから送れるので、Cookie 認証の API が form-urlencoded を受け付けると、SameSite 属性や CSRF トークンで塞いでいない限り CSRF の攻撃面になります。「JSON しか受け付けない」ことが結果的に防御になっている API で、互換性のために form-urlencoded も受けるよう変えると、その防御が外れます。プリフライトの仕組みはCORSとは?仕組み・プリフライト・設定例、CSRF の対策の順番はXSSとCSRFの違いとはで解説しています。
OAuth 2.0のトークンエンドポイントはform-urlencoded必須
OAuth 2.0(RFC 6749 4.1.3節)は、クライアントがトークンエンドポイントへ送るパラメータを application/x-www-form-urlencoded 形式・UTF-8 で送ると定めています。認可コードをアクセストークンに交換するリクエストを JSON で送ると仕様の形式から外れ、受け付けられる保証はありません。JSON を受けるかどうかは認可サーバーの実装次第です。
await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://app.example.com/callback',
client_id: 'my-client',
}),
});
上は送信形式を示す例です。クライアント認証が必要なら認可サーバー指定の方法を追加し、PKCE を使う認可要求なら code_verifier も送ります。応答は application/json で返ってきます。送信は form-urlencoded、応答は JSON という非対称は仕様どおりです。
使い分けの判断基準と失敗パターン
新しく API を設計するなら JSON で問題ありません。form-urlencoded を選ぶ理由があるのは、相手や環境が形式を決めている場合に限られます。
- JavaScript を使わない HTML フォームの送信先(既定の enctype)
- OAuth 2.0 のトークンエンドポイントなど、仕様が form-urlencoded を指定している API
- Webhook の送信元や古い決済・SMS ゲートウェイなど、送信形式を変えられない外部サービスからの受信
- 本文を
$_POSTで読む前提の既存 PHP アプリ
form-urlencoded を選ぶべきでない場面もはっきりしています。数値と null の区別が要る更新 API、配列やネストを持つデータ、日本語の長文を大量に送る画面、ファイルを含む送信です。最初の3つは JSON、ファイルは multipart/form-data を使います。「フォームから送るから form-urlencoded」と決め、後から明細行や添付ファイルが増えて括弧記法の変換と上限の引き上げと別エンドポイントが積み上がるのが、典型的な失敗パターンです。
両方を受ける API を作る場合は、Content-Type ごとに受け口を分け、型の変換とバリデーションを1か所に集めます。受け取った本文をオブジェクトにするなら Object.fromEntries(new URLSearchParams(body)) が手早い方法ですが、t=1&t=2 は {"t":"2"} と最後の値しか残らず、値は文字列のままです。複数値は getAll('t') で取り出し、数値は明示的に変換します。社内システムと外部サービスの連携でこうした形式の違いを吸収する設計は、API開発・システム連携の支援でも扱っています。
よくある質問
application/x-www-form-urlencodedとは何ですか?
リクエスト本文を key=value&key2=value2 の形で送る Content-Type です。空白は +、英数字と *-._ 以外の文字は、既定では UTF-8 のバイト列を %XX にして表します。HTML フォームの enctype の既定値です。
x-www-form-urlencodedとJSONの違いは何ですか?
form-urlencoded は文字列のキーと値しか表せず、ネストや配列の仕様もありません。JSON は数値・真偽値・null・配列・オブジェクトを型のまま送れます。ブラウザから別オリジンへ application/json で送るとプリフライトの対象になる点も違います。
application/jsonとは何ですか?
JSON(RFC 8259)で書かれたデータであることを示す Content-Type です。閉じたエコシステムの外で交換する JSON には UTF-8 が要求され、application/json に charset パラメータは定義されていません。
form-dataとx-www-form-urlencodedはどう使い分けますか?
ファイルを含むなら multipart/form-data、文字列だけの短い項目なら form-urlencoded です。form-urlencoded は UTF-8 の「あ」を3バイトから9バイトへ広げるため、今回の「あ」1,000字の計測では 9,002 バイトと multipart(3,120 バイト)の約3倍になりました。form-urlencoded のフォームでファイル欄を送ると、ファイル名だけが届きます。
Content-Typeにapplication/x-www-form-urlencodedを指定するにはどうしますか?
fetch と axios では、本文に URLSearchParams を渡せば自動で設定されます。curl は -d か --data-urlencode、Python の requests は data= に辞書を渡すと設定されます。ヘッダーを手で書く場合は Content-Type: application/x-www-form-urlencoded です。