Media Files & Uploads

In the last lesson, static files were defined as assets that ship with your codebase — CSS, JavaScript, the images you designed yourself. Media files are the opposite category: content that arrives after your app is already running, uploaded by an actual user through a form. A TaskFlow user attaching a cover image to a project is a media file. The taskflow/style.css that renders that project's page is a static file. Same broad idea — "a file the browser can request" — but they're configured differently, stored differently, and (as you'll see two lessons from now) deployed very differently in production.

MEDIA_URL and MEDIA_ROOT

Media files are controlled by a pair of settings that mirror the static files settings from the previous lesson, but they mean something subtly different. MEDIA_URL is the URL prefix uploaded files are served from. MEDIA_ROOT is the actual folder on disk where Django saves uploaded files. Unlike STATIC_ROOT, which only gets populated by running collectstatic, MEDIA_ROOT is written to directly, every time a user uploads something.

# taskflow_project/settings.py
import os

MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

With those two settings defined, Django knows where to physically save an uploaded file (MEDIA_ROOT) and what URL to hand back so a browser can later request it (MEDIA_URL).

Adding a File Upload Field to a Model

Let's give TaskFlow's Project model the ability to hold a cover image. Django provides ImageField for exactly this — a specialized field that validates the uploaded file is actually a readable image, in addition to storing it.

# taskflow/models.py
from django.db import models
from django.contrib.auth.models import User

class Project(models.Model):
    name = models.CharField(max_length=120)
    description = models.TextField(blank=True)
    owner = models.ForeignKey(User, on_delete=models.CASCADE)
    cover_image = models.ImageField(upload_to='project_covers/', blank=True, null=True)

    def __str__(self):
        return self.name

The upload_to='project_covers/' argument tells Django where, relative to MEDIA_ROOT, each uploaded file should be saved. So a cover image uploaded for a project ends up on disk at media/project_covers/some_image.jpg, and is reachable in the browser at /media/project_covers/some_image.jpg.

Note: ImageField requires the third-party Pillow package to be installed (pip install Pillow). Django uses it behind the scenes to verify the uploaded file is a genuine, readable image. If you only need to accept arbitrary files without image validation, use the plainer FileField instead.

Handling a File Upload in a Form

A regular HTML form does not send file contents by default — it only sends text-encoded field values. To actually transmit a file's bytes, the form must explicitly opt in with enctype="multipart/form-data".

{% load static %}
<form method="post" enctype="multipart/form-data">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Save Project</button>
</form>
Common beginner mistake: forgetting enctype="multipart/form-data". Without it, the form still submits and looks like it "worked" — no error is thrown — but the uploaded file simply never arrives on the server. request.FILES will be empty every time. If an upload form seems to silently ignore the file field, this attribute is the first thing to check.

Accessing the Uploaded File via request.FILES

Once a form is submitted with the correct enctype, Django separates incoming data into two dictionaries on the request: request.POST for regular text fields, and request.FILES for anything uploaded. A view handling the project form needs to read from both.

# taskflow/views.py
from django.shortcuts import render, redirect
from .forms import ProjectForm

def create_project(request):
    if request.method == 'POST':
        form = ProjectForm(request.POST, request.FILES)
        if form.is_valid():
            project = form.save(commit=False)
            project.owner = request.user
            project.save()
            return redirect('project-detail', pk=project.pk)
    else:
        form = ProjectForm()
    return render(request, 'taskflow/project_form.html', {'form': form})

Notice the form is constructed with two arguments now — ProjectForm(request.POST, request.FILES) — instead of just one. Leaving off request.FILES is the server-side equivalent of the missing enctype mistake above: the form will report itself invalid, or simply save without the image, because Django never received the uploaded file's data.

Serving Uploaded Media Files in Development

Static files get served automatically by Django's development server whenever DEBUG = True, as covered in the previous lesson. Media files do not get that same automatic treatment — you have to explicitly add a URL pattern for them, even in development. Django's static() helper makes this a one-line addition to your project's urls.py.

# taskflow_project/urls.py
from django.contrib import admin
from django.urls import path
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path('admin/', admin.site.urls),
    # ... your app's url patterns ...
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

That if settings.DEBUG: guard is deliberate and important — this snippet is a development convenience only. It should never end up serving files this way in production, which is exactly what we'll dig into in the next lesson.

Full Worked Example: Upload, Save, Display

Putting every piece together — the model, the form, the view, and the URL wiring — here's the complete round trip for a TaskFlow project cover image.

# taskflow/forms.py
from django import forms
from .models import Project

class ProjectForm(forms.ModelForm):
    class Meta:
        model = Project
        fields = ['name', 'description', 'cover_image']

The template that renders the saved project reads the uploaded file back through the field's .url attribute — never a hardcoded path, since Django builds that URL from MEDIA_URL for you.

{# taskflow/templates/taskflow/project_detail.html #}
<h2>{{ project.name }}</h2>

{% if project.cover_image %}
    <img src="{{ project.cover_image.url }}" alt="{{ project.name }} cover">
{% else %}
    <p>No cover image uploaded yet.</p>
{% endif %}

<p>{{ project.description }}</p>

Checking {% if project.cover_image %} before rendering the <img> matters because cover_image is optional (blank=True, null=True on the model) — a project created before this field existed, or one where the user simply skipped the upload, won't have a file to point at.

Note: This is correct, real Django code — but this website's own project doesn't run a Django server, so you won't see this upload flow actually execute on this page. Wire it up in your own TaskFlow project to see the image round-trip for real.
Next up: Everything above works fine on your laptop. But Django's dev server was never meant to serve these files at real scale — see Serving Files in Production for how static and media files are actually handled once your app goes live.