# File uploads in OpenAPI: multipart/form-data, raw binary, and multiple files done right

File upload is the endpoint most teams hand-wave in an OpenAPI document. The request works in the one client they tested, so nobody notices that the spec says `type: string`, omits the multipart encoding, or models three files as a single one. Then generated SDKs send JSON, the mock server cannot accept a body, and a static scan of the code disagrees with what the route actually does.

There are exactly three upload shapes in HTTP. Model the right one and everything downstream works.

## The three shapes

| Shape           | Content-Type                                        | When you use it                             | OpenAPI body                                     |
|-----------------|-----------------------------------------------------|---------------------------------------------|--------------------------------------------------|
| Mixed form      | `multipart/form-data`                               | File plus metadata fields, or several files | `object` schema, file props are `string: binary` |
| Raw single file | `application/octet-stream` (or the real media type) | Only the file, nothing else, often a `PUT`  | `string: binary`                                 |
| URL / base64    | `application/json`                                  | Small files or references stored elsewhere  | Normal JSON property                             |

Sending JSON with a base64 blob is a fourth option in practice, but it is just the JSON shape with a 33 percent size penalty. Prefer one of the first two for anything beyond a tiny icon.

## multipart/form-data, field by field

An avatar upload that also takes an `alt` description and an optional `make_primary` flag is an `object`. Each part of the multipart body is a property. The file part uses `type: string` with `format: binary`; everything else is a normal field.

``` yaml
paths:
  /users/me/avatar:
    post:
      summary: Upload the current user's avatar
      operationId: uploadAvatar
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: PNG or JPEG, up to 5 MB.
                alt:
                  type: string
                  maxLength: 200
                  description: Accessible description. Omit to keep the current one.
                make_primary:
                  type: boolean
                  default: false
            encoding:
              file:
                contentType: image/png, image/jpeg
      responses:
        '201':
          description: Avatar stored
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Avatar'
        '413':
          description: File exceeds 5 MB
        '422':
          description: Unsupported media type or corrupt image
```

Two details matter. `required: [file]` marks the file part itself as mandatory while `alt` stays optional. The `encoding.file.contentType` hint tells codegen and docs that this part is an image, not an opaque blob. Do not set `contentType: multipart/form-data` on the part; that is the wire type of the whole body, not of one part.

## Sending a raw binary body

When the URL already identifies the resource and there is no metadata, `PUT` the bytes directly. The body schema is a single binary string and the media type is the file's real type, or `application/octet-stream` when it is genuinely arbitrary.

``` yaml
paths:
  /objects/{storageKey}:
    put:
      summary: Store or replace one object
      operationId: putObject
      parameters:
        - $ref: '#/components/parameters/StorageKey'
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        '201': { description: Object created }
        '204': { description: Existing object replaced }
```

Raw binary is simpler for clients (no multipart assembly) and cheaper on the wire, but it cannot carry a second field. The moment you need metadata alongside the file, move to multipart.

## Multiple files

An ordered list of files under one field name is an array of binary strings.

``` yaml
paths:
  /expenses/{id}/receipts:
    post:
      summary: Attach one or more receipts
      operationId: attachReceipts
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [receipts]
              properties:
                receipts:
                  type: array
                  minItems: 1
                  maxItems: 10
                  items:
                    type: string
                    format: binary
      responses:
        '201': { description: Receipts stored }
```

The client sends repeated parts, all named `receipts`. That is the multipart convention for an array. Distinct named files with different meanings are different properties instead:

``` yaml
properties:
  front: { type: string, format: binary }
  back:  { type: string, format: binary }
```

Use an array for "zero to N of the same thing" and named properties for "this exact file and that exact file."

## What to put in the spec, and what not to

OpenAPI describes the contract, not every server setting. Be precise about what is expressible.

| Concern                  | How to express it                                                      |
|--------------------------|------------------------------------------------------------------------|
| File required            | `required` on the property                                             |
| Count of files           | `minItems` / `maxItems` on the array                                   |
| Accepted media types     | `encoding.<field>.contentType`, plus a `415`/`422` response            |
| Text field encoding      | `encoding.<field>.contentType: text/plain` (defaults to `text/plain`)  |
| JSON part                | `encoding.<field>.contentType: application/json` with an object schema |
| Max size                 | Not enforceable in JSON Schema; document it and add a `413` response   |
| File extension allowlist | Not a schema concern; document and validate server-side                |

Do not invent `maxFileSize` keywords. Validators ignore unknown annotations, so they give false confidence. A documented limit paired with a `413 Payload Too Large` response is the honest contract.

## What codegen produces

With the shapes above, generators emit code a developer can use directly. A TypeScript generator turns the multipart example into a `FormData` body:

``` ts
const form = new FormData();
form.append("file", fileBlob, "avatar.png");
form.append("alt", "Profile photo");
form.append("make_primary", "true");
await sdk.uploadAvatar(form);
```

The raw `PUT` generates a method that takes a `Blob` or `Buffer`. When the spec instead says `type: string` with no `format: binary`, generators emit a method expecting a JSON string, and every caller has to fix the SDK by hand.

## What static scanners get wrong

When you reverse-engineer OpenAPI from code, upload routes are where naive parsers fail. Express with Multer distinguishes `single("file")`, `array("receipts", 10)`, and `fields([{ name: "front" }, { name: "back" }])`; ASP.NET uses `[FromForm]` alongside `IFormFile` and `List<IFormFile>`; Go and Gin bind multipart through distinct tags; Django REST uses `FileField` and `ListSerializer`. A parser that only reads the handler signature usually types the body as `string` or marks it unknown.

A deterministic scanner should trace the actual multipart middleware to recover the field names, single-versus-array shape, and required parts, and report a gap only when the file handling is built dynamically. It should never flatten every upload to `application/octet-stream`, and it should never guess a 5 MB limit the code does not enforce.

## Checklist before you ship the endpoint

1.  File parts are `type: string, format: binary`, never bare `string` or `object`.
2.  Mixed metadata uses `multipart/form-data` with an `object` schema; file-only uploads use raw binary.
3.  Repeated same-kind files are an `array` of binary; distinct files are named properties.
4.  `encoding` declares image or JSON parts; accepted types are paired with `415`/`422`.
5.  Size and extension limits are documented with a `413`, not hidden in fake keywords.
6.  Generate the client once and confirm it builds a `FormData` body or sends a `Blob`.

Get these six right and the same spec drives correct docs, a mock that accepts a real upload, a working SDK, and an honest code scan.

You can model each of these shapes, generate a client, and spin up a mock that accepts a real multipart body in one local-first workspace, [right in your browser](https://www.powerduck.com/app/?ref=powerduck.com). If you are recovering upload contracts from an existing codebase, see how deterministic framework tracing handles multipart middleware in the [Express and NestJS scan overview](https://www.powerduck.com/blog/generate-openapi-from-express-nestjs-code/).

