Form Data and File Uploads

What a browser form actually sends, and why it is neither JSON nor a query string.

Overview

Three body formats

A browser form does not send JSON. When a user submits an HTML form, the browser packs the fields into a different format, and when the form includes a file, it uses yet another. FastAPI can read both, but you have to say so, because the default assumption is that a body is JSON.

This matters whenever you build a login page, a contact form, or anything that lets users upload a picture or a document. It also matters when you call your own API from tools that send forms. Getting the format wrong produces confusing errors that look like validation failures but are really a mismatch of expectations.

This article starts with the three body formats you will meet: JSON, URL-encoded forms, and multipart forms. It then shows the Form function, which tells FastAPI to read a field from a form body. A rule follows that trips many people up: one request has one body and one content type, so you cannot mix JSON and form fields in the same call.

Next comes the small extra package that FastAPI needs before it can parse forms at all, and the error you see if it is missing. The last section covers UploadFile, the class that represents an uploaded file. It handles large files sensibly by spooling them to disk instead of loading everything into memory.

After reading, you will know which format a request uses and how to declare it correctly.

An HTTP request has one body. For an API there are three ways it's often coded.

JSON (use/json) is what a JavaScript client or another service sends. A Pydantic model param reads it.

URL-coded form (use/x-www-form-urlencoded) is what a plain HTML <form> posts. It looks like a query string in the body: username=ada&password=hunter2.

Multipart (multipart/form-data) is what a form with a file input sends. It carries a few named parts, each with its own headers. This Is how a file and its metadata travel together.

FastAPI needs to be told which. A model param means JSON. Form() means one of the form encodings. UploadFile means a file part in a multipart body.

Worth knowing

Form fields need Form(). Without it a plain parameter is read as a query parameter and a model as JSON.
Form data and a JSON body cannot share one request — there is one body and one content type.
Form and file endpoints need the python-multipart package installed, and FastAPI raises at startup without it.
UploadFile gives a filename, a declared content type and a file-like object. Large files are spooled to disk rather than held in memory.
Neither filename nor content_type is trustworthy — both come from the client. Never build a path from a filename.
Nothing limits upload size by default. Cap it in the handler, and again in whatever sits in front of the app.

Form Data and File Uploads

What a browser form really sends. Why it's neither JSON nor a query string.

Form() reads a form field

A browser form posts application/x-www-form-urlencoded, which is not JSON. Form() says so explicitly.

example_01.pyFastAPI
Output

Form fields validate like anything else

The same constraints, the same 422 — with body as the source, because a form is a body.

example_02.pyFastAPI
Output

Form and JSON cannot share a request

One body, one content type. Declaring both a model and a Form field is a contradiction, and the error is confusing if you do not expect it.

example_03.pyFastAPI
Output

Uploads arrive as UploadFile

A file part gives you a filename, a content type and a file-like object — streamed to disk rather than held in memory.

example_04.pyFastAPI
Output

Files and fields together

multipart/form-data carries both, which is what a real upload form sends.

example_05.pyFastAPI
Output

Validating an upload is your job

The framework gives you a filename and a declared content type. Neither is trustworthy, and nothing checks the size.

example_06.pyFastAPI
Output

Form()

def login(username: str = Form(), password: str = Form()):

Without Form(), a plain username: str param would be read as a query param. The request would fail with a confusing 422 about a missing query field while the value sits in the body.

That is the one thing to note here. The marker isn't decoration. It's what tells FastAPI where to look.

all else behaves as it does elsewhere: constraints, defaults, needed-ness and the 422 all work the same way, with loc[0] == "body" because a form *is* the body.

One body, one content type

A request can't be both JSON and a form. Declaring a model param alongside a Form() param is a clash. The resulting error doesn't always say so clearly.

Pick the encoding your client really sends. If it's a browser form, use Form(). If it's JavaScript, it's almost certainly sending JSON. A model is simpler and better documented.

A common middle case: an HTML form that you'd rather receive as a model. The clean answer is to have the front end send JSON. Failing that, a dependency can assemble a model from form fields. This Keeps the handler tidy.

