Django Multiple Image Upload with Dropzone.js

Oct 16, 2020

The easiest solution I have found to upload multiple images has been Dropzone.js. This is the second time I have implemented it in a Django app and I always end up customizing it a bit because I like the idea of only uploading when the user hits "submit." It's relatively straightforward, but required a bit of work in Django, JavaScript (jQuery here), HTML, and CSS.

The Photo model:

class Photo(ObjUserRelation):
    photo = models.ImageField(upload_to=saved_image_path)
    photo_compressed = models.ImageField(upload_to=saved_thumb_path, editable=False)
    thumbnail = models.ImageField(upload_to=saved_thumb_path, editable=False)

    def save(self, *args, **kwargs):

        if not self.make_thumbnail():
            # set to a default thumbnail
            raise Exception('Could not create thumbnail - is the file type valid?')

        if not self.make_thumbnail(small=True):
            # set to a default thumbnail
            raise Exception('Could not create thumbnail - is the file type valid?')

        super(Photo, self).save(*args, **kwargs)

    def make_thumbnail(self, small=False):
        return make_thumbnail(self, small)

I am setting custom locations for the files with:

def saved_directory_path(instance, filename, root):
    now_time =
    current_day =
    current_month = now_time.month
    current_year = now_time.year
    return '{root}/{year}/{month}/{day}/{user}/{random}/{filename}'.format(root=root,
                                                                           filename=filename, )

def saved_image_path(instance, filename):
    return saved_directory_path(instance, filename, 'profile/images')

def saved_thumb_path(instance, filename):
    return saved_directory_path(instance, filename, 'profile/thumbs')

To create thumbnails (for quicker loading), I am using Pillow (pip install Pillow) and call make_thumbnail in Photo's save method:

def make_thumbnail(self, small=False):
    thumb_name, thumb_extension = os.path.splitext(
    thumb_extension = thumb_extension.lower()

    image =

    if small:
        image.thumbnail(settings.THUMB_SIZE, Image.ANTIALIAS)
        thumb_filename = thumb_name + '_thumb' + thumb_extension

        image.thumbnail((700, 700), Image.ANTIALIAS)
        thumb_filename = thumb_name + '_compressed' + thumb_extension

    if thumb_extension in ['.jpg', '.jpeg']:
        FTYPE = 'JPEG'
    elif thumb_extension == '.gif':
        FTYPE = 'GIF'
    elif thumb_extension == '.png':
        FTYPE = 'PNG'
        return False  # Unrecognized file type

    # Save thumbnail to in-memory file as StringIO
    temp_thumb = BytesIO(), FTYPE, quality=70)

    if small:
        # set save=False, otherwise it will run in an infinite loop, ContentFile(, save=False)
    else:, ContentFile(, save=False)

    return True

The View:

The website I created is basically a social media site like Facebook, so I am uploading the images in the post request when the user creates a post:

class Posts(LoginRequiredMixin, views.APIView):
    http_method_names = ['get', 'post', 'delete']

    def post(self, request):
        body = request.POST.get('body', "")
        images = request.FILES.get('file[0]', None)

        if body or images:
            author = request.user
            post = SocialMediaPost()
            post.body = body
   = author
            post.datetime_created =

            files = [request.FILES.get('file[%d]' % i) for i in range(0, len(request.FILES))]

            for image in files:
                photo = Photo(photo=image, user=request.user, obj_type='post',

        return JsonResponse({'message': 'Post created'}, status=200)

If files = [request.FILES.get('file[%d]' % i) for i in range(0, len(request.FILES))] looks a bit strange, that's because of the way Dropzone.js submits the form data. it took me some trial, error, and a lot of debugging to figure out the format.

The template:

My post form is relatively simple; a textarea, the dropzone button, and the submit button. Now that I think of it, I'm not sure if drag and drop works, but you can ignore my customization with the font awesome icon and use the default if you want that.

    <div class="container post-container pt-1">

        <div class="card shadow bg-white mt-3 mb-3">
            <div class="card-body">
                <form action="{% url 'posts' %}" method="POST" class="post-form" enctype="multipart/form-data"
                      id="post-form">{% csrf_token %}
                    <textarea class="form-control" placeholder="What's going on?" id="post-form-body"
                    <div class="row pt-3">
                        <div class="col align-middle">
                            <div class="dropzone dropzone-file-area" id="fileUpload">
                                <div class="dz-default dz-message">
                                    <span id="images" class="far fa-images mb-3" data-toggle="tooltip"
                                          title="Add Photos"></span>
                            <input id="images" name="file" type="file" multiple hidden="hidden">
                        <div class="col-1 ml-auto">
                            <button id="submit-all" type="submit" class="save btn btn-primary float-right">Submit

The Script

Dropzone by default uploads asynchronously as the user adds photos. That's cool, but harder to deal with (what if they don't create the post, how do I keep track of the relationship to the post?), so I decided to upload the images with the post. That unfortunately means I need to prevent default action (.preventDefault()) when the submit button is pressed and build the form data in javascript.

    Dropzone.options.fileUpload = {
        url: '{% url 'posts' %}',
        thumbnailWidth: 80,
        thumbnailHeight: 80,
        dictRemoveFile: "Remove",
        autoProcessQueue: false,
        uploadMultiple: true,
        parallelUploads: 20,
        maxFiles: 20,
        maxFilesize: 20,
        acceptedFiles: ".jpeg,.jpg,.png,.gif",
        addRemoveLinks: true,
        init: function () {
            dzClosure = this; // Makes sure that 'this' is understood inside the functions below.

            // for Dropzone to process the queue (instead of default form behavior):
            document.getElementById("submit-all").addEventListener("click", function (e) {
                // Make sure that the form isn't actually being sent.
                if (dzClosure.getQueuedFiles().length > 0) {
                } else {
                        url: {% url 'posts' %},
                        type: 'POST',
                        dataType: 'json',
                        data: {
                            'body': jQuery("#post-form-body").val(),
                            'csrfmiddlewaretoken': '{{csrf_token}}',
                        beforeSend: function (xhr) {
                            xhr.setRequestHeader("X-CSRFToken", '{{ csrf_token }}');
                        success: function (result) {
                            window.location.replace("{% url 'posts' %}");

            //send all the form data along with the files:
            this.on("sendingmultiple", function (data, xhr, formData) {
                formData.append("body", jQuery("#post-form-body").val());
                formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');

            // On success refresh
            this.on("success", function (file) {
                window.location.replace("{% url 'posts' %}");

I will try to expand on some of the details, but that's the bulk of it. Post a comment if you have any questions.