Media files: handling image and file uploads
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
pip install Pillow# blog/models.py
class Post(models.Model):
# ... existing fields ...
image = models.ImageField(upload_to="post_images/", blank=True, null=True)python manage.py makemigrations blog
python manage.py migrateImageField 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
# blogsite/settings.py
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"# 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
# blog/forms.py
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "slug", "content", "published", "image"]{# 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
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
{# 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.
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.