python-multipart

Form and file endpoints need the python-multipart package. Without it FastAPI raises at startup. The message names the package rather than the endpoint. Which is helpful once you know, and puzzling the first time.

It's a runtime dependency of the framework's form support rather than a thing FastAPI bundles. It has to be installed by name.

UploadFile

def upload(file: UploadFile):
    data = file.file.read()

UploadFile gives you three things: filename, content_type, and file, a file-like object.

The key property is that a large upload is spooled to disk rather than held in memory. A bytes param would read the whole thing into RAM. This Is fine for a small avatar and a denial-of-service vector for anything else. UploadFile is the right default.

It also offers async methods. await file.read() — for use in async def endpoints. A slow upload doesn't block the event loop.

What the framework does not check

This is the part worth taking seriously, because the defaults are permissive.

Size is unlimited. Nothing caps an upload. Check it in the handler by reading with a limit rather than reading all and then measuring. And set a limit in what sits in front of the app too. By the time your code runs the bytes have already arrived.

**content_type is a claim.** It comes from the client and can say anything. If the type matters, check the actual content. Magic bytes for images, parsing for structured formats.

**filename is attacker-controlled.** It can hold .., abs paths, null bytes and characters your filesystem treats specially. Never build a path from it. make your own name. A UUID. And keep the first only as metadata if you need it for display.

The content is unexamined. An uploaded file is any bytes. If it will be served back to browsers, serve it from a split domain or with Content-Disposition. Attachment and a restrictive Content-Type. An HTML file can't execute in your origin.

Documenting them

Form and file endpoints appear in the OpenAPI doc. The interactive docs render a file picker for UploadFile. This Makes them truly testable from the browser.

Form() takes description like every other param. It's worth writing. A form field's name is often shorter and less obvious than a JSON key.

When to use which

Reach for JSON by default. It's better documented, better checked, nests properly. Each client can give it.

Reach for form data when a browser form posts right to your API without JavaScript, or when an existing client already sends it.

Reach for multipart when files are involved. That is what it's for. It's the only one of the three that carries binary content alongside fields.

For anything large, consider not uploading through your API at all. A pre-signed URL to object storage moves the bytes right and keeps them out of your request path fully. That is more moving parts. It's the standard answer once files get big.

Mistakes people make with Form Data and File Uploads

**Losing Form().** A plain param is read as a query param. The request 422s about a missing query field while the value sits in the body. The marker is what tells FastAPI where to look.

Mixing a model and form fields. One request, one body, one content type. Declaring both is a clash. The resulting error doesn't say so clearly.

**Missing python-multipart.** FastAPI raises at startup and names the package rather than the endpoint - obvious later, puzzling the first time.

**Reading an upload as bytes.** Fine for an avatar, a denial-of-service vector for anything larger. UploadFile spools to disk.

**Building a path from filename.** It's attacker-controlled and can hold .., abs paths. Characters your filesystem treats specially. make your own name.

**Trusting content_type.** It's a claim from the client. If the type matters, check the bytes.

No size limit. Nothing caps an upload by default. The bytes arrive before your handler runs - so cap it in the handler *and* in what sits in front of the app.

Serving uploads back

A related risk that is easy to miss.

If uploaded files are served back to browsers from your own domain, an uploaded HTML file runs in your origin - which means it can read cookies, call your API as the viewer. Often behave as your own page.

The mitigations, in rough order of preference: serve user content from a split domain. Send Content-Disposition: attachment so it downloads rather than renders. And set a restrictive Content-Type rather than echoing the one the client claimed.

For anything at scale, uploading right to object storage with a pre-signed URL avoids the problem and keeps the bytes out of your request path fully.

Getting a model out of a form

A recurring want: a browser posts a form. You'd rather work with a Pydantic model than six Form() params.

There's no built-in switch for it. A model param means JSON by meaning. The usual answers, in order of how much they cost:

Have the front end send JSON. A few lines of JavaScript. All downstream turns simpler - check, nesting, docs and error shapes all improve.

