~/TechPurAI
~/tutorials/django-from-scratch/media-and-file-uploads
intermediate·part 19 of 22·3 min read

Media files: handling image and file uploads

Updated Aug 15, 2026Python · Django

Part 18 covered static files — CSS and JS bundled with the project, the same for every user. This part covers media files — content a user uploads, like a featured image on a post — which Django deliberately keeps in a separate settings pair, since the two get handled very differently in production.

Adding an image field

bash
pip install Pillow
python
# blog/models.py
class Post(models.Model):
    # ... existing fields ...
    image = models.ImageField(upload_to="post_images/", blank=True, null=True)
bash
python manage.py makemigrations blog
python manage.py migrate

ImageField needs Pillow installed to validate that an uploaded file is actually a valid image — without it, makemigrations fails outright. upload_to="post_images/" is a subfolder under MEDIA_ROOT (set up next) that uploaded files land in. blank=True, null=True makes the field optional — blank=True for form validation (an empty field passes), null=True for the database column (it can store NULL); an ImageField needs both, since they control two different layers.

Media settings

python
# blogsite/settings.py
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
python
# blogsite/urls.py
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    # ... existing patterns ...
]

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

MEDIA_ROOT is the actual folder on disk uploads get saved to; MEDIA_URL is the URL prefix they're served under, the same relationship STATIC_ROOT/STATIC_URL has. The if settings.DEBUG: block matters: Django's dev server will serve uploaded media through it, but only while DEBUG = True — production never uses this shortcut, and part 22 covers what replaces it (typically the same web server already serving static files).

Fixing the form to accept a file

python
# blog/forms.py
class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "slug", "content", "published", "image"]
django
{# blog/templates/blog/post_form.html #}
<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Save</button>
</form>

enctype="multipart/form-data" on the <form> tag is not optional for a file upload — without it, the browser submits the file's name as a plain string instead of its actual contents, and the upload silently does nothing.

Fixing the view to read it

python
class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    form_class = PostForm
    template_name = "blog/post_form.html"
    success_url = reverse_lazy("post_list")

    def form_valid(self, form):
        form.instance.author = self.request.user
        return super().form_valid(form)

Nothing changes here — CreateView and UpdateView already read request.FILES automatically when instantiating the form, alongside request.POST. That's specific to the generic class-based views; a function-based view handling a file upload has to pass both explicitly: PostForm(request.POST, request.FILES).

Displaying it

django
{# post_detail.html #}
{% if post.image %}
  <img src="{{ post.image.url }}" alt="{{ post.title }}">
{% endif %}

{% if post.image %} guards against posts with no uploaded image — post.image.url raises an error on an empty field rather than returning None, since checking for the field's presence first.

Common mistake

Forgetting enctype="multipart/form-data" and then debugging why an uploaded image is always empty on save — the form validates fine, image is just never actually present in what got submitted.

Next: pagination — the post list has been rendering every published post on one page since part 9, with no limit.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous18. Static files: serving CSS and JavaScript correctlynext →20. Pagination: splitting a long list across pages