File uploads en DRF con MultiPartParser y FileField

Avanzado
Django
Django
Actualizado: 19/04/2026

Configuración mínima

Subir archivos requiere que la vista acepte el content-type multipart/form-data:

from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework import viewsets

class DocumentoViewSet(viewsets.ModelViewSet):
    queryset = Documento.objects.all()
    serializer_class = DocumentoSerializer
    parser_classes = [MultiPartParser, FormParser]

Sin MultiPartParser, DRF devuelve 415 Unsupported Media Type cuando el cliente envía un formulario con archivo.

Modelo y Serializer

# models.py
from django.db import models

def documento_upload_path(instance, filename):
    # Organiza en carpetas por tenant y mes
    return f'documentos/{instance.tenant_id}/{instance.fecha.year}/{instance.fecha.month}/{filename}'

class Documento(models.Model):
    nombre = models.CharField(max_length=200)
    archivo = models.FileField(upload_to=documento_upload_path)
    tipo_mime = models.CharField(max_length=100, blank=True)
    tamano = models.PositiveIntegerField(default=0)
    subido_en = models.DateTimeField(auto_now_add=True)
    tenant = models.ForeignKey('Tenant', on_delete=models.CASCADE)
# serializers.py
class DocumentoSerializer(serializers.ModelSerializer):
    archivo = serializers.FileField()
    url = serializers.SerializerMethodField()

    class Meta:
        model = Documento
        fields = ['id', 'nombre', 'archivo', 'url', 'tipo_mime', 'tamano', 'subido_en']
        read_only_fields = ['tipo_mime', 'tamano', 'subido_en', 'url']

    def get_url(self, obj):
        request = self.context.get('request')
        if obj.archivo and request:
            return request.build_absolute_uri(obj.archivo.url)
        return None

El cliente envía multipart/form-data:

curl -X POST http://localhost:8000/api/documentos/ \
  -H "Authorization: Bearer $TOKEN" \
  -F "nombre=Factura Abril" \
  -F "archivo=@factura.pdf"

Validaciones

Tamaño máximo

from django.core.validators import FileExtensionValidator
from rest_framework import serializers

class DocumentoSerializer(serializers.ModelSerializer):
    archivo = serializers.FileField(
        validators=[FileExtensionValidator(allowed_extensions=['pdf', 'docx', 'png', 'jpg'])],
    )

    def validate_archivo(self, value):
        max_bytes = 10 * 1024 * 1024  # 10 MB
        if value.size > max_bytes:
            raise serializers.ValidationError(
                f'El archivo excede el tamano maximo ({max_bytes // 1024 // 1024} MB).'
            )
        return value

Validación de tipo MIME real (no solo extensión)

La extensión se puede falsificar. Para validar el contenido real:

pip install python-magic
import magic

def validate_archivo(self, value):
    # Lee los primeros bytes para detectar MIME
    value.seek(0)
    mime = magic.from_buffer(value.read(2048), mime=True)
    value.seek(0)

    permitidos = ['application/pdf', 'image/png', 'image/jpeg']
    if mime not in permitidos:
        raise serializers.ValidationError(f'Tipo no permitido: {mime}')
    return value

Detección de malware

Para aplicaciones sensibles, integra ClamAV antes de guardar:

import pyclamd

def validate_archivo(self, value):
    cd = pyclamd.ClamdUnixSocket()
    value.seek(0)
    result = cd.scan_stream(value.read())
    value.seek(0)
    if result:
        raise serializers.ValidationError('Archivo detectado como virus.')
    return value

Almacenamiento local

Por defecto, Django guarda en MEDIA_ROOT:

# settings.py
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

En urls.py (solo en desarrollo):

from django.conf import settings
from django.conf.urls.static import static

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

Nunca uses disco local en producción con múltiples workers/servidores: cada uno ve su propio disco.

Almacenamiento en S3 con django-storages

pip install django-storages boto3
# settings.py
INSTALLED_APPS = [..., 'storages']

STORAGES = {
    'default': {
        'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage',
    },
    'staticfiles': {
        'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage',
    },
}

AWS_ACCESS_KEY_ID = os.environ['AWS_ACCESS_KEY_ID']
AWS_SECRET_ACCESS_KEY = os.environ['AWS_SECRET_ACCESS_KEY']
AWS_STORAGE_BUCKET_NAME = 'mi-bucket-uploads'
AWS_S3_REGION_NAME = 'eu-west-1'
AWS_S3_SIGNATURE_VERSION = 's3v4'
AWS_DEFAULT_ACL = None  # no publicar por defecto
AWS_QUERYSTRING_AUTH = True  # URLs firmadas
AWS_QUERYSTRING_EXPIRE = 3600  # 1 hora de validez

Con esto, FileField.save() sube directamente a S3. Las URLs generadas son firmadas con TTL. El código de la app no cambia (Django abstrae el storage).

Servir archivos privados con URLs firmadas

S3 con AWS_DEFAULT_ACL = None significa bucket privado. Las URLs firmadas permiten acceso temporal:

from django.utils.functional import cached_property

class Documento(models.Model):
    archivo = models.FileField(upload_to='documentos/')

    @cached_property
    def url_firmada(self):
        # django-storages genera URL firmada automaticamente al acceder a .url
        return self.archivo.url

El link resultante:

