# Django y PostgreSQL en 2026: indexación, búsqueda de texto completo y preguntas de entrevista > Guía práctica de optimización de Django con PostgreSQL: índices B-tree, parciales y de cobertura, búsqueda de texto completo con SearchVector y GIN, además de preguntas de entrevista 2026. - Published: 2026-06-27 - Updated: 2026-07-06 - Author: SharpSkill - Tags: django, postgresql, database optimization, full text search, python - Reading time: 9 min --- La optimización de Django con PostgreSQL marca la diferencia entre una aplicación que escala más allá de sus primeros mil usuarios y otra que se paraliza en cada vista de lista. PostgreSQL incluye un motor de indexación y un subsistema de búsqueda de texto completo que la mayoría de los proyectos Django deja sin tocar, desperdiciando un rendimiento de consultas que no cuesta nada recuperar. Esta guía cubre los patrones de indexación, las herramientas de búsqueda de `django.contrib.postgres` y las preguntas de entrevista sobre bases de datos que separan a los ingenieros Django de nivel medio de los senior en 2026. > **Medir antes de indexar** > > Cada índice acelera las lecturas, pero ralentiza las escrituras y consume disco. Conviene ejecutar `EXPLAIN (ANALYZE, BUFFERS)` sobre la consulta real, agregar el índice y luego comparar el plan. Un índice que el planificador de consultas nunca elige es pura sobrecarga en cada INSERT y UPDATE. ## Fundamentos de indexación de PostgreSQL en Django Por defecto, PostgreSQL crea un índice B-tree solo en las claves primarias y en las columnas con `unique=True`. Cualquier otra columna filtrada u ordenada ejecuta un escaneo secuencial hasta que exista un índice. Django expone los índices a través de `Meta.indexes`, que se prefiere sobre el antiguo `db_index=True` porque admite índices compuestos, parciales y de cobertura en un solo lugar. El orden de las columnas dentro de un índice compuesto no es cosmético. Un B-tree puede usar un índice para una consulta solo cuando el filtro utiliza un prefijo inicial de las columnas indexadas, una regla explicada en profundidad en [Use The Index, Luke](https://use-the-index-luke.com/sql/where-clause/the-equals-operator/concatenated-keys). Un índice sobre `(status, created_at)` sirve para `WHERE status = ? ORDER BY created_at`, pero ese mismo índice no puede acelerar una consulta que filtra únicamente por `created_at`. ```python # models.py from django.db import models class Order(models.Model): customer_email = models.EmailField() status = models.CharField(max_length=20) total = models.DecimalField(max_digits=10, decimal_places=2) created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ # B-tree de una columna para búsquedas exactas y por rango en status models.Index(fields=["status"]), # Índice compuesto: sirve para WHERE status = ? ORDER BY created_at DESC # La columna inicial (status) debe aparecer en el filtro para poder usarse models.Index(fields=["status", "-created_at"]), ] ``` El índice compuesto convierte una consulta de tablero como "últimos pedidos pendientes" en un único escaneo de índice, en lugar de un filtro seguido de una ordenación sobre toda la tabla. ## Índices parciales y de cobertura para consultas dirigidas Un índice parcial cubre solo las filas que cumplen una condición, por lo que se mantiene pequeño y reside en memoria. Cuando la mayoría de las filas comparten un valor y las consultas solo tocan la minoría, un índice parcial resulta mucho más económico que uno completo. Un índice de cobertura va más allá: al agregar columnas que no son clave con `include`, PostgreSQL responde la consulta directamente desde el índice sin tocar el heap de la tabla, un patrón de acceso llamado escaneo solo de índice (index-only scan). ```python # models.py from django.db import models from django.db.models import Q class Order(models.Model): # campos definidos arriba class Meta: indexes = [ # Índice parcial: solo indexa las filas pendientes, ignora el historial completado models.Index( fields=["created_at"], name="pending_orders_idx", condition=Q(status="pending"), ), # Índice de cobertura: total se lee directo del índice (index-only scan) models.Index( fields=["status"], include=["total"], name="status_total_covering_idx", ), ] ``` En una tabla de pedidos donde el 2% de las filas está en estado pendiente, el índice parcial puede ser cincuenta veces más pequeño que un índice completo sobre la misma columna, y el planificador lo mantiene caliente en la caché de búferes. La cláusula `include` está documentada en [los escaneos solo de índice de PostgreSQL](https://www.postgresql.org/docs/current/indexes-index-only-scans.html) y es la mejora más pasada por alto en las aplicaciones Django que agregan una columna numérica detrás de un filtro de estado. ## Búsqueda de texto completo en Django con PostgreSQL Recurrir a `title__icontains=query` resulta cómodo, pero fuerza un escaneo secuencial y no puede clasificar los resultados por relevancia. La búsqueda de texto completo de Django envuelve los tipos nativos `tsvector` y `tsquery` de PostgreSQL a través de `SearchVector`, `SearchQuery` y `SearchRank`. Un vector de búsqueda normaliza el texto en lexemas, elimina las palabras vacías y reduce las palabras a su raíz, de modo que una búsqueda de "running" coincide con "run" y "ran". ```python # views.py from django.contrib.postgres.search import ( SearchVector, SearchQuery, SearchRank, ) from .models import Article def search_articles(query_text): query = SearchQuery(query_text, config="english") # El peso A clasifica las coincidencias del título por encima del cuerpo (peso B) vector = ( SearchVector("title", weight="A") + SearchVector("body", weight="B") ) return ( Article.objects.annotate(rank=SearchRank(vector, query)) .filter(rank__gte=0.1) .order_by("-rank") ) ``` El parámetro `weight` asigna niveles de prioridad de A (el más alto) a D. Una coincidencia en el título se clasifica por encima de una coincidencia en el cuerpo aunque ambos contengan el término buscado, lo que refleja cómo los usuarios esperan que se comporte una búsqueda. La API completa y el modelo de ponderación de cuatro niveles se detallan en la [referencia de búsqueda de texto completo de Django](https://docs.djangoproject.com/en/5.2/ref/contrib/postgres/search/). ## Índices GIN y SearchVectorField para búsquedas rápidas La consulta anterior recalcula el vector de búsqueda para cada fila en cada solicitud, lo cual es aceptable para unos miles de filas e inaceptable para un millón. El patrón de producción almacena el vector en un `SearchVectorField` y lo indexa con un índice GIN, el tipo de índice que PostgreSQL usa para valores compuestos como los documentos de texto completo y JSONB. ```python # models.py from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.indexes import GinIndex from django.db import models class Article(models.Model): title = models.CharField(max_length=255) body = models.TextField() # Documento de búsqueda almacenado y precalculado search_vector = SearchVectorField(null=True) class Meta: indexes = [ # GIN convierte las búsquedas de texto completo en escaneos logarítmicos GinIndex(fields=["search_vector"]), ] ``` El campo almacenado debe mantenerse sincronizado con `title` y `body`. Una señal `post_save` que escribe el vector con `.update()` evita disparar la señal de forma recursiva, ya que `QuerySet.update()` no emite señales de guardado. ```python # signals.py from django.contrib.postgres.search import SearchVector from django.db.models.signals import post_save from django.dispatch import receiver from .models import Article @receiver(post_save, sender=Article) def sync_search_vector(sender, instance, **kwargs): Article.objects.filter(pk=instance.pk).update( search_vector=SearchVector("title", weight="A") + SearchVector("body", weight="B"), ) ``` Para los equipos que usan Django 5.0 o una versión más reciente, un `GeneratedField` calculado por la base de datos o un disparador de PostgreSQL elimina por completo el UPDATE adicional al mantener la columna dentro de la base de datos. Sea cual sea el mecanismo que la mantenga poblada, la consulta filtra entonces el campo indexado directamente, y el índice GIN convierte lo que era un escaneo completo de tabla en una búsqueda logarítmica. > **Los índices GIN son costosos de escribir** > > Los índices GIN aceleran las lecturas con un costo real de escritura, ya que cada inserción toca muchas entradas del índice. En tablas con muchas escrituras, conviene configurar el parámetro de almacenamiento `fastupdate` y vigilar el tamaño de la lista pendiente, o las escrituras de texto completo se estancarán detrás del mantenimiento del índice. ## Cómo elegir el tipo de índice adecuado en PostgreSQL B-tree es el valor predeterminado correcto, pero PostgreSQL ofrece varios tipos de índice a través de `django.contrib.postgres.indexes`, y elegir el equivocado desperdicia disco sin mejorar una sola consulta. La elección depende de la forma de los datos y del operador que usa la consulta, tal como se cataloga en la [documentación de tipos de índice de PostgreSQL](https://www.postgresql.org/docs/current/indexes-types.html). | Tipo de índice | Ideal para | Clase de Django | |------------|----------|--------------| | B-tree | Igualdad y rango en columnas escalares | `models.Index` | | GIN | Búsqueda de texto completo, JSONB, contención en arreglos | `GinIndex` | | BRIN | Tablas enormes de solo anexado ordenadas por una columna | `BrinIndex` | | Hash | Búsquedas solo por igualdad en columnas grandes | `HashIndex` | BRIN merece atención especial para los datos de series temporales. En una tabla de decenas de millones de filas insertadas en orden de marca de tiempo, un `BrinIndex` sobre `created_at` ocupa unos pocos kilobytes donde un B-tree necesitaría cientos de megabytes, porque BRIN almacena solo el valor mínimo y máximo por rango de bloques. La contrapartida es que BRIN solo ayuda cuando el orden físico de las filas sigue a la columna indexada, algo que se cumple naturalmente en los registros de solo anexado y las tablas de eventos. ```python # models.py from django.contrib.postgres.indexes import BrinIndex from django.db import models class Event(models.Model): payload = models.JSONField() created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ # Índice diminuto para escaneos por rango en una tabla de solo anexado y orden temporal BrinIndex(fields=["created_at"]), ] ``` Ajustar el tipo de índice al patrón de acceso es un tema recurrente en las entrevistas senior, porque demuestra que el candidato razona sobre la disposición del almacenamiento en lugar de agregar B-trees por reflejo en todas partes. ## Preguntas de entrevista sobre bases de datos en Django en 2026 Las preguntas sobre bases de datos dominan las entrevistas senior de Django porque la fluidez con el ORM no implica entender lo que este emite. Las preguntas siguientes reflejan lo que los paneles de contratación realmente indagan en 2026, y encajan de forma natural con el ajuste a nivel de consulta que cubre la [guía de optimización de consultas del ORM de Django](/blog/django/django-orm-optimizing-queries). **¿Cuándo un índice compuesto sobre `(a, b)` no ayuda a una consulta?** Cuando la consulta no filtra por `a`. Un B-tree se ordena primero por su columna inicial, de modo que un filtro solo sobre `b` no puede usar el índice. Esta es la regla del prefijo más a la izquierda, y los candidatos que la explican demuestran que entienden la estructura del índice en lugar de recetas memorizadas. **¿Cuál es la diferencia entre `select_related` y `prefetch_related`?** `select_related` realiza un JOIN de SQL y funciona para relaciones de clave foránea y uno a uno en una sola consulta. `prefetch_related` ejecuta una segunda consulta y une los resultados en Python, lo cual es necesario para las relaciones muchos a muchos y de clave foránea inversa. Recurrir al equivocado o pierde la optimización o dispara una segunda consulta innecesaria. **¿En qué se diferencia `icontains` de la búsqueda de texto completo a nivel de base de datos?** `icontains` se compila a `ILIKE '%term%'`, que no puede usar un índice B-tree estándar y escanea todas las filas. La búsqueda de texto completo coincide contra un `tsvector` precalculado respaldado por un índice GIN y clasifica los resultados por relevancia. La distinción es una señal confiable de que un candidato ha superado los conjuntos de datos de juguete. **¿Por qué `count()` puede ser lento en una tabla grande y cuáles son las alternativas?** PostgreSQL no tiene un conteo de filas en caché para una tabla, así que `COUNT(*)` escanea todas las filas visibles bajo MVCC. Para conteos aproximados, consultar `pg_class.reltuples` devuelve una estimación al instante, y para la paginación, la paginación por clave (keyset) sobre una columna indexada evita el conteo por completo. Hay práctica estructurada sobre estos patrones en el [módulo de entrevista sobre caché de Django](/technologies/django/interview-questions/django-caching), donde los contadores respaldados por caché son un tema recurrente, y en toda la [ruta de aprendizaje de Django](/technologies/django). **¿Qué revela `EXPLAIN ANALYZE` que `EXPLAIN` por sí solo no muestra?** `EXPLAIN` imprime el costo estimado del planificador; `EXPLAIN ANALYZE` ejecuta realmente la consulta e informa los tiempos y conteos de filas reales. Una gran brecha entre las filas estimadas y las reales apunta a estadísticas de tabla desactualizadas, corregibles con `ANALYZE`, y es una causa raíz común de que el planificador ignore un índice que obviamente debería aplicar. ## Conclusión - Definir los índices en `Meta.indexes` en lugar de `db_index=True` para que los índices compuestos, parciales y de cobertura vivan en un solo lugar auditable. - Ordenar las columnas del índice compuesto según la regla del prefijo más a la izquierda: la columna inicial debe aparecer en el filtro de la consulta para que el índice se aplique. - Usar índices parciales cuando las consultas solo tocan una minoría de filas, como los pedidos pendientes en una tabla dominada por el historial completado. - Agregar índices de cobertura con `include` para habilitar los escaneos solo de índice y evitar el acceso al heap en consultas de agregación con muchas lecturas. - Reemplazar la búsqueda con `icontains` por `SearchVector`, `SearchQuery` y `SearchRank` para obtener clasificación por relevancia y derivación de raíces sin esfuerzo. - Almacenar el documento de búsqueda en un `SearchVectorField` respaldado por un `GinIndex`, mantenido en sincronía por una señal, un `GeneratedField` o un disparador de base de datos, antes de que la búsqueda de texto completo alcance la escala de producción. - Validar siempre las decisiones de indexación con `EXPLAIN (ANALYZE, BUFFERS)`; un índice que el planificador nunca selecciona es un impuesto en tiempo de escritura sin beneficio en tiempo de lectura. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/es/blog/django/django-postgresql-indexing-full-text-search-2026