Modern web applications demand smooth, responsive file upload experiences. Users expect drag-and-drop functionality, real-time progress feedback, and instant results without page refreshes. While JavaScript frameworks like React or Vue can deliver this, there's a simpler path: combining Django's robust backend with HTMX's lightweight interactivity.
In this guide, we'll build a production-ready file upload system featuring drag-and-drop zones, progress bars, and asynchronous processing pipelines using Celery. By the end, you'll have a complete solution that handles everything from client-side interactions to background task processing.
Why Django + HTMX?
Before diving into code, let's understand why this stack works so well:
Django provides mature file handling, security features, and ORM capabilities out of the box. Its form system makes validation and error handling straightforward.
HTMX enables dynamic interactions with minimal JavaScript. Instead of writing complex frontend code, you write HTML on the server and let HTMX handle the AJAX magic. This reduces your codebase size and keeps logic centralized.
Celery handles time-consuming tasks asynchronously, preventing uploads from blocking your web workers. This is crucial for processing large files or performing intensive operations like image manipulation or video transcoding.
Project Setup
Let's start with a fresh Django project and install our dependencies:
pip install django htmx django-htmx celery redis pillow
django-admin startproject fileupload_project
cd fileupload_project
python manage.py startapp uploadsConfigure your settings.py:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_htmx',
'uploads',
]
MIDDLEWARE = [
# ... other middleware
'django_htmx.middleware.HtmxMiddleware',
]
# File upload settings
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Celery settings
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'Create a celery.py file in your project root:
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'fileupload_project.settings')
app = Celery('fileupload_project')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()Building the Upload Model
Our model needs to track upload status, handle file storage, and store metadata:
# uploads/models.py
from django.db import models
from django.contrib.auth.models import User
import uuid
class FileUpload(models.Model):
STATUS_CHOICES = [
('pending', 'Pending'),
('processing', 'Processing'),
('completed', 'Completed'),
('failed', 'Failed'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE)
file = models.FileField(upload_to='uploads/%Y/%m/%d/')
original_filename = models.CharField(max_length=255)
file_size = models.BigIntegerField()
mime_type = models.CharField(max_length=100)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
progress = models.IntegerField(default=0)
error_message = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
processed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.original_filename} - {self.status}"Run migrations:
python manage.py makemigrations
python manage.py migrateCreating the Upload Form
Django forms handle validation and security automatically:
# uploads/forms.py
from django import forms
from .models import FileUpload
class FileUploadForm(forms.ModelForm):
class Meta:
model = FileUpload
fields = ['file']
widgets = {
'file': forms.FileInput(attrs={
'class': 'file-input',
'accept': 'image/*,.pdf,.doc,.docx',
'hx-post': '/upload/',
'hx-encoding': 'multipart/form-data',
'hx-target': '#upload-results',
'hx-swap': 'beforeend',
})
}
def clean_file(self):
file = self.cleaned_data.get('file')
if file:
# Validate file size (10MB limit)
if file.size > 10 * 1024 * 1024:
raise forms.ValidationError('File size cannot exceed 10MB')
# Validate file type
allowed_types = ['image/jpeg', 'image/png', 'image/gif',
'application/pdf', 'application/msword']
if file.content_type not in allowed_types:
raise forms.ValidationError('File type not supported')
return fileBuilding the Views
Our views handle both initial page loads and HTMX requests:
# uploads/views.py
from django.shortcuts import render, get_object_or_404
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
from .forms import FileUploadForm
from .models import FileUpload
from .tasks import process_file_upload
import mimetypes
@login_required
def upload_page(request):
form = FileUploadForm()
recent_uploads = FileUpload.objects.filter(user=request.user)[:10]
return render(request, 'uploads/upload_page.html', {
'form': form,
'recent_uploads': recent_uploads
})
@login_required
@require_http_methods(["POST"])
def upload_file(request):
form = FileUploadForm(request.POST, request.FILES)
if form.is_valid():
upload = form.save(commit=False)
upload.user = request.user
upload.original_filename = request.FILES['file'].name
upload.file_size = request.FILES['file'].size
upload.mime_type = request.FILES['file'].content_type
upload.save()
# Trigger async processing
process_file_upload.delay(str(upload.id))
return render(request, 'uploads/upload_item.html', {
'upload': upload
})
return render(request, 'uploads/upload_error.html', {
'errors': form.errors
})
@login_required
def upload_progress(request, upload_id):
upload = get_object_or_404(FileUpload, id=upload_id, user=request.user)
return render(request, 'uploads/progress_bar.html', {
'upload': upload
})Asynchronous Processing with Celery
Celery tasks handle intensive operations without blocking web requests:
# uploads/tasks.py
from celery import shared_task
from django.utils import timezone
from .models import FileUpload
from PIL import Image
import time
@shared_task
def process_file_upload(upload_id):
try:
upload = FileUpload.objects.get(id=upload_id)
upload.status = 'processing'
upload.save()
# Simulate processing steps
for i in range(0, 101, 10):
time.sleep(0.5) # Simulate work
upload.progress = i
upload.save()
# If it's an image, create thumbnail
if upload.mime_type.startswith('image/'):
create_thumbnail(upload)
upload.status = 'completed'
upload.processed_at = timezone.now()
upload.progress = 100
upload.save()
except Exception as e:
upload.status = 'failed'
upload.error_message = str(e)
upload.save()
def create_thumbnail(upload):
"""Create thumbnail for image uploads"""
try:
img = Image.open(upload.file.path)
img.thumbnail((200, 200))
thumb_path = upload.file.path.replace('.', '_thumb.')
img.save(thumb_path)
except Exception as e:
print(f"Thumbnail creation failed: {e}")Frontend: HTML Templates with HTMX
The magic happens in our templates. HTMX attributes turn regular HTML into interactive components:
<!-- templates/uploads/upload_page.html -->
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>File Upload</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<link rel="stylesheet" href="{% static 'uploads/style.css' %}">
</head>
<body>
<div class="container">
<h1>Upload Files</h1>
<!-- Drag and Drop Zone -->
<div class="drop-zone" id="drop-zone">
<svg class="upload-icon" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
<p>Drag files here or click to browse</p>
<form id="upload-form"
hx-post="{% url 'upload_file' %}"
hx-encoding="multipart/form-data"
hx-target="#upload-results"
hx-swap="beforeend">
{% csrf_token %}
{{ form.file }}
</form>
</div>
<!-- Upload Results -->
<div id="upload-results" class="upload-results">
{% for upload in recent_uploads %}
{% include 'uploads/upload_item.html' %}
{% endfor %}
</div>
</div>
<script src="{% static 'uploads/drag-drop.js' %}"></script>
</body>
</html>
<!-- templates/uploads/upload_item.html -->
<div class="upload-item" id="upload-{{ upload.id }}">
<div class="upload-info">
<strong>{{ upload.original_filename }}</strong>
<span class="file-size">{{ upload.file_size|filesizeformat }}</span>
</div>
<div class="progress-container"
hx-get="{% url 'upload_progress' upload.id %}"
hx-trigger="every 1s"
hx-swap="outerHTML">
{% include 'uploads/progress_bar.html' %}
</div>
</div>
<!-- templates/uploads/progress_bar.html -->
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill status-{{ upload.status }}"
style="width: {{ upload.progress }}%">
</div>
</div>
<div class="status-text">
{% if upload.status == 'completed' %}
✓ Complete
{% elif upload.status == 'failed' %}
✗ Failed: {{ upload.error_message }}
{% else %}
{{ upload.progress }}% - {{ upload.status }}
{% endif %}
</div>
</div>Adding Drag-and-Drop Functionality
A small JavaScript file enhances the upload zone with drag-and-drop:
// static/uploads/drag-drop.js
document.addEventListener('DOMContentLoaded', function() {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.querySelector('.file-input');
// Click to browse
dropZone.addEventListener('click', () => fileInput.click());
// Drag and drop handlers
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.remove('drag-over');
});
});
dropZone.addEventListener('drop', function(e) {
const files = e.dataTransfer.files;
fileInput.files = files;
// Trigger HTMX form submission
htmx.trigger('#upload-form', 'submit');
});
});Styling the Interface
Clean, modern CSS makes the upload experience intuitive:
/* static/uploads/style.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
color: white;
text-align: center;
margin-bottom: 40px;
font-size: 2.5em;
}
.drop-zone {
background: white;
border: 3px dashed #cbd5e0;
border-radius: 12px;
padding: 60px 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 40px;
}
.drop-zone:hover,
.drop-zone.drag-over {
border-color: #667eea;
background: #f7fafc;
transform: translateY(-2px);
}
.upload-icon {
width: 64px;
height: 64px;
fill: #667eea;
margin-bottom: 20px;
}
.drop-zone p {
color: #4a5568;
font-size: 1.1em;
}
.file-input {
display: none;
}
.upload-results {
display: flex;
flex-direction: column;
gap: 16px;
}
.upload-item {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.upload-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.file-size {
color: #718096;
font-size: 0.9em;
}
.progress-container {
margin-top: 12px;
}
.progress-bar {
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
transition: width 0.3s ease;
border-radius: 4px;
}
.status-pending { background: #fbbf24; }
.status-processing { background: #3b82f6; }
.status-completed { background: #10b981; }
.status-failed { background: #ef4444; }
.status-text {
margin-top: 8px;
font-size: 0.9em;
color: #4a5568;
}URL Configuration
Wire everything together in your URLs:
# uploads/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.upload_page, name='upload_page'),
path('upload/', views.upload_file, name='upload_file'),
path('progress/<uuid:upload_id>/', views.upload_progress, name='upload_progress'),
]
# fileupload_project/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('uploads.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)Running the Application
Start your services in separate terminals:
# Terminal 1: Django development server
python manage.py runserver
# Terminal 2: Redis (if not running as service)
redis-server
# Terminal 3: Celery worker
celery -A fileupload_project worker --loglevel=infoAdvanced Features to Consider
Once you have the basics working, consider these enhancements:
Chunked Uploads: For large files, implement chunked uploads to handle network interruptions and show accurate progress. Libraries like django-chunked-upload make this straightforward.
Multiple File Selection: Modify the form to accept multiple files and process them in parallel using Celery's group tasks.
Cloud Storage: Integrate with S3 or Google Cloud Storage for scalable file storage. Django-storages makes this configuration simple.
Virus Scanning: Add ClamAV integration to scan uploaded files for malware before processing.
Image Optimization: Automatically compress and optimize images using Pillow or specialized services like TinyPNG's API.
Retry Logic: Implement exponential backoff for failed uploads, giving temporary network issues a chance to resolve.
Security Considerations
File uploads are a common attack vector. Protect your application by:
- Always validating file types on the server, never trusting client-side validation alone
- Scanning uploaded files for malware before processing
- Storing files outside your web root to prevent direct execution
- Using UUIDs for filenames to prevent enumeration attacks
- Implementing rate limiting to prevent abuse
- Setting strict file size limits
- Validating file content matches the declared type
- Using Django's built-in CSRF protection
Performance Optimization
As your application scales, keep these optimizations in mind:
Database Indexing: Add indexes to frequently queried fields like status and created_at.
Caching: Use Redis to cache upload status checks, reducing database queries.
CDN Integration: Serve processed files through a CDN for faster delivery.
Connection Pooling: Configure database connection pooling for better Celery performance.
Task Prioritization: Use Celery's priority queues to handle urgent uploads first.
Conclusion
You now have a production-ready file upload system combining Django's reliability, HTMX's simplicity, and Celery's power. This architecture handles everything from drag-and-drop interactions to background processing without requiring a complex JavaScript framework.
The beauty of this approach lies in its simplicity. HTMX lets you build interactive experiences with minimal JavaScript, while Django and Celery handle the heavy lifting on the server. You get modern UX without the complexity of a full JavaScript framework.
From here, you can extend this foundation with features like image galleries, document previews, collaborative editing, or integration with third-party services. The patterns you've learned apply to any scenario requiring asynchronous processing and real-time feedback.
Start simple, test thoroughly, and scale gradually. Your users will appreciate the smooth, responsive upload experience, and you'll appreciate the maintainable, Django-centric architecture.