Saltar al contenido
🐍 Curso de Python
/

Módulo 4 · Lección 29 de 45

Subida de ficheros

Dos atributos que hay que poner o no llega nada, los ficheros huérfanos que nadie borra, y por qué un fichero subido es siempre sospechoso.

⏱ 16 min de lectura

La lección 20 separó dos cosas que se confunden: los estáticos, que escribes tú y viajan con el código, y los ficheros subidos, que aparecen mientras el sitio funciona. Aquellos ya están; estos son los de ahora.

Subir un fichero se parece a cualquier otro campo y tiene cuatro diferencias que muerden. Los datos no llegan por donde tú crees, hay dos atributos sin los cuales no pasa nada y no salta ningún error, los ficheros no se borran cuando se borra la fila, y un fichero es lo más peligroso que se puede aceptar de alguien desconocido.

Los ajustes#

No vienen en settings.py, como se midió en la lección 20:

agenda/settings.py
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'subidos'

Los mismos dos conceptos que en los estáticos: MEDIA_URL es el prefijo de las direcciones y MEDIA_ROOT la carpeta del disco. La diferencia es que aquí los dos hacen falta desde el primer día, porque sin MEDIA_ROOT no hay dónde guardar.

Y como con los estáticos, runserver no los sirve por su cuenta. Hay que decírselo, solo en desarrollo:

agenda/urls.py
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)
Salida en pantalla
/media/fotos/2026/09/mi_foto.png   ->  200
/media/no-existe.png               ->  404

Ese if settings.DEBUG no es una precaución de estilo: esa forma de servir ficheros es insegura y lenta, y por eso Django la desactiva sola en producción. Allí lo hace el servidor de delante, igual que con los estáticos.

Los campos#

contactos/models.py
foto = models.ImageField('foto', upload_to='fotos/%Y/%m/', blank=True)
adjunto = models.FileField('adjunto', upload_to='adjuntos/', blank=True)

upload_to es la subcarpeta dentro de MEDIA_ROOT, y admite trozos de fecha que se sustituyen al guardar. Repartir por año y mes no es cosmético: una carpeta con cincuenta mil ficheros es lenta de listar en casi cualquier sistema.

Cuando la ruta depende del objeto, se pone una función:

python
def ruta_foto(instancia, nombre_original):
        return f'fotos/{instancia.pk}/{nombre_original}'

ImageField hace dos cosas de más y necesita Pillow instalado, que es la segunda dependencia del curso:

consola
pip install Pillow
Salida en pantalla
foto      <input type="file" name="foto" accept="image/*" id="id_foto">
adjunto   <input type="file" name="adjunto" id="id_adjunto">

La primera es ese accept, que hace que el navegador filtre. La segunda es que comprueba que el fichero es de verdad una imagen:

