Part 6: File uploads & forms¶
Tasks often come with files: a design mockup, a screenshot of a bug, a PDF from the client. In this part you'll let members attach files to tasks. An upload is a multipart/form-data request instead of JSON, so you'll combine a file parameter with form fields, validate the file's size and type, store it with a plain FileField, and return an absolute URL for it.
What you'll learn
- How to accept a file together with form fields using
File[...]andForm[...] - How to validate an upload with a reusable annotated type, so errors come back as
422 - How to configure
MEDIA_ROOTand serve uploaded files during development - How to build absolute URLs in a response with a resolver that reads the request
- How to delete a file together with its database row, behind a permission check
Time: about 15 minutes
Storing files¶
An attachment belongs to a task and remembers who uploaded it. Add the model at the end of tasks/models.py:
class Attachment(models.Model):
task = models.ForeignKey(Task, related_name="attachments", on_delete=models.CASCADE)
file = models.FileField(upload_to="attachments/%Y/%m/")
name = models.CharField(max_length=200)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL, related_name="attachments", on_delete=models.CASCADE
)
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
fileis aFileField, not anImageField.ImageFieldneeds Pillow, and attachments aren't only images.upload_toputs files in a folder per month, such asattachments/2026/09/. The database stores only that relative path. The file itself goes to the storage, which is the local disk by default.nameis the display name. The client can choose it, or it defaults to the uploaded file's name.
$ python manage.py makemigrations
Migrations for 'tasks':
tasks/migrations/0005_attachment.py
+ Create model Attachment
$ python manage.py migrate
Django needs to know where to put uploads and under which URL to serve them. Add two settings below STATIC_URL:
STATIC_URL = 'static/'
# User-uploaded files
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
runserver doesn't serve MEDIA_ROOT on its own. Add Django's static() helper to the URLconf:
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path
from .api import api
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", api.urls),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
static() returns no URL patterns when DEBUG is off. In production, the web server or a storage service such as S3 serves the files. The .gitignore from Part 1 already excludes media/.
Validating the upload¶
Django Ninja passes an uploaded file to your view as an UploadedFile, which is Django's own class. It already has a name and a size. Validation rules for it can live in the type, like any other Pydantic rule. Add the new imports and code to tasks/schemas.py:
from datetime import date
from pathlib import Path
from typing import Annotated, Literal
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AfterValidator, Field, field_validator, model_validator
from pydantic_core import PydanticCustomError
from accounts.schemas import UserOut
from projects.schemas import LabelOut
from .models import Attachment, Comment, Priority, Task, TaskStatus
...
MAX_UPLOAD_SIZE = 5 * 1024 * 1024 # 5 MB
ALLOWED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".txt", ".md"}
def check_upload(file: UploadedFile) -> UploadedFile:
if file.size > MAX_UPLOAD_SIZE:
raise PydanticCustomError("file_too_large", "Files can be at most 5 MB")
if Path(file.name).suffix.lower() not in ALLOWED_EXTENSIONS:
raise PydanticCustomError("file_type", "Only PDF, PNG, JPEG and text files are allowed")
return file
AttachmentFile = Annotated[UploadedFile, AfterValidator(check_upload)]
class AttachmentIn(Schema):
name: str = Field("", max_length=200)
check_uploadis a normal Pydantic validator. It runs after the file is parsed, and raisesPydanticCustomErrorlike the validators from Part 3, so a rejected file is a422with its owntypeand message.AttachmentFilebundles the type and its rules. Any endpoint that takes an attachment can use it, and the view only ever sees a valid file.- The extension check uses the file name. The
content_typeof an upload comes from the client too, so neither proves what's inside the file. What they do prevent is storing, say, an.htmlfile that your media server would then serve as a web page. - Django has no size limit for uploaded files.
DATA_UPLOAD_MAX_MEMORY_SIZEdoesn't count them, andFILE_UPLOAD_MAX_MEMORY_SIZEonly decides when Django switches from memory to a temporary file. The check runs after the whole file has arrived, so in production also cap the request size in the web server, such asclient_max_body_sizein nginx. AttachmentInholds the form fields that come with the file. There is only one here, the optional display name.
Go deeper: File Uploads, Errors & Exception Handling
The upload endpoint¶
A file can't travel inside a JSON body, so the whole request is multipart/form-data: one part per form field, and one for the file. Add the imports and two endpoints to tasks/api.py:
from django.shortcuts import get_object_or_404
from ninja import File, Form, PatchDict, Path, Query, Router, Status
from ninja.errors import HttpError
from ninja.pagination import CursorPagination, PageNumberPagination, paginate
from projects.models import Role
from projects.permissions import get_project_or_404
from .models import ALLOWED_TRANSITIONS, Priority, Task
from .schemas import (
AttachmentFile,
AttachmentIn,
AttachmentOut,
CommentIn,
CommentOut,
StatusIn,
TaskFilter,
TaskIn,
TaskOrdering,
TaskOut,
TaskUpdate,
)
...
@router.get("/{task_id}/attachments", response=list[AttachmentOut])
def list_attachments(request, project_id: Path[int], task_id: int):
task = get_task_or_404(request.auth, project_id, task_id)
return task.attachments.select_related("uploaded_by").order_by("id")
@router.post("/{task_id}/attachments", response={201: AttachmentOut})
def upload_attachment(
request,
project_id: Path[int],
task_id: int,
details: Form[AttachmentIn],
file: File[AttachmentFile],
):
task = get_task_or_404(request.auth, project_id, task_id)
attachment = task.attachments.create(
file=file, name=details.name or file.name, uploaded_by=request.auth
)
return Status(201, attachment)
Form[AttachmentIn]reads the schema's fields from the form data, the same wayQuery[TaskFilter]read them from the query string in Part 5. The client sends anamefield, not adetailsfield.File[AttachmentFile]reads thefilepart fromrequest.FILESand runscheck_upload. With aFileparameter present, Django Ninja documents the whole request body asmultipart/form-data, and the interactive docs show a file picker.- Passing the
UploadedFiletocreate()is enough. TheFileFieldsaves it to the storage underupload_to, and the row gets the stored path. - The file name comes from the client, but Django keeps only its last part. A file sent as
../../evil.pdfis stored asattachments/2026/09/evil.pdf. If a file with the same name exists, the storage adds a random suffix, as infooter_42rBBEz.png, and the display name staysfooter.png. - Both endpoints start with
get_task_or_404from Part 4, so only members of the project can list or upload files.
Note
Django only fills request.FILES for POST requests. Django Ninja raises a ConfigError if you declare a File parameter on a PUT or PATCH operation, unless you add its compatibility middleware. The File Uploads guide explains how.
Go deeper: File Uploads, Form Data
Absolute file URLs¶
A FileField listed in Meta.fields is serialized as its URL, such as /media/attachments/2026/09/sitemap.pdf. That's a path, not a URL. A mobile app or a frontend on another domain doesn't know which host to put in front of it. The request does, so AttachmentOut builds the full URL in a resolver:
class AttachmentOut(ModelSchema):
uploaded_by: UserOut
url: str
class Meta:
model = Attachment
fields = ["id", "name", "uploaded_at"]
@staticmethod
def resolve_url(obj, context):
return context["request"].build_absolute_uri(obj.file.url)
- A resolver that takes a
contextargument receives{"request": ..., "response_status": ...}when the schema is used as aresponse.build_absolute_uri()adds the scheme and host of the current request. urlis a new field that doesn't exist on the model. The model'sfilefield isn't inMeta.fields, so the response has no relative path next to the absolute one.- Declared fields come first in the output, so the keys are
uploaded_by,url, thenid,nameanduploaded_at.
Go deeper: Schemas: accessing context, Schemas: files and images
Try it¶
Start runserver and set TOKEN to an access token for alice, as in Part 4. With any small PDF saved as sitemap.pdf, upload it to task 1. curl -F sends a multipart/form-data request, and @ reads the file from disk:
$ curl http://127.0.0.1:8000/api/projects/1/tasks/1/attachments \
-H "Authorization: Bearer $TOKEN" \
-F "name=Sitemap draft" \
-F "[email protected]"
{
"uploaded_by": {
"display_name": "Alice Martin",
"id": 1,
"username": "alice"
},
"url": "http://127.0.0.1:8000/media/attachments/2026/09/sitemap.pdf",
"id": 1,
"name": "Sitemap draft",
"uploaded_at": "2026-09-28T11:12:57.878Z"
}
Don't set a Content-Type header yourself. curl -F sets it to multipart/form-data together with the boundary string that separates the parts. The URL works in the browser, and curl -I shows that Django serves it with the right type:
$ curl -I http://127.0.0.1:8000/media/attachments/2026/09/sitemap.pdf
HTTP/1.1 200 OK
Date: Mon, 28 Sep 2026 11:13:36 GMT
Server: WSGIServer/0.2 CPython/3.12.13
Content-Type: application/pdf
Content-Length: 2025
Content-Disposition: inline; filename="sitemap.pdf"
...
The media URL needs no token. Anyone who has the link can download the file. That's fine for this tutorial, but for private files you'd return the file from an endpoint that checks permissions first, or use signed URLs from your storage service.
Now try a file type that isn't allowed, such as -F "[email protected]":
{
"detail": [
{
"type": "file_type",
"loc": [
"file",
"file"
],
"msg": "Only PDF, PNG, JPEG and text files are allowed"
}
]
}
A 6 MB scan.pdf gets "type": "file_too_large" with the message "Files can be at most 5 MB". Errors in the form fields and the file are collected together. A 201-character name with no file returns both:
{
"detail": [
{
"type": "string_too_long",
"loc": [
"form",
"name"
],
"msg": "String should have at most 200 characters",
"ctx": {
"max_length": 200
}
},
{
"type": "missing",
"loc": [
"file",
"file"
],
"msg": "Field required"
}
]
}
The first item of loc is where the value was read from: form for form fields, file for files. A JSON body sent to this endpoint gets the same missing error for file.
Deleting attachments¶
Deleting follows the rule from Part 4's comments, with one difference: the uploader can delete their own file, and so can the project's owners and admins. Add the endpoint to tasks/api.py:
@router.delete("/{task_id}/attachments/{attachment_id}", response={204: None})
def delete_attachment(request, project_id: Path[int], task_id: int, attachment_id: int):
task = get_task_or_404(request.auth, project_id, task_id)
attachment = get_object_or_404(task.attachments, id=attachment_id)
if attachment.uploaded_by != request.auth:
get_project_or_404(request.auth, project_id, roles=[Role.OWNER, Role.ADMIN])
attachment.file.delete(save=False)
attachment.delete()
return Status(204, None)
get_object_or_404(task.attachments, ...)looks the attachment up within the task. An attachment id from another task is a404.- For anyone but the uploader,
get_project_or_404withrolesdoes the role check, and raises a403for a plain member. - Deleting the row doesn't delete the file. Django never removes files from the storage on its own, not even on a cascade.
attachment.file.delete(save=False)removes it first.save=Falseskips saving the row, which is deleted on the next line anyway.
Bob is a member of the project, not an owner. Get a token for him (password builder-42) and put it in BOB_TOKEN. When he tries to delete alice's sitemap:
$ curl -X DELETE http://127.0.0.1:8000/api/projects/1/tasks/1/attachments/1 -H "Authorization: Bearer $BOB_TOKEN"
His own uploads, or alice deleting any attachment in her project, return 204 and remove the file from media/. Files deleted with the whole task, by the cascade, stay on disk. Cleaning those up takes a periodic job or a post_delete signal.
Go deeper: Authentication
Recap¶
- A
File[...]parameter makes the requestmultipart/form-data. Combine it withForm[Schema]to read the other fields, which are validated like a JSON body. Annotated[UploadedFile, AfterValidator(...)]puts size and type rules into a reusable type. A bad file is a422withloc["file", ...], next to any form errors.- Store uploads with a
FileFieldandMEDIA_ROOT, and serve them withstatic()in development only. - A resolver with a
contextargument can read the request, andbuild_absolute_uri()turns a file's path into a full URL. - Delete the stored file together with the row. Django won't do it for you.
Next¶
TaskFlow now has most of its features, and each part checked them by hand. In Part 7: Testing, you'll turn those checks into a pytest suite with Django Ninja's TestClient, covering CRUD, validation, permissions, filters and uploads.