In Ktor, why does a file upload use call.receiveMultipart() rather than receive<T>()?
answer
- one body, many parts
- a converter builds a single object
- never load a 2 GB upload into memory
- forEachPart, PartData, dispose
basics
~20 sA multipart/form-data body is a stream of independently typed parts, not one document a converter can turn into a single object. Ktor exposes it as MultiPartData, iterated with forEachPart, so large files stream to disk instead of loading into memory.
solid answer
~40 s`ContentNegotiation` converters deserialize *one* body into *one* object. `multipart/form-data` is not that shape: it is a sequence of parts, each with its own headers, name and content — some plain form fields, some binary files — and its whole point is that a file part may be far larger than memory. So Ktor bypasses converters and gives you `call.receiveMultipart()`, returning `MultiPartData`, which you iterate with `forEachPart`. Each `PartData` is matched by type: `PartData.FormItem` carries a text `value`, `PartData.FileItem` carries `originalFileName` and a content provider you copy to a destination. You call `part.dispose()` on every part to release its buffers or temp file. The typed-object path stays available for the metadata: post JSON separately, or read a `FormItem` and deserialize its text yourself.
code
kotlin · 15 linespost("/documents") {
var title = ""
call.receiveMultipart().forEachPart { part ->
when (part) {
is PartData.FormItem -> if (part.name == "title") title = part.value
is PartData.FileItem -> {
val stored = File(uploadDir, newId())
part.provider().copyAndClose(stored.writeChannel())
}
else -> {}
}
part.dispose()
}
call.respond(HttpStatusCode.Created)
}go deeper
Know that file uploads use call.receiveMultipart() and that you loop over parts rather than receiving one object, and that receiveParameters() handles plain form posts.
Explain why converters do not apply: many independently typed parts and a streaming requirement. Name forEachPart, the PartData.FormItem/FileItem split and dispose().
Show the production reflexes — enforce a size cap while copying, never trust the client filename or content type, clean up on failure, and know that parts arrive in order and cannot be rewound.
Own the shape of the upload contract across the platform: direct multipart to the service versus pre-signed uploads to object storage, and what each means for capacity, timeouts and the service's blast radius.
## Why a converter cannot do this job A `ContentConverter` in Ktor has one shape of contract: given the request body and a target type, produce an instance. That assumes the body is a single self-describing document. `multipart/form-data` violates the assumption twice. First, one request carries *several* payloads separated by a boundary, each with its own `Content-Disposition` and `Content-Type` — there is no single target type. Second, the format exists precisely for content too large to materialise: a 2 GB upload must be consumed incrementally, and any API that hands you a fully-parsed object has already lost that property. So Ktor keeps multipart out of `ContentNegotiation` and offers a streaming API instead. ## The API `val multipart = call.receiveMultipart()` returns `MultiPartData`. The idiomatic consumption is `multipart.forEachPart { part -> ... }`, which is a suspending loop over the parts as they arrive. Each element is a `PartData` subtype you match with `when`: - **`PartData.FormItem`** — a plain form field. `part.value` is the text. - **`PartData.FileItem`** — a file. `part.originalFileName` is the client-supplied name, `part.contentType` its declared type, and the content is obtained from the part's provider as a byte channel you copy to your destination. - **`PartData.BinaryItem`** and **`PartData.BinaryChannelItem`** — raw binary parts, rarely used in browser uploads. Every part must be released with `part.dispose()` once you are done with it; the buffers or temp files backing it are not freed otherwise. Doing this inside the `forEachPart` lambda, after the `when`, is the safe habit. There is also `readAllParts()`, which collects everything into a list. It is convenient for small forms and exactly wrong for uploads — it defeats streaming. ## Order matters Parts arrive in the order the client sent them, and you cannot rewind. If your handler needs a field (say a target folder id) *before* it can decide where to write the file, that field must appear before the file part in the request — or you buffer the file to a temp location and move it afterwards. Interviewers like this detail because it is only learned by hitting it. ## Guardrails you must add yourself Streaming does not mean safe. Anything you write to disk from an upload needs: - A **size limit** enforced while copying, not after — otherwise the first hostile client fills the volume. - A **generated server-side filename**. `originalFileName` is attacker-controlled text; using it directly invites path traversal. Sanitise or, better, ignore it and store your own identifier with the original name as metadata. - **Content-type distrust.** The part's declared `Content-Type` is a client claim, not a fact. - **Cleanup on failure.** A cancelled or failed request should not leave half-written files behind. ## Sibling APIs people confuse with it `call.receiveParameters()` reads an `application/x-www-form-urlencoded` body into `Parameters` — the ordinary HTML form post with no files. It also bypasses `ContentNegotiation`. Choosing between the two is simply which encoding the form declares; a form containing `<input type="file">` must be `multipart/form-data`. ## Combining with typed bodies A common design is metadata plus file. Three workable shapes: 1. Two requests: `POST /documents` with a JSON body creates the record, then `PUT /documents/{id}/content` uploads bytes. Cleanest, and each endpoint keeps one body shape. 2. One multipart request where one part is a `FormItem` containing JSON that you deserialize yourself from `part.value`. 3. One multipart request with individual scalar fields as separate `FormItem`s, assembled in the handler. All three are legitimate; the point in an interview is that you know the framework will not assemble a DTO from a multipart body for you. ## The interview point "Because multipart is a stream of parts, not a document" is the answer. The follow-through that separates a middle from a junior answer is naming `forEachPart`, the `PartData` subtypes, `dispose()`, and why `readAllParts()` is a trap for large uploads.
- What goes wrong if you call readAllParts() on an endpoint that accepts large files?It collects every part before your code runs, so a large upload is materialised in memory or temp storage up front and concurrent uploads multiply that. Streaming with `forEachPart` and copying each file part straight to its destination keeps memory flat regardless of file size.
- Why is part.dispose() important, and where do you call it?Each part holds buffers or a temporary file that are not released automatically. Call `dispose()` on every part once you have consumed it — typically as the last statement inside the `forEachPart` lambda, after the `when` — otherwise a busy upload endpoint leaks memory and temp files.
- How would you accept file plus metadata in one endpoint?Either send the metadata as a `PartData.FormItem` holding JSON and deserialize `part.value` yourself, or send each scalar as its own form field and assemble it in the handler. Remember parts stream in order, so put the metadata parts before the file if the handler needs them to decide where to write.
saying these in an interview costs you the question
- Expecting ContentNegotiation to map a multipart body to a data class
- Calling readAllParts() for large file uploads
- Forgetting part.dispose() and leaking temp files
- Trusting originalFileName as a server-side path
- Confusing multipart/form-data with x-www-form-urlencoded