Django Performance Review

TalentFlow SaaS  ·  Django 4.2 + DRF 3.14 + PostgreSQL 15  ·  Generado por CULTIVA IA  ·  16 jun 2026

4 Críticos 3 Altos 1 Bajo
8
Hallazgos totales
4
Críticos
3
Altos
1
Bajos
3
Archivos revisados
1

Consultas N+1 — CRÍTICO

4 hallazgos
PERF-001
N+1 triple en ApplicationSerializer (3 queries/fila)
CRÍTICO serializers.py : ApplicationSerializer
Los tres SerializerMethodField acceden a obj.candidate y a obj.candidate.applications sin ningún select_related ni anotación previa en el queryset. Para una lista de 100 aplicaciones se generan entre 300 y 400 queries adicionales.
  • Flujo: ApplicationViewSet.get_queryset() → Application.objects.filter(job_id) → ApplicationSerializer → get_candidate_name / get_candidate_email / get_application_count
  • Sin optimización: no existe select_related ni prefetch_related en get_queryset()
  • Volumen: tabla jobs_application con 1.400.000 filas; un proceso de selección típico tiene 200-500 aplicaciones
  • Hot path: endpoint /api/jobs/{id}/applications/ usado por los reclutadores constantemente en hora pico
# serializers.py — 3 queries por cada Application en el listado
class ApplicationSerializer(serializers.ModelSerializer):
    def get_candidate_name(self, obj):
        return obj.candidate.full_name     # ← query N+1

    def get_candidate_email(self, obj):
        return obj.candidate.email         # ← query N+1 (mismo rel.)

    def get_application_count(self, obj):
        return obj.candidate.applications.count()  # ← query N+1
✗ Antes — views.py
def get_queryset(self):
    job_id = self.kwargs.get('job_pk')
    return Application.objects
        .filter(job_id=job_id)
        .order_by('-created')
✓ Después — views.py
def get_queryset(self):
    job_id = self.kwargs.get('job_pk')
    return Application.objects
        .filter(job_id=job_id)
        .select_related('candidate')
        .annotate(
            app_count=Count('candidate__applications')
        )
        .order_by('-created')
✓ Después — serializers.py
class ApplicationSerializer(serializers.ModelSerializer):
    candidate_name  = serializers.CharField(source='candidate.full_name', read_only=True)
    candidate_email = serializers.EmailField(source='candidate.email',    read_only=True)
    application_count = serializers.IntegerField(read_only=True)  # viene de la anotación
Impacto estimado: Para 200 aplicaciones: de ~600 queries a 2 queries (filter + join). Reducción de latencia esperada: de 3-8 s a <50 ms.
PERF-002
N+1 triple en JobSerializer (company + applications × 2)
CRÍTICO serializers.py : JobSerializer
get_company_name accede a obj.company.name sin select_related. get_application_count lanza un COUNT por oferta. get_latest_applicant lanza dos queries por oferta: una para obtener la última aplicación y otra para acceder a app.candidate.full_name. Para 50 empleos de una empresa: ~200 queries extra.
# serializers.py — 4 queries por cada Job
def get_company_name(self, obj):
    return obj.company.name                           # ← N+1

def get_application_count(self, obj):
    return obj.applications.count()                   # ← N+1

def get_latest_applicant(self, obj):
    app = obj.applications.order_by('-created').first()  # ← N+1
    if app:
        return app.candidate.full_name               # ← N+1 adicional
# views.py — un único queryset optimizado
def get_queryset(self):
    from django.db.models import Count, Subquery, OuterRef
    latest_app_qs = (Application.objects
        .filter(job=OuterRef('pk'))
        .select_related('candidate')
        .order_by('-created')
        .values('candidate__full_name')[:1]
    )
    return (Job.objects
        .filter(company=self.request.user.company)
        .select_related('company')
        .annotate(
            application_count=Count('applications'),
            latest_applicant=Subquery(latest_app_qs),
        )
        .order_by('-published_at')
    )
Impacto estimado: Para 50 ofertas: de ~200 queries a 2 queries. Endpoint /api/jobs/ bajo con 3.000 req/h: reducción estimada de 70-85% en tiempo de respuesta.
2

Querysets sin paginar — CRÍTICO

2 hallazgos
PERF-003
JobViewSet sin paginación — 85.000 filas potenciales en RAM
CRÍTICO views.py : JobViewSet.get_queryset()
JobViewSet no declara pagination_class ni paginate_by. Si el DEFAULT_PAGINATION_CLASS del proyecto no está configurado en settings, el endpoint devuelve todos los empleos de la empresa en una sola respuesta, serializando y transfiriendo un JSON potencialmente de cientos de miles de registros.
  • JobViewSet hereda de ModelViewSet sin pagination_class
  • Tabla jobs_job: 85.000 filas en producción
  • Endpoint de alto tráfico: 3.000 req/hora
# views.py
from rest_framework.pagination import PageNumberPagination

class JobPagination(PageNumberPagination):
    page_size = 25
    page_size_query_param = 'page_size'
    max_page_size = 100

class JobViewSet(viewsets.ModelViewSet):
    serializer_class = JobSerializer
    pagination_class = JobPagination   # ← añadir
    ...