https://mi-bucket.s3.eu-west-1.amazonaws.com/documentos/factura.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=...

Pasa 1 hora y deja de funcionar. Ideal para documentos confidenciales.

Upload directo desde el frontend a S3

Para archivos grandes (>100 MB), no pases por el servidor Django. Usa presigned POST URL generado en el backend:

# views.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
import boto3

@api_view(['POST'])
def generar_presigned_post(request):
    filename = request.data['filename']
    s3 = boto3.client('s3')
    presigned = s3.generate_presigned_post(
        Bucket='mi-bucket',
        Key=f'documentos/{request.user.id}/{filename}',
        Fields={'acl': 'private'},
        Conditions=[
            {'acl': 'private'},
            ['content-length-range', 0, 100 * 1024 * 1024],  # max 100 MB
        ],
        ExpiresIn=3600,
    )
    return Response(presigned)

El frontend recibe {url, fields}, arma el formulario multipart y lo envía directamente a S3. Tu Django no ve el archivo, solo la ruta final. Escalabilidad máxima.

Progreso y streaming de upload

Para uploads con barra de progreso, el frontend muestra XHR upload events. Por el lado del servidor, no hay problema: multipart es streamable.

Si el archivo es gigante y quieres procesarlo chunk a chunk sin cargarlo completo en memoria:

def validate_archivo(self, value):
    # value es un InMemoryUploadedFile o TemporaryUploadedFile
    # Para archivos > FILE_UPLOAD_MAX_MEMORY_SIZE (default 2.5MB), Django ya lo escribe a disco temporal
    for chunk in value.chunks(chunk_size=1024 * 1024):
        # procesar chunk
        pass
    value.seek(0)
    return value

FILE_UPLOAD_MAX_MEMORY_SIZE configura cuándo Django pasa de memoria a disco temporal.

Borrado del archivo cuando se borra el modelo

Por defecto, borrar una fila no borra el archivo del storage:

# signals.py
from django.db.models.signals import post_delete
from django.dispatch import receiver

@receiver(post_delete, sender=Documento)
def borrar_archivo(sender, instance, **kwargs):
    if instance.archivo:
        instance.archivo.delete(save=False)

O usar la librería django-cleanup que lo hace automáticamente para todos los FileField.

Thumbnails e imágenes optimizadas

Para imágenes, Pillow permite redimensionar al subir:

from PIL import Image
from io import BytesIO
from django.core.files.uploadedfile import InMemoryUploadedFile

class AvatarSerializer(serializers.ModelSerializer):
    avatar = serializers.ImageField()

    def validate_avatar(self, value):
        img = Image.open(value)
        img.thumbnail((500, 500))  # max 500x500 manteniendo aspect ratio

        buffer = BytesIO()
        img.save(buffer, format='JPEG', quality=85)
        buffer.seek(0)

        return InMemoryUploadedFile(
            buffer, None, value.name, 'image/jpeg', buffer.getbuffer().nbytes, None
        )

Para casos más complejos (múltiples thumbnails, conversión automática), django-imagekit:

pip install django-imagekit
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit

class Perfil(models.Model):
    foto = models.ImageField(upload_to='perfiles/')
    foto_thumbnail = ImageSpecField(
        source='foto',
        processors=[ResizeToFit(200, 200)],
        format='JPEG',
        options={'quality': 85},
    )

Testing de uploads

from django.core.files.uploadedfile import SimpleUploadedFile

class DocumentoUploadTest(APITestCase):
    def test_subir_pdf(self):
        pdf = SimpleUploadedFile(
            'test.pdf',
            b'%PDF-1.4 fake content',
            content_type='application/pdf',
        )
        response = self.client.post('/api/documentos/', {
            'nombre': 'Test',
            'archivo': pdf,
        }, format='multipart')
        self.assertEqual(response.status_code, 201)
        self.assertTrue(Documento.objects.filter(nombre='Test').exists())

    def test_rechazar_archivo_demasiado_grande(self):
        big = SimpleUploadedFile('big.bin', b'x' * (11 * 1024 * 1024), content_type='application/octet-stream')
        response = self.client.post('/api/documentos/', {'nombre': 'Big', 'archivo': big})
        self.assertEqual(response.status_code, 400)

Checklist de producción

  • [ ] parser_classes = [MultiPartParser, FormParser] en el ViewSet.
  • [ ] Validación de tamaño máximo.
  • [ ] Validación de tipo MIME real (no solo extensión).
  • [ ] Escáner antivirus en archivos de usuarios no confiables.
  • [ ] Storage en S3/GCS/Azure (no disco local).
  • [ ] URLs firmadas con TTL limitado para archivos privados.
  • [ ] Upload directo frontend→S3 para archivos grandes (>50 MB).
  • [ ] Borrado del archivo al borrar el modelo.
  • [ ] Thumbnails/optimización automática para imágenes.
  • [ ] Límite de subidas por usuario (rate limit o quota).
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Django es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Django

Explora más contenido relacionado con Django y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Configurar parser_classes para multipart/form-data. Declarar FileField o ImageField en el serializer. Guardar archivos con upload_to y el storage configurado. Subir directamente a S3 con django-storages + AWS_STORAGE_BUCKET_NAME. Validar tamano maximo y tipo MIME. Servir archivos privados con URLs firmadas presigned_url.

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje