Updated on
MultipartFormDataContent is the HttpContent subclass that builds a multipart/form-data request body in .NET. We add one part per field or file, and it handles the boundary string and the per-part headers for us.
Every part is itself an HttpContent. A text field is a StringContent, a file read from disk is a StreamContent, bytes already in memory are a ByteArrayContent, and each one goes into the same Add() call before we post the whole thing with HttpClient.
Let’s start.
What Is Multipart Form-Data?
multipart/form-data is the encoding an HTML form declares with enctype="multipart/form-data", and the one we use in code when a request carries binary data.
A form’s enctype attribute picks one of three encodings. text/plain sends unstructured text. application/x-www-form-urlencoded is the default and sends key-value pairs joined by &, with every non-alphanumeric byte percent-encoded. multipart/form-data splits the body into separate parts, and a form must name it explicitly, because a file input does not switch the encoding by itself.
The percent-encoding is why the default does not suit files. Each byte becomes a percent sign and two hex digits, so a binary payload arrives roughly three times its original size.
A multipart/form-data body avoids that. It is a sequence of parts separated by a boundary string, and because the parts are delimited rather than escaped, each carries its bytes untouched.
Each part gets its own headers, so one request can mix a plain text field and a JPEG without either interfering.
The text/plain encoding just carries unstructured information and has uses beyond HTML forms.
The application/x-www-form-urlencoded is the default encoding for HTML forms. The body of application/x-www-form-urlencoded requests will be composed of a series of key-value pairs where an equal sign = separates every key from its value while an ampersand & separates each pair:
firstName=John&lastName=Doe
Moreover, any non-alphanumeric data in this request body will be URL encoded. Meaning that each byte is represented by a percent sign followed by two hexadecimal digits %HH. While it is possible to send binary data and files using this encoding, using three bytes to represent a single byte of the original data would be highly inefficient.
On the other hand, multipart/form-data is the encoding we select with enctype when a form carries a file upload field.
In a multipart/form-data request, the body is made of a series of payloads called “parts” separated by a specific boundary value. Each part can have its request headers and can declare its name through the Content-Disposition header. The payload itself doesn’t need to be URL encoded so it can contain raw binary data.
What Does a Multipart Request Look Like on the Wire?
A multipart request is defined by the multipart/form-data content type. The multipart content type requires a boundary directive containing a string of ASCII characters. This string serves as a delimiter of the different parts in the request body.
Optionally, each part can have a different Content-Type header allowing for the inclusion of different kinds of data, in this case, text, and images:
POST http://localhost:5272/upload-file HTTP/1.1 User-Agent: Mozilla/5.0 Accept: */* Host: localhost:5272 Accept-Encoding: gzip, deflate, br Connection: keep-alive Content-Type: multipart/form-data; boundary=--------------------------688335339700918511956765 Content-Length: 22651 ----------------------------688335339700918511956765 Content-Disposition: form-data; name="name" Content-Type: text/plain; charset=utf-8 John Doe ----------------------------688335339700918511956765 Content-Disposition: form-data; name="position" Content-Type: text/plain; charset=utf-8 Regional Manager ----------------------------688335339700918511956765 Content-Disposition: form-data; name="profile_picture"; filename="john_doe.jpg" Content-Type: image/jpeg <binary JPEG data omitted> ----------------------------688335339700918511956765--
This dump is a browser form post; a request built by MultipartFormDataContent looks the same except that .NET writes the boundary in quotes, which the next section explains.
In the context of a multipart request, a Content-Disposition header with the value form-data is required for each part along with a name directive identifying the request part. Optionally, we can add a filename directive.
How Do We Send Multipart Form-Data With HttpClient?
Sending a multipart request takes three steps, and they are the same three whether the body carries text, a file, or both.
Create a MultipartFormDataContent. The parameterless constructor is the normal choice, because it generates a valid boundary string for us.
Add one HttpContent per part, giving each a name. multipartContent.Add(new StringContent("John", Encoding.UTF8, MediaTypeNames.Text.Plain), "first_name") adds a text field called first_name.
Post it like any other content: await _httpClient.PostAsync(url, multipartContent). Nothing about the call site changes because MultipartFormDataContent is an HttpContent like any other.
Disposal is the part worth knowing. Disposing the MultipartFormDataContent disposes every part added to it, and a StreamContent disposes the stream it wraps, so a using on the outer content is enough to close a file we opened for a part.
The HttpClient itself is the exception to that. It is meant to be long-lived and shared, not created per request.
That last point is the reason for creating and reusing an HttpClient with IHttpClientFactory instead of newing one up for every call.
The URLs below point at the sample project’s test server, which exposes an upload-form and an upload-image endpoint; the linked repository runs it in memory.
Let’s send two text fields:
using MultipartFormDataContent multipartContent = new();
multipartContent.Add(new StringContent("John", Encoding.UTF8, MediaTypeNames.Text.Plain), "first_name");
multipartContent.Add(new StringContent("Doe", Encoding.UTF8, MediaTypeNames.Text.Plain), "last_name");
using var response = await _httpClient.PostAsync("http://localhost:5272/upload-form", multipartContent);
if (response.IsSuccessStatusCode)
{
// Data uploaded successfully.
}
In the example, we instantiate MultipartFormDataContent through its parameterless constructor. Then, we use its Add() method to include each part of the request.
In this case, we create two new StringContent instances and add them to the MultipartFormDataContent specifying the part name as a second parameter. These will conform to the two parts of our multipart request.
Later, we will use the MultipartFormDataContent instance as the content parameter for a HttpClient.PostAsync() call as we would do with any other type of HttpContent.
Include Files in a Multipart Request
However, the main reason we would use a multipart request is to send a file to a remote server in the body of the HTTP request. To achieve that, let’s use the StreamContent class:
using MultipartFormDataContent multipartContent = new();
var imageContent = new StreamContent(File.OpenRead("john_doe.jpg"));
imageContent.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Image.Jpeg);
multipartContent.Add(imageContent, "profile_picture", "john_doe.jpg");
using var response = await _httpClient.PostAsync("http://localhost:5272/upload-image", multipartContent);
The StreamContent, like StringContent, inherits from HttpContent and will use data coming from an underlying Stream to build a request body. Here, we create a local variable imageContent as a StreamContent instance based on a FileStream reading a jpeg file.
Next, we set the correct content type for the content using the imageContent.Headers.ContentType property. Finally, we include our imageContent in the multipart request by calling the Add() method in our MultipartFormDataContent instance before sending the request.
Add an Array of Bytes to a Multipart Request
Let’s consider a scenario where we store our file data in an array of bytes instead of a stream. In that case, we can use ByteArrayContent and add it to the MultipartFormDataContent in the same way we did with the StreamContent:
var byteArrayContent = new ByteArrayContent(await File.ReadAllBytesAsync("john_doe.jpg"));
byteArrayContent.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Image.Jpeg);
multipartContent.Add(byteArrayContent, "profile_picture", "john_doe.jpg");
This assumes we already hold the bytes. If we are starting from a file on disk and want the array rather than the stream, our guide on reading a file into a byte array covers the ways to get there.
Set the Boundary Delimiter
As discussed before, the boundary is a delimiter string that will mark the beginning and the end of each part in the request.
The boundary must be an ASCII string no longer than 70 characters. This value must not be present in the content of any of the parts.
That ceiling is not a .NET invention. RFC 2046 says the boundary parameter “consists of 1 to 70 characters from a set of characters known to be very robust through mail gateways”, which is the limit the constructor enforces for us.
In most cases, we do not need to set the boundary value explicitly since MultipartFormDataContent will choose a valid one for us. However, if we want to set our own boundary we can do so by passing it as a parameter to the constructor:
MultipartFormDataContent multipartContent = new("My Custom Boundary");
.NET writes the boundary into the Content-Type header wrapped in double quotes: Content-Type: multipart/form-data; boundary="My Custom Boundary". It does this unconditionally. Quoting is legal, and it is required as soon as the boundary contains a space, so MultipartContent quotes every boundary rather than deciding per boundary. Passing one made only of letters, digits and hyphens does not change it.
The quotes are part of the header, not part of the delimiter, and a receiver has to strip them before matching parts. That is what HeaderUtilities.RemoveQuotes() is for: MediaTypeHeaderValue.Parse() hands back the boundary with its quotes still attached, and code that feeds that value straight into a parser is matching against the wrong string. Parsers that skip this step have shipped, including ASP.NET Core’s own MultipartReader in earlier versions, so if a server rejects a request that looks correct, the boundary quoting is the first thing to check.
MultipartFormDataContent also enforces the RFC 2046 length limit itself. A boundary of 70 characters is accepted and 71 throws an ArgumentOutOfRangeException reading “The field cannot be longer than 70 characters”, so this is a constructor-time failure rather than a request the server rejects later.
Which HttpContent Type Should Each Part Use?
Every part of a multipart request is an HttpContent, so the question is only ever which subclass fits the data we have.
Text goes in a StringContent, and the three-argument constructor sets the part’s encoding and media type in one call.
A file we are reading from disk goes in a StreamContent. It streams the file into the request body instead of loading it, which is what makes it the right choice for anything large.
Bytes we already hold in memory go in a ByteArrayContent. Wrapping them in a MemoryStream and using StreamContent works too, and buys nothing.
An object we want serialized goes in a JsonContent, which sets application/json on the part itself.
Two of these set the part’s Content-Type for us and the rest do not, so StreamContent and ByteArrayContent need an explicit Headers.ContentType assignment. Getting it wrong is silent: the request sends, and the server sees the wrong media type.
The mapping is one to one, each Add() call becomes one section of the body.
| The part carries | Use | How its Content-Type gets set |
|---|---|---|
| A text field | StringContent | Third constructor argument, e.g. MediaTypeNames.Text.Plain |
| A file being read from disk | StreamContent | Assign Headers.ContentType after construction |
| Bytes already in memory | ByteArrayContent | Assign Headers.ContentType after construction |
| A serialized object | JsonContent | Set to application/json; charset=utf-8 by JsonContent.Create() |
| A nested set of key-value pairs | FormUrlEncodedContent | Set to application/x-www-form-urlencoded by the class |
| Anything else | any HttpContent subclass | Assign Headers.ContentType after construction |
Pass the part name as the second argument to Add(), and add a third argument when the part is a file, which is what puts the filename and filename* directives in that part’s Content-Disposition header.
StreamContent earns its place on large uploads because the file never lands in memory in one piece, which our article on streaming request and response bodies with HttpClient goes into. When the whole body is JSON rather than one part among several, sending a JSON body with HttpClient is the shorter route.
Conclusion
In this article, we have learned what a multipart request is. We have learned that multipart requests can send files to remote servers. Also, we analyzed what a multipart request looks like internally.
Next, we learned how to send requests containing multiple parts using HttpClient and MultipartFormDataContent. Finally, we learned how we can include files as part of our requests and specify the correct Content-Type for each of them.
The obvious next questions are on the receiving end: how a server reads a large multipart upload, and validating an uploaded file once it arrives.
Tested with .NET 10.

Very insightful article, especially part with sending multipart form data through HttpClient!
Thanks!