multipart/form-data vs urlencoded: What Your Form Sends
multipart/form-data and x-www-form-urlencoded, byte for byte. We captured what Chrome and curl send for the same form and what the server does with it.
multipart/form-data and application/x-www-form-urlencoded are the two ways a browser can encode a POSTed HTML form. urlencoded, the default, joins every field into one string like name=Jane+Doe&email=jane%40example.com. multipart/form-data sends each field as a separate part with its own headers and the raw bytes of the value. A form with a file input must use multipart. For a text-only form either one works: urlencoded is smaller, and multipart is what JavaScript’s FormData sends. In our test, the same five-field contact form was 128 bytes urlencoded and 576 bytes as multipart.
Get it wrong and nothing tells you. A file upload form with no enctype submits normally, the success page loads, and the attachment field says cv.pdf. There is no file.
Most explanations stop at the definition. We run a form endpoint, which means our job is to accept whatever a browser sends, so for this guide we captured the exact bytes that headless Chrome 155 and curl 8.7 send for the same form in each encoding, measured the size of each, and passed every capture through the Rails stack that FormBackend runs on to see what the server gets.
Table of Contents
- The three enctype values
- What urlencoded sends
- What multipart/form-data sends
- Side by side: the same form, measured
- What the server does with each one
- Files: why urlencoded drops them without an error
- fetch(), FormData and the missing boundary
- The same thing from curl and Postman
- What about JSON?
- text/plain: for debugging only
- Server limits that apply
- Which one to use
The three enctype values
The enctype attribute on a <form> decides how the browser serializes the fields into the request body. The HTML standard allows three values:
| enctype | Body looks like | Use it for |
|---|---|---|
application/x-www-form-urlencoded |
name=Jane+Doe&topics=pricing |
Text-only forms. This is the default. |
multipart/form-data |
One part per field, separated by a boundary | Any form with <input type="file"> |
text/plain |
name=Jane Doe on separate lines |
Debugging only |
If you leave the attribute out, or misspell it, you get urlencoded. The WHATWG HTML standard states that “the attribute’s missing value default and invalid value default are both the application/x-www-form-urlencoded state.” So enctype="multipart/formdata" (no hyphen) silently gives you urlencoded.
Two details that change the result:
enctypeonly applies to POST. On a form withmethod="get", the browser ignoresenctypeand always urlencodes the fields into the query string. If you’re unsure which method you need, we compare them in GET vs POST for HTML forms.- A submit button can override it.
formenctypeon a<button>or<input type="submit">replaces the form’senctypefor submissions started by that button, the same wayformactionoverrides the form’s action attribute.
What urlencoded sends
Here is the form we used for every test in this guide. It has two plain fields, a message with punctuation, an accented letter and an emoji, and two checked checkboxes with the same name:
<form action="/submit" method="post"> <input name="name" value="Jane Doe"> <input name="email" value="jane@example.com"> <textarea name="message">Hi! Ça va? 50% off & free 🙂</textarea> <input type="checkbox" name="topics" value="pricing" checked> <input type="checkbox" name="topics" value="support" checked> </form>
Chrome 155 sent this request:
POST /submit HTTP/1.1 Content-Type: application/x-www-form-urlencoded Content-Length: 128 name=Jane+Doe&email=jane%40example.com&message=Hi%21+%C3%87a+va%3F+50%25+off+%26+free+%F0%9F%99%82&topics=pricing&topics=support
The rules come from the WHATWG URL standard:
- Fields are
name=valuepairs joined with&, in document order. - A space becomes
+. - ASCII letters, digits and
*,-,.,_stay as they are. Every other byte is percent-encoded, so@becomes%40and!becomes%21. - Text is UTF-8 first, then each byte is encoded.
Çis two bytes in UTF-8, so it becomes%C3%87. The emoji is four bytes and becomes 12 characters. - A repeated name, like
topics, appears once per value. - Line breaks in a
<textarea>are normalized to CRLF before encoding, so a newline always arrives as%0D%0A, whatever the user’s operating system.
The encoding of the text itself comes from the page. A form on a UTF-8 page sends UTF-8. To force it, add accept-charset="UTF-8" to the form. If the server needs to know which encoding was used, include a hidden field named _charset_ with no value; the browser fills in the encoding name. fetch() with FormData or URLSearchParams always sends UTF-8.
A lot of tutorials say form values are encoded with encodeURIComponent() or that spaces become %20. Neither is true for a form body. encodeURIComponent() leaves !'()~ alone and uses %20 for a space, so a server that decodes with the wrong function gets Jane+Doe with a literal plus sign. The URL standard is candid about how this format came to be: it calls urlencoded “in many ways an aberrant monstrosity, the result of many years of implementation accidents and compromises…”
What multipart/form-data sends
Same form, with enctype="multipart/form-data":
POST /submit HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryW8kNAIbFv7l3iMLL Content-Length: 576 ------WebKitFormBoundaryW8kNAIbFv7l3iMLL Content-Disposition: form-data; name="name" Jane Doe ------WebKitFormBoundaryW8kNAIbFv7l3iMLL Content-Disposition: form-data; name="email" jane@example.com ------WebKitFormBoundaryW8kNAIbFv7l3iMLL Content-Disposition: form-data; name="message" Hi! Ça va? 50% off & free 🙂 ------WebKitFormBoundaryW8kNAIbFv7l3iMLL Content-Disposition: form-data; name="topics" pricing ------WebKitFormBoundaryW8kNAIbFv7l3iMLL Content-Disposition: form-data; name="topics" support ------WebKitFormBoundaryW8kNAIbFv7l3iMLL--
Every line ends in CRLF (\r\n). Each field gets its own part, and each part has:
- A delimiter line: two hyphens, then the boundary.
- A
Content-Disposition: form-data; name="..."header. RFC 7578 makes this header and itsnameparameter mandatory. - A blank line.
- The value as raw bytes.
Çand the emoji are plain UTF-8, not percent-encoded.
Text parts carry no Content-Type header. The HTML standard says “non-file fields must not have a Content-Type header specified.” File parts do, and they add a filename:
------WebKitFormBoundaryNW9m2Te8bSn5anJ2
Content-Disposition: form-data; name="resume"; filename="cv.pdf"
Content-Type: application/pdf
%PDF-1.4 ...file bytes...
------WebKitFormBoundaryNW9m2Te8bSn5anJ2--
Field names and file names are put in quotes, so the browser has to escape the three characters that would break the header. The HTML standard says to replace line feed, carriage return and " with %0A, %0D and %22, and “must not perform any other escapes.” A file named my "cv".pdf arrives as filename="my %22cv%22.pdf".
There is no base64 and no Content-Transfer-Encoding. RFC 7578 says senders “SHOULD NOT generate any parts with a Content-Transfer-Encoding header field.” The file’s bytes go into the body unchanged, and that is the reason multipart can carry binary data efficiently.
The boundary
The boundary is a string the sender picks so that it does not appear anywhere in the content. It is declared once in the Content-Type header. Each part starts with -- plus the boundary, and the last delimiter adds -- at the end as well. RFC 2046 limits it to 70 characters.
You don’t choose it. Each client generates its own:
| Client | Boundary format | Example from our captures |
|---|---|---|
| Chrome, Safari | ----WebKitFormBoundary + 16 random characters |
----WebKitFormBoundaryW8kNAIbFv7l3iMLL |
| Firefox 135 and later | ----geckoformboundary + random hex |
(not captured) |
curl -F |
24 hyphens + 22 random characters | ------------------------3t6cAHj46OiuUbhc1KWxV5 |
Most guides predate Firefox’s current format. Before version 135 (released February 2025), Firefox used a row of hyphens followed by decimal digits. Mozilla bug 1931331 describes the cause: AWS WAF’s SQL injection rule blocked some multipart requests from Firefox because the boundary was only numbers. Mozilla changed the browser rather than wait for every firewall to change.
The practical lesson: never parse multipart with a regex that expects a specific boundary. Read it from the header.
Side by side: the same form, measured
We sent the test form through every path a browser offers and recorded the body size. The capture server was a raw socket in Python, so nothing was rewritten between the browser and the log.
| Path | Content-Type sent | Body size |
|---|---|---|
| Native form, default enctype | application/x-www-form-urlencoded |
128 bytes |
Native form, enctype="multipart/form-data" |
multipart/form-data; boundary=----WebKit… |
576 bytes |
Native form, enctype="text/plain" |
text/plain |
112 bytes |
fetch(url, { body: new FormData(form) }) |
multipart/form-data; boundary=----WebKit… |
576 bytes |
fetch(url, { body: new URLSearchParams(new FormData(form)) }) |
application/x-www-form-urlencoded;charset=UTF-8 |
128 bytes |
fetch() with JSON.stringify(Object.fromEntries(...)) |
application/json |
109 bytes |
Captured 2026-10-19 with headless Chrome 155 (155.0.8059.85) on macOS.
For a typical contact form, a multipart body is about 4.5 times the size of the urlencoded body: 576 bytes against 128 in our capture. Almost all of that is fixed cost: each part repeats a 40-character boundary line and a Content-Disposition header, so every field adds about 90 bytes before its value. For five short fields that overhead is most of the request.
It is still under 1 KB. Nobody’s contact form is slow because of this. The size difference matters for high-volume API traffic and for very large text payloads, not for a form a person fills in by hand.
When multipart gets smaller
The overhead of multipart is per field. The overhead of urlencoded is per byte. So for a long enough value, multipart wins. Where that happens depends on the language of the text.
We calculated where the multipart body becomes smaller than the urlencoded one for a single message field, using a small script that applies the same encoding rules. It reproduces the captured sizes above to the byte (128 and 576), and we grew the message one character at a time:
| Text in the message field | urlencoded size vs raw UTF-8 | multipart is smaller from |
|---|---|---|
| English sentence with punctuation | 1.12× | 1,094 characters |
| Danish (æ, ø, å) | 1.29× | 438 characters |
| Japanese | 3.0× | 22 characters |
| Emoji | 3.0× | 17 emoji |
Characters are Unicode code points. JavaScript’s .length counts each emoji as two.
Every byte outside ASCII becomes three characters in urlencoded form. Most Japanese characters are three bytes in UTF-8, so each one costs nine bytes on the wire. If your users write in a non-Latin script, a multipart body is often the smaller one even for short messages.
What the server does with each one
Size is the minor difference. The major difference is whether your server can read the body at all. We passed each capture through Rack 2.2 and ActionDispatch 8.1, the parsing layer under the FormBackend endpoint and under any Rails app on the same versions:
| Capture | What params contained |
|---|---|
| urlencoded | name, email, message, topics: "support" |
| multipart | name, email, message, topics: "support" |
| multipart with a file | the same, plus resume as an uploaded file (filename, type, 14 bytes) |
| text/plain | {}, nothing |
| JSON | name, email, message, topics: "support" (the browser had already dropped pricing, see JSON) |
| multipart header with no boundary | 400 Bad Request |
Three results are worth knowing about:
- urlencoded, multipart and JSON all decode to the same params. The
Content-Typeheader tells the server which parser to run, and after that your code sees identical values. The choice is about transport, not about what the data means. topicscame through with one value, not two. Both encodings sentpricingandsupport, and Rails kept the last one. That is not an encoding problem: Rails, PHP and several other frameworks need the field namedtopics[]to collect repeated values into an array. We cover that rule, with the other reasons a field disappears, in what each HTML input type sends to your server.- text/plain gave an empty hash. Rails returns
{}, and most body parsers ignore the type by default. That is by design (see below).
Files: why urlencoded drops them without an error
This is the failure from the opening of this guide. When the form has a file input and the default encoding, the HTML standard’s rule is short: “If entry’s value is a File object, then let value be entry’s value’s name.”
The browser sends the file name as the value. Our capture with a PDF attached:
name=Jane+Doe&email=jane%40example.com&...&resume=cv.pdf
No error, no warning in the console, and the form submits normally. The fix is two attributes on the form:
<form action="https://www.formbackend.com/f/YOUR_TOKEN" method="post" enctype="multipart/form-data"> <input type="file" name="resume"> <button type="submit">Send</button> </form>
Both are necessary. With method="get" the enctype is ignored, and you are back to a file name in the query string. The file uploads documentation has the FormBackend-specific settings, such as multiple files and accepted file types, and handling file uploads via your form walks through the complete setup.
fetch(), FormData and the missing boundary
When you submit with JavaScript, the body type you pass to fetch() selects the encoding. You don’t set the header. The Fetch standard sets it from the body:
// multipart/form-data; boundary=... (set by the browser) fetch(url, { method: "POST", body: new FormData(form) }); // application/x-www-form-urlencoded;charset=UTF-8 (set by the browser) fetch(url, { method: "POST", body: new URLSearchParams(new FormData(form)) });
A common mistake looks like this:
// Don't do this fetch(url, { method: "POST", headers: { "Content-Type": "multipart/form-data" }, body: new FormData(form), });
Setting the header by hand looks careful, but it breaks the request. In our capture, Chrome sent the header exactly as written, Content-Type: multipart/form-data, with no boundary parameter. The body still used a boundary, but the server had no way to know what it was. Rails answered with 400 Bad Request. In Node, multer and other parsers built on busboy reject it with Multipart: Boundary not found. MDN warns about it directly: setting the header “will prevent the browser from being able to set the Content-Type header with the boundary expression it will use to delimit form fields in the request body.”
Remove the header and it works. Our AJAX documentation has a working fetch() example for a FormBackend form. If you also call preventDefault() to send the form yourself, there are a few more things the browser stops doing for you; what preventDefault() does to your form submission lists them.
The same thing from curl and Postman
curl picks the encoding from the flag. -d (and --data-urlencode) sends urlencoded, -F sends multipart:
curl -F name="Jane Doe" -F email=jane@example.com -F resume=@cv.pdf https://example.com/submit
curl 8.7.1 sent this body, with its own boundary format:
Content-Type: multipart/form-data; boundary=------------------------3t6cAHj46OiuUbhc1KWxV5
--------------------------3t6cAHj46OiuUbhc1KWxV5
Content-Disposition: form-data; name="name"
Jane Doe
--------------------------3t6cAHj46OiuUbhc1KWxV5
Content-Disposition: form-data; name="resume"; filename="cv.pdf"
Content-Type: application/pdf
%PDF-1.4 ...file bytes...
--------------------------3t6cAHj46OiuUbhc1KWxV5--
(The email part is left out here; it looks the same as name.) With --data-urlencode "name=Jane Doe" the same request goes out as name=Jane+Doe with Content-Type: application/x-www-form-urlencoded. Postman has the same choice as two body tabs, form-data and x-www-form-urlencoded, and like the browser it writes the boundary for you as long as you don’t add the header yourself.
What about JSON?
HTML forms can’t send JSON. There is no enctype="application/json". A W3C proposal for one, HTML JSON form submission, was published as a Note in 2015 and is no longer maintained.
So JSON always means JavaScript, and it has two costs compared with the form encodings:
- A CORS preflight. urlencoded, multipart and text/plain are the three CORS-safelisted content types in the Fetch standard. A cross-origin POST with any of them goes out directly. A POST with
Content-Type: application/jsonfirst sends anOPTIONSrequest, and the endpoint must answer it correctly before the real request leaves the browser. - You serialize the fields yourself, and it is easy to lose data.
Object.fromEntries(new FormData(form))is the usual one-liner. In our capture it sent"topics":"support": one of the two checked boxes was gone before the request left the browser. Files can’t go into JSON at all without base64, which makes them about a third larger.
JSON is the right choice for an API that receives nested data from your own code. For a form that a person fills in, FormData is shorter, keeps every value and every file, and doesn’t need a preflight. Our guide to submitting HTML forms with JavaScript compares the two in more detail.
text/plain: for debugging only
The third enctype sends one name=value per line with no encoding at all:
name=Jane Doe
email=jane@example.com
message=Hi! Ça va? 50% off & free 🙂
topics=pricing
topics=support
It is easy to read, and that is all it is good for. The HTML standard is direct: text/plain payloads “are not reliably interpretable by computer, as the format is ambiguous (for example, there is no way to distinguish a literal newline in a value from the newline at the end of the value).” A message that contains a line like email=someone@else.com can’t be told apart from a real field.
Use it to look at what a form sends while you build it. Don’t send it to a server, and don’t write a server that accepts it.
Server limits that apply
Encoding also decides which server limits apply. These defaults can make a valid form look like it sent nothing:
| Server | Default | What happens when you exceed it |
|---|---|---|
| PHP | post_max_size 8M, upload_max_filesize 2M, max_file_uploads 20, max_input_vars 1000 |
Above post_max_size, $_POST and $_FILES are empty and PHP only logs a warning. A file above upload_max_filesize is dropped with error code UPLOAD_ERR_INI_SIZE; the text fields still arrive. |
| Rack (Rails, Sinatra) | 128 file parts per request, 4,096 parts total | The parser raises an error and the request fails. Change with RACK_MULTIPART_FILE_LIMIT and RACK_MULTIPART_TOTAL_PART_LIMIT. |
Express body-parser |
urlencoded body limit 100 KB, 1,000 parameters | 413 for too large. Multipart is not parsed at all; you need multer, busboy or formidable. |
Sources: php.net core ini settings, Rack utils.rb, Express body-parser.
The PHP post_max_size case is the hardest to diagnose. Your code gets no exception, only a request in which every field is empty, which looks exactly like a form that submitted nothing.
Which one to use
| Your form | Use |
|---|---|
| Text fields only, submitted natively | Leave enctype off (urlencoded) |
| Has a file input | method="post" + enctype="multipart/form-data" |
Submitted with fetch() |
body: new FormData(form), no Content-Type header |
| Long free text in a non-Latin script | multipart is often smaller; either one works |
| Your own API with nested data | JSON from JavaScript |
| Anything that a server must read | Never text/plain |
For a text-only form there is no wrong answer between the two. urlencoded is the default and the smaller body. multipart handles every field type, costs a few hundred bytes extra on a short form, and it is what new FormData(form) sends anyway. Add a file input later and only multipart keeps working.
On the receiving side, a form endpoint has to accept all of it, because it cannot choose what the browser sends. The FormBackend endpoint parses urlencoded, multipart (with files) and JSON into the same submission, and answers the CORS preflight that JSON requires. If you’d rather not write and maintain that parser yourself, point your form’s action at a FormBackend endpoint and you can see each submission’s fields, values and attachments as they arrived.
Frequently asked questions
What does enctype="multipart/form-data" mean?
It tells the browser to send the form body as a series of parts, one per field, separated by a random boundary string. Each part has its own Content-Disposition header with the field name, and file fields also carry a filename and a Content-Type. Values are sent as raw bytes rather than percent-encoded, which is why this is the only form encoding that can carry file contents.
What is the difference between multipart/form-data and application/x-www-form-urlencoded?
application/x-www-form-urlencoded sends every field as one string of name=value pairs joined with &, with spaces turned into + and other reserved bytes percent-encoded. multipart/form-data sends each field as a separate part with its own headers and the raw bytes of the value. urlencoded is smaller for short text forms. multipart is required for files and is smaller for long non-ASCII text. In our test, a five-field contact form was 128 bytes urlencoded and 576 bytes as multipart.
Is multipart/form-data required for file uploads?
Yes. Under the default urlencoded encoding the HTML standard sends only the file's name in place of the file. The browser shows no error, the form submits, and the server receives a field like resume=cv.pdf with no file attached. Use method="post" and enctype="multipart/form-data" on any form with an input of type file.
What is the default enctype of an HTML form?
application/x-www-form-urlencoded. The HTML standard makes it both the missing value default and the invalid value default, so a form with no enctype attribute, or with a misspelled one, sends urlencoded data. On a form with method="get" the enctype is ignored and the fields are always urlencoded into the query string.
Should I set the Content-Type header when sending FormData with fetch?
No. When the body is a FormData object, the browser writes the header itself, including the boundary parameter that the server needs to split the body into parts. If you set Content-Type: multipart/form-data by hand, the header goes out without a boundary while the body still uses one. Rails answers that request with 400 Bad Request, and Node parsers built on busboy, such as multer, fail with Multipart: Boundary not found.
Can an HTML form send JSON?
Not without JavaScript. The only enctype values are application/x-www-form-urlencoded, multipart/form-data and text/plain. A W3C proposal for a JSON enctype was published as a Note in 2015 and is no longer maintained. To send JSON you serialize the fields yourself and post them with fetch(), which also causes a CORS preflight request on cross-origin endpoints.
What is the boundary in multipart/form-data?
A string the sender picks that does not occur anywhere in the body, used to mark where each part starts and ends. It is declared in the Content-Type header, each part begins with two hyphens plus the boundary, and the last one also ends with two hyphens. RFC 2046 limits it to 70 characters. Chrome and Safari use ----WebKitFormBoundary followed by 16 random characters.
Keep reading
What preventDefault() Actually Does to Your Form Submission
preventDefault() cancels the browser's default action. On a form that means no reload and no request. Here's what reaches your server after you call it.
HTML Form Input Types: What Each One Sends to Your Server
All 22 HTML form input types, what each one actually sends to your server, and why fields go missing: unchecked boxes, disabled inputs, and enctype.
The HTML Form Action Attribute: A Complete Guide
What the HTML form action attribute does, what to put in it when you have no server, how formaction works, and a checklist for when your form action won't submit.
Add a form backend to your site in minutes
Connect any HTML form to FormBackend and start collecting submissions — no backend code required.
Start free