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
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