File Uploads¶
To receive an uploaded file, annotate a parameter with UploadedFile. Django
Ninja reads it from request.FILES, and — because a file can only travel
over HTTP as multipart/form-data — automatically switches the operation's
request encoding to multipart/form-data the moment it sees a File
parameter (combined with Form fields or a JSON body, if there
are any).
Basic usage¶
from ninja import NinjaAPI, UploadedFile
api = NinjaAPI()
@api.post("/upload")
def upload(request, file: UploadedFile):
data = file.read()
return {"name": file.name, "len": len(data)}
You don't need to mark the parameter with File(...) — Django Ninja
recognizes the UploadedFile type on its own and treats it as a file
parameter automatically. Marking it explicitly with File works exactly the
same way and is useful once you need validation constraints:
from ninja import File, NinjaAPI, UploadedFile
api = NinjaAPI()
@api.post("/upload")
def upload(request, file: File[UploadedFile]):
data = file.read()
return {"name": file.name, "len": len(data)}
UploadedFile is Django's own UploadedFile
(Django Ninja re-exports it so Pydantic knows how to validate it), so it has
all of the usual attributes and methods:
read(size=None)multiple_chunks(chunk_size=None)chunks(chunk_size=None)namesizecontent_typecontent_type_extracharset
Multiple files¶
Declare a list of UploadedFile to accept several files under the same
field name:
from ninja import NinjaAPI, UploadedFile
api = NinjaAPI()
@api.post("/upload-many")
def upload_many(request, files: list[UploadedFile]):
return [f.name for f in files]
A client sends this as several multipart/form-data parts that all share the
files field name. set and tuple annotations work the same way.
You can also just declare several separate UploadedFile parameters — each
one reads from its own field name (file1, file2, ...), and you can mix
single files and lists of files on the same operation:
from ninja import NinjaAPI, UploadedFile
api = NinjaAPI()
@api.post("/upload-both")
def upload_both(request, file: UploadedFile, extra: list[UploadedFile]):
return {"file": file.name, "extra": [f.name for f in extra]}
Optional files¶
Give the parameter a File(None) default (and a | None annotation) to make
it optional. A bare None default doesn't work here, since the automatic
UploadedFile → File detection only kicks in when the annotation is
exactly UploadedFile, not UploadedFile | None:
from ninja import File, NinjaAPI, UploadedFile
api = NinjaAPI()
@api.post("/upload-optional")
def upload_optional(request, file: File[UploadedFile | None] = None):
if file is None:
return {"uploaded": False}
return {"uploaded": True, "name": file.name}
Validation and metadata¶
Validation and metadata arguments go in P(...), the same as for
Form:
gt, ge, lt, le, min_length, max_length, pattern, plus alias,
title, description, example/examples, deprecated and
include_in_schema for the generated OpenAPI schema.
min_length/max_length are most useful on a list of files, where they
constrain how many files must be sent:
from ninja import File, NinjaAPI, P, UploadedFile
api = NinjaAPI()
@api.post("/upload-gallery")
def upload_gallery(
request, images: File[list[UploadedFile], P(min_length=1, max_length=5)]
):
return [f.name for f in images]
Use alias when the multipart field name isn't a valid Python identifier:
from ninja import File, NinjaAPI, P, UploadedFile
api = NinjaAPI()
@api.post("/upload-legacy")
def upload_legacy(request, file: File[UploadedFile, P(alias="user-file")]):
return {"name": file.name}
The older default form, file: UploadedFile = File(...), still works and means
the same thing as file: File[UploadedFile], but the annotation is preferred.
Files with form data or a JSON body¶
Because the HTTP protocol has no way to put binary files inside
application/json, sending files alongside other data means sending
everything as multipart/form-data. Django Ninja does this switch for you
automatically whenever an operation has at least one File parameter — you
don't need to configure anything.
Mark the extra fields with Form, individually or grouped in
a Schema. Each field becomes its own part of the multipart/form-data
request:
from datetime import date
from ninja import File, Form, NinjaAPI, Schema, UploadedFile
api = NinjaAPI()
class UserDetails(Schema):
first_name: str
last_name: str
birth_date: date
@api.post("/users")
def create_user(request, details: Form[UserDetails], file: File[UploadedFile]):
return [details.dict(), file.name]
The client sends first_name, last_name, birth_date and file as four
separate multipart/form-data parts.
Leave the schema as a plain (Body-like) parameter instead, and it's sent
as a single multipart field whose value is the JSON-encoded object:
from datetime import date
from ninja import File, NinjaAPI, Schema, UploadedFile
api = NinjaAPI()
class UserDetails(Schema):
first_name: str
last_name: str
birth_date: date
@api.post("/users")
def create_user(request, details: UserDetails, file: File[UploadedFile]):
return [details.dict(), file.name]
The client now sends exactly two multipart/form-data parts: details
(the object, serialized to a JSON string) and file.
Both styles combine with list[UploadedFile] the same way — replace file
above with images: list[UploadedFile] to accept a Schema plus a gallery of
files in one request.
Prefer Form. Form fields are what HTML forms and most HTTP clients send
by default, and every field shows up as its own input in the interactive docs.
Reach for the JSON body style when the data is nested (lists of objects,
nested schemas) and doesn't flatten into form fields well.
When a Body parameter is mixed with File or Form, how its part is read
depends on its type:
| Parameter type | Multipart part is read as |
|---|---|
Schema, list, dict, TypedDict, dataclass, Any, … |
a JSON-encoded string, e.g. {"first_name": "John"} |
str, int, date, Enum, Literal, UUID, … |
plain text, e.g. John — the same as a Form field |
The rule follows the parameter's JSON schema: primitives (string, number,
integer, boolean) are plain text, anything else is JSON. Optional[...] and
Annotated[...] don't change it, and a union that includes a structured type
is read as JSON as a whole: for Union[str, dict] send {"a": 1} or "hello"
(JSON-quoted).
So for scalar values Body(...) and Form(...) behave the same here; use
Form to make the intent clear.
Swagger UI / OpenAPI
This matches the OpenAPI default encoding for multipart/form-data:
object properties are sent as application/json, primitive ones as plain
text. In Swagger's "Try it out", a JSON body schema appears as a single
text area — type the object as JSON there — while scalar fields appear as
ordinary inputs. Generated API clients follow the same rules.
Tip
Path and query parameters can still be declared on the same operation —
Django Ninja resolves each parameter from its own source regardless of
how many File/Form/Body parameters are mixed in.
request.FILES with PUT and PATCH¶
Django's request.FILES is only populated for POST requests by
default — a long
standing Django behavior, not specific to Django Ninja. This means a PUT or
PATCH operation that declares a File parameter would never actually
receive the file, since request.FILES stays empty.
Django Ninja detects this at operation-registration time and raises
ninja.errors.ConfigError immediately instead of letting the endpoint fail
silently at request time:
@api.patch("/upload") # raises ConfigError as soon as this module is imported
def upload(request, file: UploadedFile):
...
To fix it, add Django Ninja's compatibility middleware to MIDDLEWARE. It
manually parses multipart/form-data for the affected methods and populates
request.FILES before your view runs:
MIDDLEWARE = [
# ... your existing middleware ...
"ninja.compatibility.files.fix_request_files_middleware",
]
By default the middleware (and the ConfigError check) applies to PUT,
PATCH and DELETE. Override which methods it covers with the
NINJA_FIX_REQUEST_FILES_METHODS setting:
Note
The middleware only touches requests it needs to — it leaves normal
POST requests, requests with an application/json body, and any method
not listed in NINJA_FIX_REQUEST_FILES_METHODS completely alone.
Notes¶
- File uploads are still bound by Django's own upload settings —
DATA_UPLOAD_MAX_MEMORY_SIZE,FILE_UPLOAD_MAX_MEMORY_SIZEandFILE_UPLOAD_PERMISSIONSapply the same way they would in a plain Django view. Django Ninja doesn't add a size limit of its own on top of these. - Uploaded files behave like any other parameter in tests — see
Testing for how
TestClientsendsFILES.