PERF-004
ApplicationViewSet sin paginación — 1.4M filas potenciales
CRÍTICO views.py : ApplicationViewSet.get_queryset()
Mismo patrón que PERF-003 pero sobre jobs_application (1.400.000 filas). Un proceso viral con 50.000 candidatos haría un OOM kill del worker Gunicorn.
class ApplicationPagination(PageNumberPagination):
    page_size = 50
    max_page_size = 200

class ApplicationViewSet(viewsets.ModelViewSet):
    pagination_class = ApplicationPagination  # ← añadir
    ...
3

Índices faltantes — ALTO

2 hallazgos
PERF-005
Índice faltante en Job.status, Job.published_at y Candidate.email
ALTO models.py : Job, Candidate
Job.status y Job.published_at son usados en filterset_fields y en order_by('-published_at') sin índice. Candidate.email se usa frecuentemente para buscar duplicados durante la importación CSV. Con 85.000 y 210.000 filas respectivamente, son full table scans.
class Job(models.Model):
    status       = models.CharField(max_length=20, default='draft', db_index=True)  # ← añadir
    published_at = models.DateTimeField(null=True, blank=True, db_index=True)       # ← añadir

    class Meta:
        indexes = [
            models.Index(fields=['company', 'status']),        # patrón filter más frecuente
            models.Index(fields=['status', '-published_at']),  # orden frecuente
        ]

class Candidate(models.Model):
    email = models.EmailField(db_index=True)  # ← añadir; deduplicación en import
PERF-006
Índice compuesto faltante en Application(job, stage)
ALTO models.py : Application
El patrón filter(job_id=job_pk, stage='applied') (usado en bulk_reject) y filter(job_id=job_pk).order_by('-created') (ApplicationViewSet) no tienen índice compuesto. Con 1.4M filas, PostgreSQL hace un seq scan o usa solo el FK de job sin beneficiarse del filtro por stage.
class Application(models.Model):
    class Meta:
        indexes = [
            models.Index(fields=['job', 'stage']),       # bulk_reject filter
            models.Index(fields=['job', '-created']),    # listado ordenado
            models.Index(fields=['candidate', '-created']),  # perfil del candidato
        ]
4

Bucles de escritura — ALTO

1 hallazgo
PERF-007
bulk_reject y import_candidates: N saves y N creates en bucle
ALTO views.py : ApplicationViewSet.bulk_reject / import_candidates
bulk_reject llama a app.save() por cada aplicación en stage 'applied'. import_candidates llama a Candidate.objects.create() y luego Application.objects.create() por cada fila del CSV, generando 2N round trips. Para un CSV de 500 candidatos: 1.000 inserts individuales.
  • Bucle sobre queryset sin límite (todas las applications en 'applied')
  • Cada iteración llama a save() / create()
  • Endpoint user-facing (no job de mantenimiento): bloqueo perceptible
✗ Antes
def bulk_reject(self, ...):
    apps = Application.objects.filter(
        job_id=job_pk, stage='applied'
    )
    for app in apps:
        app.stage = 'rejected'
        app.save()   # ← N UPDATEs
✓ Después
def bulk_reject(self, ...):
    updated = Application.objects.filter(
        job_id=job_pk, stage='applied'
    ).update(stage='rejected')
    # 1 sola query UPDATE
    return Response({'updated': updated})
✗ Antes — import_candidates
for row in data:
    c = Candidate.objects.create(
        email=row['email'],
        full_name=row['full_name'],
    )
    Application.objects.create(
        job_id=job_pk,
        candidate=c,
    )    # ← 2N inserts
✓ Después — import_candidates
candidates = Candidate.objects.bulk_create(
    [Candidate(email=r['email'],
               full_name=r['full_name'])
     for r in data],
    ignore_conflicts=True,
)
Application.objects.bulk_create(
    [Application(job_id=job_pk, candidate=c)
     for c in candidates],
    batch_size=500,
)  # ← 2 inserts batch
Impacto estimado: Para 500 candidatos en CSV: de 1.000 inserts a 2 inserts batch. Tiempo de respuesta de ~8 s a <300 ms.
5

Patrones ineficientes — BAJO

1 hallazgo
PERF-008
Application.candidate_email como property hace query si no hay select_related
BAJO models.py : Application.candidate_email
La property candidate_email en el modelo accede a self.candidate.email, lo que dispara una query si la instancia se obtiene sin select_related('candidate'). No es crítico porque el uso directo no se detecta en loops de alto volumen (la corrección de PERF-001 ya cubre el caso de riesgo), pero es un accidente waiting to happen si se usa en nuevas vistas sin optimización.
# Documentar explícitamente el requisito de select_related
class Application(models.Model):
    @property
    def candidate_email(self):
        """Accede a candidate FK. Requiere select_related('candidate') en el queryset."""
        return self.candidate.email
6

Áreas sin problemas detectados

Archivos de migraciones, tests, management commands y admin — excluidos del scope según criterios de la revisión. Los ForeignKey existentes (Job→Company, Application→Job, Application→Candidate) tienen sus índices automáticos de FK: no se necesitan índices adicionales en esas columnas. Los accesos a modelos individuales (single-object fetch) con 2 queries no se reportan como N+1.