Salida en pantalla
un fichero llamado truco.png con el texto "no soy una imagen" dentro:
válido: False
{'foto': ['Envíe una imagen válida. El fichero que ha enviado no era una imagen
          o se trataba de una imagen corrupta.']}

Fíjate en que lo ha pillado pese al nombre y al tipo declarado. Eso es porque abre el fichero y lo mira, en lugar de fiarse de lo que dice. Es la única validación de contenido que Django trae de serie.

Los dos atributos que hay que poner#

Aquí está el fallo que se comete una vez y cuesta media tarde.

html
<form method="post" enctype="multipart/form-data">
python
formulario = ContactoForm(peticion.POST, peticion.FILES)

Sin el enctype, el navegador manda solo los nombres de los ficheros. Sin peticion.FILES, el formulario no mira dónde están.

Y lo peor es cómo falla:

Salida en pantalla
válido sin FILES: True | foto en cleaned_data: None

No hay ningún error. El formulario se valida, se guarda, redirige, y el fichero no está. Con un campo blank=True, que es lo normal en una foto, no se queja nadie.

La regla, y conviene escribirla junto al {% csrf_token %} en la cabeza: si el formulario tiene un fichero, lleva enctype en la plantilla y peticion.FILES en la vista. Las dos cosas, siempre juntas.

En las vistas genéricas de la lección 24 esto ya está hecho: CreateView y UpdateView pasan request.FILES solas. El enctype de la plantilla sigue siendo cosa tuya.

Qué tienes después de guardar#

Salida en pantalla
name  : fotos/2026/09/mi_foto.png
url   : /media/fotos/2026/09/mi_foto.png
path  : /tmp/m2/subidos/fotos/2026/09/mi_foto.png
size  : 122
dims  : 40 x 30

name es la ruta relativa, que es lo único que se guarda en la base de datos: la columna es un texto, no el fichero. url es lo que va en la plantilla, path la ruta del disco, y width y height solo existen en ImageField.

En la plantilla, con la comprobación delante, porque el campo puede estar vacío:

html
{% if contacto.foto %}
  <img src="{{ contacto.foto.url }}" alt="{{ contacto.nombre }}" width="200">
{% endif %}

Sin ese {% if %}, un contacto sin foto genera un <img src="">, que el navegador intenta cargar como la propia página.

Los nombres#

Django no deja los nombres tal cual, y lo que hace conviene conocerlo:

Salida en pantalla
'mi foto.png'  ->  fotos/2026/09/mi_foto.png
el mismo otra vez  ->  fotos/2026/09/mi_foto_fxRQR34.png
y otra vez         ->  fotos/2026/09/mi_foto_OIng1bW.png

Los espacios pasan a guiones bajos, y los nombres repetidos no se pisan: se les añade un sufijo aleatorio. Es una decisión sensata, porque lo contrario sería que la foto de alguien sustituyera la de otro por llamarse foto.png.

Y lo importante:

Salida en pantalla
'../../../etc/passwd.png'  ->  fotos/2026/09/passwd.png

La travesía de rutas está neutralizada: Django se queda solo con la última parte del nombre. No hay que hacer nada, y hay que saberlo para no fiarse en otros sitios: si tú construyes una ruta con algo que venga de fuera, esa protección no está.

Validar#

ImageField comprueba que es una imagen y nada más. El tamaño y la extensión son cosa tuya:

python
from django.core.validators import FileExtensionValidator


class SubirForm(forms.Form):
        documento = forms.FileField(validators=[FileExtensionValidator(['pdf', 'txt'])])

    def clean_documento(self):
                fichero = self.cleaned_data['documento']
        if fichero.size > 5 * 1024 * 1024:
                        raise forms.ValidationError('El fichero no puede pasar de 5 MB.')
        return fichero
Salida en pantalla
extensión mala   -> False  La extensión de archivo "exe" no esta permitida. Las permitidas son: pdf, txt.
demasiado grande -> False  El fichero no puede pasar de 5 MB.
correcto         -> True

Y fíjate en el return fichero del final: es la trampa de la lección 27, y aquí destruiría el fichero entero.

Los límites que ya vienen puestos, medidos:

Salida en pantalla
FILE_UPLOAD_MAX_MEMORY_SIZE:   2 MB
DATA_UPLOAD_MAX_MEMORY_SIZE:   2 MB
DATA_UPLOAD_MAX_NUMBER_FILES:  100
FILE_UPLOAD_PERMISSIONS:       0o644

Los dos primeros no son lo mismo, y confundirlos hace perder tiempo. FILE_UPLOAD_MAX_MEMORY_SIZE no es un límite: es el punto a partir del cual Django deja de tener el fichero en memoria y lo escribe en un temporal. Se puede subir un vídeo de un giga con ese valor. DATA_UPLOAD_MAX_MEMORY_SIZE sí es un límite, pero solo del resto del formulario, sin contar los ficheros.

O sea: de serie no hay ningún límite al tamaño de un fichero subido. Si no lo pones tú, alguien puede llenarte el disco. Y conviene ponerlo también en el servidor de delante, porque validar en Django significa que el fichero ya ha viajado entero.