The recording for this lesson has not been published yet.
You already met STATIC_URL in the tutorial: CSS and images that you
ship with the code. Uploads are different. They arrive at runtime, they are not in git, and
they must never be trusted.
| Static | Media | |
|---|---|---|
| Comes from | You, in the repository | Users, at runtime |
| Settings | STATIC_URL, STATIC_ROOT, STATICFILES_DIRS |
MEDIA_URL, MEDIA_ROOT |
| Collected by | collectstatic |
Nothing - the files are already there |
| In version control | Yes | Never |
# mysite/settings.py
MEDIA_URL = "/media/" # the URL prefix in the browser
MEDIA_ROOT = BASE_DIR / "media" # the directory on disk
# mysite/urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("blog.urls")),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
static() returns an empty list when DEBUG is False, on
purpose: the view behind it is single-threaded and not hardened. In production the web
server or object storage serves /media/. On PythonAnywhere you map the URL
to the directory in the Web tab.
Add the directory to .gitignore so uploads never end up in a commit:
# .gitignore
/media/
pip install Pillow
pip freeze > requirements.txt
# blog/models.py
def post_image_path(instance, filename):
"""Group uploads by year so the directory stays browsable."""
return f"posts/{timezone.now():%Y/%m}/{filename}"
class Post(models.Model):
...
image = models.ImageField(
upload_to=post_image_path,
blank=True,
null=True,
help_text="JPEG, PNG or WebP, up to 2 MB.",
)
attachment = models.FileField(upload_to="attachments/", blank=True)
ImageField is a FileField that additionally verifies the file is
really an image (Pillow opens it) and gives you .width and
.height. Both store a relative path in the database, so moving
MEDIA_ROOT does not break any row.
| Attribute | Value |
|---|---|
post.image.name | posts/2026/08/cat.jpg |
post.image.path | Absolute path on disk (local storage only) |
post.image.url | /media/posts/2026/08/cat.jpg |
post.image.size | Bytes - hits the storage backend |
enctype="multipart/form-data" on the form tag, and
request.FILES as the second argument to the form. Miss either one and the
field stays empty with no error at all.
# blog/views.py
def post_edit(request, pk):
post = get_object_or_404(Post, pk=pk)
form = PostForm(request.POST or None, request.FILES or None, instance=post)
if request.method == "POST" and form.is_valid():
form.save()
return redirect(post.get_absolute_url())
return render(request, "blog/post_edit.html", {"form": form})
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
{{ form.as_div }}
<button class="btn btn-primary">Save</button>
</form>
{% if post.image %}
<img src="{{ post.image.url }}" alt="{{ post.title }}" class="img-fluid rounded">
{% endif %}
A browser tells you the file name and a Content-Type header. Both are supplied
by the client and both can lie. Check three things yourself.
# blog/validators.py
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2 MB
validate_image_extension = FileExtensionValidator(
allowed_extensions=["jpg", "jpeg", "png", "webp"]
)
def validate_max_size(value):
if value.size > MAX_UPLOAD_SIZE:
raise ValidationError(
"Keep the file under %(limit)s MB (yours is %(size).1f MB).",
params={"limit": 2, "size": value.size / 1024 / 1024},
)
image = models.ImageField(
upload_to=post_image_path,
blank=True,
validators=[validate_image_extension, validate_max_size],
)
The real content check is to let Pillow verify the bytes. ImageField already
does this, but if you accept a plain FileField, do it in the form:
from PIL import Image, UnidentifiedImageError
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "text", "image"]
def clean_image(self):
image = self.cleaned_data.get("image")
if not image or not hasattr(image, "file"):
return image # unchanged, already on disk
try:
with Image.open(image.file) as img:
img.verify() # raises if the bytes are not an image
except (UnidentifiedImageError, OSError) as exc:
raise ValidationError("That file is not a readable image.") from exc
image.file.seek(0) # verify() consumed the stream
return image
A file called avatar.php or evil.html in
MEDIA_ROOT is dangerous if your web server will run it or if the browser
renders it on your domain. Serve media with a static handler only, and prefer a separate
domain or bucket for user content.
A 6 MB phone photo on a list page is a bad experience. Generate a small copy once, when the post is saved, and store its path in a second field.
from io import BytesIO
from django.core.files.base import ContentFile
from PIL import Image, ImageOps
THUMB_SIZE = (400, 300)
class Post(models.Model):
...
image = models.ImageField(upload_to=post_image_path, blank=True)
thumbnail = models.ImageField(upload_to="posts/thumbs/", blank=True, editable=False)
def save(self, *args, **kwargs):
super().save(*args, **kwargs) # the original must exist first
if self.image and not self.thumbnail:
self._make_thumbnail()
super().save(update_fields=["thumbnail"])
def _make_thumbnail(self):
with Image.open(self.image) as img:
img = ImageOps.exif_transpose(img).convert("RGB")
img.thumbnail(THUMB_SIZE)
buffer = BytesIO()
img.save(buffer, format="WEBP", quality=80)
self.thumbnail.save(
f"{Path(self.image.name).stem}-thumb.webp",
ContentFile(buffer.getvalue()),
save=False,
)
Two details matter. exif_transpose honours the rotation flag phones write, so
portrait photos do not come out sideways. save=False on the inner call stops
the recursion into Post.save().
For anything more elaborate - several sizes, cropping, regeneration when the source changes
- reach for django-imagekit or easy-thumbnails instead of growing
this method, and move the work into a background task (module 9) once it starts to be felt
in the request.
Since Django 1.3 the file is intentionally left on disk when the row disappears, so a rolled-back transaction cannot destroy data. Clean up explicitly if you want to:
from django.db.models.signals import post_delete
from django.dispatch import receiver
@receiver(post_delete, sender=Post)
def delete_post_files(sender, instance, **kwargs):
for field in ("image", "thumbnail"):
file = getattr(instance, field)
if file:
file.delete(save=False)
MEDIA_URL and MEDIA_ROOT, wire the development URL
pattern, and add /media/ to .gitignore.image to Post with an upload_to callable,
install Pillow, migrate, and upload a picture through your edit page.enctype from the form tag and observe that the upload
silently does nothing. Put it back..txt renamed to .jpg.SimpleUploadedFile and
override_settings(MEDIA_ROOT=tempfile.mkdtemp()) so the suite never writes
into your real media directory.