Form encoding vs percent-encoding

urlencoded and multipart bodies, when a plus sign means a space, how servers parse each content type, and the double-decoding bugs that hide in request handling.

application/x-www-form-urlencoded

A form submission is not the same as a URL. The body of a urlencoded request applies the percent-encoding rules with one historical twist: a space becomes +, not %20.

POST /search HTTP/1.1
Content-Type: application/x-www-form-urlencoded

q=rock+%26+roll&page=2&tag=a%2Fb

  q     = "rock & roll"    (+ means space, %26 is a literal ampersand)
  page  = "2"
  tag   = "a/b"            (%2F is an escaped slash, so it is not a path separator)
  • Every key and value is encoded independently, then joined with &.
  • + means a space in a form body. In a URL path it means a literal plus.
  • There is no way to express a repeated key distinctly from an array; servers guess from the language conventions.
  • The format has no length prefix, so parsing must scan for the delimiters — escape them correctly or the body is ambiguous.

multipart/form-data

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----X8f2a

------X8f2a
Content-Disposition: form-data; name="title"

Quarterly report
------X8f2a
Content-Disposition: form-data; name="file"; filename="data.csv"
Content-Type: text/csv

id,amount
1,19.99
------X8f2a--
Aspecturlencodedmultipart
Binary safeNo — percent-encoding inflates by up to 3xYes — raw bytes per part
OverheadSmallBoundary plus per-part headers
Use forShort text fieldsFile uploads, mixed content
Encoding of text partsPercent-encodingDeclared by each part's charset
Streaming parseEasyPossible, but boundary scanning is stateful

The boundary must not appear in any part's content. A random boundary long enough to be unique makes that practically certain; a fixed boundary like ---- can be forged by a crafted upload.

The decoding bugs that reach production

  • Double decoding — a proxy decodes once, the framework decodes again, and %2520 collapses into a space.
  • Plus sign loss — decoding a path with a form decoder turns legitimate plus signs into spaces.
  • Charset confusion — the body is declared UTF-8 but a legacy client sends Windows-1252, and non-ASCII names become mojibake.
  • Key collision — a parameter named a[b] and one named a may both map to the same parsed field.
from urllib.parse import parse_qs, unquote, quote

body = "q=rock+%26+roll&tag=a%2Fb"
params = parse_qs(body, keep_blank_values=True)
# {'q': ['rock & roll'], 'tag': ['a/b']}   plus and %26 both resolved

unquote("a+b")        # 'a+b'  unquote does NOT treat plus as space
unquote_plus("a+b")   # 'a b'  the form rule
💡
Decode exactly once, as close to the edge as possible, and pass structured data inward. Every extra layer that decodes is a chance to decode twice, and double decoding is the root cause of a large share of injection vulnerabilities.

FAQ

When does plus mean a space?
In application/x-www-form-urlencoded bodies and in query strings parsed with form rules. In a URL path, and under strict RFC 3986 handling of a query, plus is a literal plus.
Why does my file upload arrive as text?
The request was sent as urlencoded instead of multipart, so the file bytes were percent-encoded. Set the content type and let the HTTP client build the multipart body.

HTML entities and escaping in markup Mojibake: diagnosing and fixing broken text

Last refreshed 2026-09-18.