Build the model in the handler. ModuleIn(**{"title": title, "minutes": minutes}) after declaring the fields as Form(). Honest. Repetitive across a few endpoints.

A dependency that assembles it. The tidy version. One function declaring the form fields and returning the model, then module: ModuleIn = Depends(as_form). Handlers stay clean and the assembly lives in one place. That is the next tier's material.

What to avoid is a decorator that inspects a model and makes form params by reflection. a few exist, they work. They make the endpoint's signature a thing a reader can't see.

Multiple files

files: List[UploadFile] accepts a few parts under the same name. This Is what a multi-select file input sends.

Two cautions with it. The size limit now applies to the total and each item. A hundred small files can be as costly as one large one - cap the count with max_length as well. And checking each file means doing the work per item. So A slow check times.

For anything where the count could be large, an endpoint that accepts one file and is called again and again is easier to reason about, easier to retry. Gives the client better progress reporting.

A closing thought

Uploads are the part of an API where the defaults are most permissive and the results most physical - disk filling, memory exhausting, files being served back and running in your origin.

None of that is exotic, and none of it's handled for you. A size cap, a type check on the bytes rather than the claim, a made filename, and a choice about where the file is served from cover nearly all of it.

Four small pieces of care, on the one endpoint type where their lack is truly dangerous.

Where uploads should go

A last architectural note, because the default path is rarely the right one at scale.

Uploading through your API means the bytes travel into your process, through what sits in front of it. Often out again to storage. That consumes request room, occupies a worker for the duration of a slow link. Puts a size limit on a thing that has no natural one.

The option is a pre-signed URL: your API issues a short-lived credential, the client uploads right to object storage. Then tells your API the key. The bytes never touch your use.

It's more moving parts and it's what most systems end up doing. The failure mode of the simple approach is a worker pool full of slow uploads.

Form Data and File Uploads: summary

Form() marks a field as coming from a form-coded body. Without it the param is read from the query string. A request has one body. Form fields and a JSON model can't coexist.

UploadFile gives a filename, a declared content type and a file-like object, spooled to disk rather than held in memory. Both the filename and the content type come from the client and neither can be trusted. Nothing limits the size unless you do.

Prefer JSON where you've the choice, use multipart when files are involved. For anything large consider uploading straight to object storage instead of through your API.

Where to go after Form Data and File Uploads

Status codes: which number to return when, why the class matters more than the number. How returning 200 for all throws away data that clients, caches and monitoring all already know how to use.

Questions people ask about Form Data and File Uploads

Why do forms need python-multipart? FastAPI uses that package to parse form bodies. Without it installed, endpoints declaring Form or File fail at startup.

Can I mix JSON and form fields in one request? No. A request has a single body content type, so declare form fields or a JSON model, not both.

What is the difference between bytes and UploadFile? bytes reads the whole file into memory. UploadFile spools to disk past a size limit and offers async read and a filename, so it suits larger files.

How do I accept several files? Annotate with list[UploadFile]. The client sends the same field name multiple times.

Does FastAPI check file size or type? No. You must check the content type and size yourself, and do not trust the filename the client supplies.

How do I read an UploadFile? Use await file.read() in an async endpoint, or file.file.read() in a sync one.

A final note

If one thing lives this module, make it the filename rule.

Every other risk here degrades gracefully - a large upload is slow, a wrong content type is a bad thumbnail. Building a path from a client-supplied filename is the one that writes a file where you didn't intend. It's a single line to avoid.

Check yourself

0 of 4

Answer without scrolling back up.

  1. You declare `username: str` with no marker on a POST endpoint. Where does FastAPI look?

  2. Why is `UploadFile` preferable to a `bytes` parameter?

  3. Can you safely use `file.filename` to build a save path?

  4. What does FastAPI do about upload size by default?

Cheat sheet

Form Data and File Uploads

Accept form fields and file uploads in a FastAPI endpoint, inspect the multipart body, and see why form data is not JSON or a query string.

FASTAPI · vizlearn.in/fastapi/form_data_and_files.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.