What a browser form actually sends, and why it is neither JSON nor a query string.
Overview
Three body formats
An HTTP request has one body, and for an API there are three ways it is commonly encoded.
JSON (application/json) is what a JavaScript client or another service sends. A Pydantic model parameter reads it.
URL-encoded form (application/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 several named parts, each with its own headers, which is how a file and its metadata travel together.
FastAPI needs to be told which. A model parameter 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 actually sends, and why it is 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.
Without Form(), a plain username: str parameter would be read as a query parameter, and 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 remember here. The marker is not decoration; it is what tells FastAPI where to look.
Everything else behaves as it does elsewhere: constraints, defaults, required-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 cannot be both JSON and a form. Declaring a model parameter alongside a Form() parameter is a contradiction, and the resulting error does not always say so clearly.
Pick the encoding your client actually sends. If it is a browser form, use Form(). If it is JavaScript, it is almost certainly sending JSON, and a model is simpler and better documented.
A common middle case: an HTML form that you would 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, which keeps the handler tidy.
python-multipart
Form and file endpoints require the python-multipart package. Without it FastAPI raises at startup, and the message names the package rather than the endpoint — which is helpful once you know, and puzzling the first time.
It is a runtime dependency of the framework's form support rather than something FastAPI bundles, so it has to be installed explicitly.
UploadFile
def upload(file: UploadFile):
data = file.file.read()
UploadFile gives you three things: filename, content_type, and file, a file-like object.
The important property is that a large upload is spooled to disk rather than held in memory. A bytes parameter would read the whole thing into RAM, which 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, so a slow upload does not 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 everything and then measuring — and set a limit in whatever sits in front of the app too, since 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 contain .., absolute paths, null bytes and characters your filesystem treats specially. Never build a path from it. Generate your own name — a UUID — and keep the original only as metadata if you need it for display.
The content is unexamined. An uploaded file is arbitrary bytes. If it will be served back to browsers, serve it from a separate domain or with Content-Disposition: attachment and a restrictive Content-Type, so an HTML file cannot execute in your origin.
Documenting them
Form and file endpoints appear in the OpenAPI document, and the interactive docs render a file picker for UploadFile, which makes them genuinely testable from the browser.
Form() takes description like every other parameter, and it is 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 is better documented, better validated, nests properly, and every client can produce it.
Reach for form data when a browser form posts directly to your API without JavaScript, or when an existing client already sends it.
Reach for multipart when files are involved. That is what it is for, and it is 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 directly and keeps them out of your request path entirely. That is more moving parts, and it is the standard answer once files get big.
Mistakes people make
Forgetting Form(). A plain parameter is read as a query parameter, so 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 contradiction, and the resulting error does not say so clearly.
Missing python-multipart. FastAPI raises at startup and names the package rather than the endpoint - obvious afterwards, 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 is attacker-controlled and can contain .., absolute paths, and characters your filesystem treats specially. Generate your own name.
Trusting content_type. It is a claim from the client. If the type matters, check the bytes.
No size limit. Nothing caps an upload by default, and the bytes arrive before your handler runs - so cap it in the handler *and* in whatever 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 executes in your origin - which means it can read cookies, call your API as the viewer, and generally behave as your own page.
The mitigations, in rough order of preference: serve user content from a separate 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 directly to object storage with a pre-signed URL avoids the problem and keeps the bytes out of your request path entirely.
Getting a model out of a form
A recurring want: a browser posts a form, and you would rather work with a Pydantic model than six Form() parameters.
There is no built-in switch for it, because a model parameter means JSON by definition. The usual answers, in order of how much they cost:
Have the front end send JSON. A few lines of JavaScript, and everything downstream becomes simpler - validation, nesting, documentation and error shapes all improve.
Build the model in the handler.ModuleIn(**{"title": title, "minutes": minutes}) after declaring the fields as Form(). Honest, and repetitive across several 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 generates form parameters by reflection. Several exist, they work, and they make the endpoint's signature something a reader cannot see.
Multiple files
files: List[UploadFile] accepts several parts under the same name, which is what a multi-select file input sends.
Two cautions with it. The size limit now applies to the total as well as each item, so a hundred small files can be as expensive as one large one - cap the count with max_length as well. And validating each file means doing the work per item, so a slow check multiplies.
For anything where the count could be large, an endpoint that accepts one file and is called repeatedly is easier to reason about, easier to retry, and gives the client better progress reporting.
A closing thought
Uploads are the part of an API where the defaults are most permissive and the consequences most physical - disk filling, memory exhausting, files being served back and executing in your origin.
None of that is exotic, and none of it is handled for you. A size cap, a type check on the bytes rather than the claim, a generated filename, and a decision about where the file is served from cover nearly all of it.
Four small pieces of care, on the one endpoint type where their absence is genuinely 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 whatever sits in front of it, and often out again to storage. That consumes request capacity, occupies a worker for the duration of a slow connection, and puts a size limit on something that has no natural one.
The alternative is a pre-signed URL: your API issues a short-lived credential, the client uploads directly to object storage, and then tells your API the key. The bytes never touch your application.
It is more moving parts and it is what most systems end up doing, because the failure mode of the simple approach is a worker pool full of slow uploads.
Summary
Form() marks a field as coming from a form-encoded body; without it the parameter is read from the query string. A request has one body, so form fields and a JSON model cannot 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 have the choice, use multipart when files are involved, and for anything large consider uploading straight to object storage instead of through your API.
Next
Status codes: which number to return when, why the class matters more than the number, and how returning 200 for everything throws away information that clients, caches and monitoring all already know how to use.
A final note
If one thing survives 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 did not intend, and it is a single line to avoid.
Check yourself
0 of 4
Answer without scrolling back up.
You declare `username: str` with no marker on a POST endpoint. Where does FastAPI look?
A plain parameter defaults to a query parameter. `Form()` is what tells FastAPI the value is in a form-encoded body.
Why is `UploadFile` preferable to a `bytes` parameter?
Reading a whole upload into RAM is fine for an avatar and a denial-of-service vector for anything larger.
Can you safely use `file.filename` to build a save path?
It comes from the client and can say anything. Generate your own name and keep the original only as display metadata.
What does FastAPI do about upload size by default?
Nothing caps it. Read with a limit in the handler, and set one in whatever sits in front of the app, since the bytes arrive before your code runs.
Cheat sheet
Form Data and File Uploads
URL-encoded form (application/x-www-form-urlencoded) is what a plain HTML <form> posts. It looks like a query string in the body: username=ada&password=hunter2.
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.