application/x-www-form-urlencoded vs multipart/form-data: What’s Actually on the Wire
Published on May 25, 2026 by The Kestrel Tools Team • 8 min read
Every HTML form ships data in one of two encodings: application/x-www-form-urlencoded or multipart/form-data. Most developers pick one by muscle memory — enctype="multipart/form-data" when there’s a file input, the default otherwise. But what’s actually happening at the byte level? And why does the choice matter far beyond file uploads?
This post cracks open both formats on the wire, explains the + vs %20 rule that trips up almost everyone, shows how repeated keys parse differently across server frameworks, and gives you a concrete decision framework for when application x-www-form-urlencoded vs multipart form-data actually matters.
If you followed our recent deep dive on encodeURI vs encodeURIComponent vs URLSearchParams, this is the natural next question: once you’ve encoded your values, what container format carries them to the server?
What Is application/x-www-form-urlencoded?
When a browser submits a form with the default encoding (or when you call fetch with a URLSearchParams body), the request body looks like this:
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=j.doe&password=p%40ss%2Bword&remember=on
The rules are deceptively simple:
- Each field becomes a
name=valuepair - Pairs are joined by
& - Spaces become
+(not%20) - All non-alphanumeric characters (except
-,_,.,*) are percent-encoded - The entire body is a single flat string — no nesting, no types, no boundaries
That’s it. The entire payload is one URL-encoded string. Compact, human-readable in dev tools, and trivially parseable.
The + vs %20 Rule That Everyone Gets Wrong
Here’s where it gets subtle. The form-urlencoded spec (WHATWG URL Standard, section 5) says spaces become +. But encodeURIComponent() produces %20 for spaces. These are not interchangeable:
// URLSearchParams follows form-encoding rules
const params = new URLSearchParams({ query: 'hello world' });
params.toString(); // "query=hello+world"
// encodeURIComponent follows RFC 3986
encodeURIComponent('hello world'); // "hello%20world"
Why does this matter? Because if your server expects form-urlencoded data and you manually build the body with encodeURIComponent, literal + characters in user input will decode incorrectly. A + in form-urlencoded means space — so C++ becomes C (two spaces) unless the + itself was encoded as %2B.
The safe rule: Use URLSearchParams for form-urlencoded bodies. It handles the + convention correctly. Use encodeURIComponent only for URI path/query segments where %20 is standard. Try it yourself with our URL Encoder tool to see both conventions side by side.
What Is multipart/form-data?
Multipart encoding wraps each field in its own section, separated by a unique boundary string:
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="title"
My Document
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="report.pdf"
Content-Type: application/pdf
[binary PDF data here]
------WebKitFormBoundary7MA4YWxkTrZu0gW--
Key differences from form-urlencoded:
- Each part has its own headers (including
Content-Typefor files) - Binary data is included raw — no percent-encoding overhead
- The boundary string must not appear in the data itself
- Total payload is larger for simple text fields (boundary + headers per part)
application/x-www-form-urlencoded vs multipart/form-data: Head-to-Head Comparison
| Dimension | form-urlencoded | multipart/form-data |
|---|---|---|
| Content-Type | application/x-www-form-urlencoded | multipart/form-data; boundary=... |
| Space encoding | + | Not encoded (raw in part body) |
| Binary data | Percent-encoded (3x size increase) | Raw bytes |
| File uploads | Not supported | Native support with filename/MIME |
| Overhead for text | Low (~5-15% encoding overhead) | High (boundary + headers per field) |
| Nesting | Flat key-value only | Flat key-value only (nesting is framework-specific) |
| Max practical size | ~1 MB before servers reject | 10+ MB typical (server-configurable) |
| Streaming parse | Must buffer entire body | Can stream part-by-part |
| Human-readable | Yes (URL-decoded in dev tools) | Partially (text parts yes, binary no) |
Repeated Keys: The Framework Divergence Nobody Warns You About
What happens when a form sends the same key multiple times? Think checkboxes, multi-selects, or APIs that accept array parameters:
colors=red&colors=blue&colors=green
The answer depends entirely on your server framework:
| Framework | Parses as | Access pattern |
|---|---|---|
| Express (qs) | { colors: ['red', 'blue', 'green'] } | req.body.colors → array |
| Express (querystring) | { colors: ['red', 'blue', 'green'] } | Same as qs for simple cases |
| Rails | { colors: ['red', 'blue', 'green'] } | Requires colors[] naming convention |
| Django | QueryDict with .getlist() | request.POST.getlist('colors') → list |
| Spring Boot | List<String> if annotated | @RequestParam List<String> colors |
| PHP | $_POST['colors'] = 'green' (last wins!) | Must use colors[] for array |
| Go net/http | r.Form["colors"] → []string | Native slice support |
This is the kind of cross-framework inconsistency that causes production bugs. PHP’s “last value wins” behavior is particularly dangerous — if you’re migrating from a Node backend to PHP (or vice versa), repeated-key forms will silently break.
Both encodings suffer this ambiguity. The HTTP spec says nothing about how repeated keys should be interpreted. It’s entirely up to the server-side parser.
When Does multipart/form-data Win?
The decision is straightforward once you know the concrete thresholds:
1. File Uploads (Obvious)
If your form includes <input type="file">, you must use multipart. Form-urlencoded has no mechanism to carry binary data with a filename and MIME type. The browser enforces this — setting enctype="multipart/form-data" is required for file inputs to work.
2. Binary or Large Payloads
Percent-encoding expands every non-ASCII byte to three characters (%XX). A 1 MB image becomes ~3 MB as form-urlencoded. Multipart sends the same image at its original 1 MB — the boundary overhead is negligible for large payloads.
Crossover point: For payloads under ~1 KB of text, form-urlencoded is more compact (no per-field boundary headers). Above ~10 KB with binary content, multipart wins on size.
3. Mixed Text + Binary in One Request
Multipart handles this naturally — text fields get their own parts, files get theirs. With form-urlencoded, you’d need to Base64-encode binary data, adding 33% overhead on top of percent-encoding.
4. Server-Side Streaming
Multipart can be parsed incrementally — a server can process (or reject) each part as it arrives. Form-urlencoded typically requires buffering the entire body before parsing can begin, since any & might be an encoded %26.
When Does form-urlencoded Win?
Don’t default to multipart for everything. Form-urlencoded is better when:
- All fields are short text — login forms, search queries, settings toggles
- You’re building query strings —
URLSearchParamsproduces valid form-urlencoded bodies and valid query strings with the same API - Debugging matters — the body is immediately readable in browser dev tools, curl output, and server logs
- Payload size is small — no boundary overhead per field
- Caching or deduplication — form-urlencoded bodies are deterministic (same input → same bytes, assuming consistent key ordering)
The Third Option: JSON Bodies
Neither encoding handles nested objects well. If your API needs structured data:
fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Jane',
address: { street: '123 Main', city: 'Portland' },
tags: ['admin', 'editor']
})
});
JSON wins when you have nested objects, typed values (numbers, booleans, null), or arrays of objects. Form-urlencoded flattens everything to strings and requires framework-specific conventions for nesting (user[name]=Jane in Rails, user.name=Jane in Spring).
Practical Decision Flowchart
- Does the request include files or binary data? → multipart/form-data
- Does the payload have nested objects or typed arrays? → application/json
- Is it a simple flat key-value submission? → application/x-www-form-urlencoded
- Is the text payload larger than ~1 MB? → Consider multipart (servers may reject large urlencoded bodies)
- Do you need incremental/streaming server-side processing? → multipart/form-data
Common Mistakes and How to Avoid Them
Mistake 1: Using encodeURIComponent for Form Bodies
// Wrong — produces %20 instead of +
const body = `user=${encodeURIComponent(name)}&pass=${encodeURIComponent(pw)}`;
// Right — handles + convention and special characters
const body = new URLSearchParams({ user: name, pass: pw });
Mistake 2: Assuming Repeated Keys Work Everywhere
Always test your specific server framework. If you need array semantics, consider:
- Using
key[]=valueconvention (works in Rails, PHP) - Switching to JSON body for complex data
- Documenting the expected parse behavior in your API spec
Mistake 3: Sending Files as form-urlencoded
The browser won’t stop you from trying (it’ll just send the filename as a string). The file contents won’t be included. This fails silently — no error, just missing data on the server.
Mistake 4: Using multipart for Simple Forms
A login form with username + password as multipart adds ~200 bytes of boundary overhead for no benefit. Use the default encoding — it’s smaller, faster to parse, and easier to debug.
Testing Both Encodings
Want to see how your values look under each encoding? Our URL Encoder shows you the percent-encoded output with both the form-urlencoded convention (+ for spaces) and the URI component convention (%20 for spaces) — so you can verify your implementation before it hits production.
Summary
The choice between application/x-www-form-urlencoded vs multipart/form-data comes down to what you’re sending:
- Flat text fields, small payloads → form-urlencoded (compact, debuggable, universal)
- Files, binary data, large payloads → multipart (raw bytes, streaming, MIME-typed parts)
- Nested/typed data → JSON (not a form encoding, but often the right answer)
The encoding format is a wire-level decision with real consequences — from the + vs %20 space rule to repeated-key parsing to payload size limits. Understanding what’s actually on the wire saves hours of debugging when things go wrong across framework boundaries.