COUNT en PostgreSQL: por qué puede ser lento y cómo optimizarlo#
COUNT(*) parece una operación trivial:
SELECT COUNT(*)
FROM orders;
La consulta pide un único número, pero eso no significa que PostgreSQL pueda obtenerlo con una lectura constante de algún contador interno.
Cuando necesitamos un conteo exacto, PostgreSQL debe determinar cuántas filas forman parte del resultado visible para la consulta. En tablas grandes, ese trabajo puede convertirse en una parte relevante del tiempo de ejecución.
El problema tampoco se resuelve simplemente creando un índice.
La pregunta útil es otra:
¿Cuántas filas necesita examinar PostgreSQL para calcular este conteo y existe una forma de reducir ese trabajo?
Por qué COUNT(*) puede ser costoso en PostgreSQL#
PostgreSQL utiliza MVCC —Multi-Version Concurrency Control— para gestionar el acceso concurrente a los datos.
Eso permite que varias transacciones trabajen al mismo tiempo manteniendo una visión consistente de la base de datos, pero también significa que la visibilidad de una fila depende del snapshot desde el que se ejecuta la consulta.
Por ese motivo, PostgreSQL no puede responder a:
SELECT COUNT(*)
FROM orders;
leyendo simplemente un contador exacto almacenado en los metadatos de la tabla.
Para obtener un resultado exacto debe procesar las filas o una estructura de índice que represente esas filas y determinar cuáles forman parte del resultado visible.
En una tabla pequeña, el coste puede pasar desapercibido.
En una tabla con millones de registros, la cantidad de trabajo empieza a importar.
Esto conduce a una distinción importante:
*devolver una sola fila con COUNT() no significa procesar una sola fila.**
Cómo analizar un COUNT con EXPLAIN ANALYZE#
Antes de crear índices, conviene observar qué está haciendo PostgreSQL.
Supongamos esta consulta:
SELECT COUNT(*)
FROM orders
WHERE status = 'completed';
Podemos analizarla con:
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*)
FROM orders
WHERE status = 'completed';
El objetivo no es buscar automáticamente un Index Scan.
Conviene revisar:
- el tipo de scan;
- las filas estimadas;
- las filas procesadas realmente;
- las filas descartadas por el filtro;
- la actividad de buffers;
- el tiempo total de ejecución.
Por ejemplo, PostgreSQL podría elegir un:
Seq Scan on orders
si considera que leer la tabla secuencialmente es más barato que utilizar un índice.
Eso no significa que el planner esté tomando una mala decisión.
Todo depende de cuántas filas coincidan con la condición.
Cuándo un índice puede mejorar un COUNT#
Supongamos que tenemos 10 millones de órdenes, pero solo 30.000 están en estado pending.
La consulta es:
SELECT COUNT(*)
FROM orders
WHERE status = 'pending';
En este escenario, el filtro reduce el conjunto de datos de forma importante.
Un índice sobre status podría permitir que PostgreSQL evite recorrer gran parte de la tabla:
CREATE INDEX idx_orders_status
ON orders(status);
Dependiendo de las estadísticas, la distribución de los valores y el estado de las páginas, PostgreSQL podría utilizar un acceso mediante índice.
En determinadas condiciones también puede utilizar un Index Only Scan.
Pero es importante no convertir esa posibilidad en una regla:
tener un índice no garantiza un Index Only Scan.
El planner elegirá el plan que estime más barato.
Index Only Scan y visibility map#
Un Index Only Scan puede evitar muchas visitas a la tabla porque los valores necesarios para resolver la consulta están disponibles en el propio índice.
Para un conteo como:
SELECT COUNT(*)
FROM orders
WHERE status = 'pending';
un índice sobre status contiene la información necesaria para localizar las entradas relevantes.
Sin embargo, PostgreSQL todavía debe respetar las reglas de visibilidad de MVCC.
Ahí entra el visibility map.
PostgreSQL mantiene información que indica qué páginas contienen únicamente filas visibles para todas las transacciones relevantes. Cuando una página está marcada como all-visible, un Index Only Scan puede evitar visitar el heap para comprobar individualmente la visibilidad de esas filas.
Por eso es incorrecto pensar que VACUUM “actualiza el índice”.
El índice ya se mantiene conforme cambia la tabla.
Lo que VACUUM puede ayudar a mantener es la información de visibilidad que permite evitar determinadas visitas al heap.
Podemos comprobarlo en el plan observando métricas como:
Heap Fetches: 0
Un número elevado de Heap Fetches indica que PostgreSQL tuvo que consultar páginas de la tabla para confirmar visibilidad aunque estuviera utilizando un Index Only Scan.
El problema real de COUNT con filtros: la selectividad#
Ahora cambiemos el escenario.
Supongamos que el 90% de las órdenes están en estado completed:
SELECT COUNT(*)
FROM orders
WHERE status = 'completed';
En ese caso, un índice sobre status puede aportar mucho menos.
¿Por qué?
Porque aunque PostgreSQL pueda localizar rápidamente las entradas con:
status = completed
esas entradas representan casi toda la tabla.
El índice no elimina suficiente trabajo.
Eso explica por qué PostgreSQL puede seguir prefiriendo un Seq Scan incluso cuando existe un índice que aparentemente coincide con la columna del WHERE.
La pregunta correcta no es:
¿Existe un índice?
Sino:
¿Cuánto reduce ese índice el conjunto de datos que PostgreSQL necesita procesar?
La selectividad del filtro importa más que la mera existencia del índice.
Cuándo tiene sentido un índice parcial#
Los índices parciales pueden ser especialmente útiles cuando consultas con frecuencia un subconjunto pequeño y bien definido.
Supongamos ahora que solo una pequeña proporción de las órdenes está pending:
SELECT COUNT(*)
FROM orders
WHERE status = 'pending';
Podríamos crear:
CREATE INDEX idx_orders_pending
ON orders(id)
WHERE status = 'pending';
Ese índice no contiene todas las órdenes.
Solo contiene las que cumplen:
status = 'pending'
Si pending representa un subconjunto pequeño, el índice también puede ser considerablemente más pequeño que uno que cubra toda la tabla.
Para consultas que utilizan exactamente ese predicado, PostgreSQL puede trabajar con una estructura mucho más reducida.
La situación sería distinta si construyéramos un índice parcial para un valor que representa el 90% de la tabla.
En ese caso seguiríamos manteniendo un índice que contiene casi todos los registros, por lo que el ahorro potencial sería mucho menor.
Los índices parciales tienen sentido cuando representan un patrón de acceso real y suficientemente selectivo.
COUNT con varios filtros#
Los conteos suelen ser más interesantes cuando combinan varias condiciones:
SELECT COUNT(*)
FROM orders
WHERE account_id = 42
AND status = 'pending'
AND created_at >= DATE '2026-09-01';
Aquí un índice simple sobre una sola columna puede no ser suficiente.
Podríamos estudiar un índice compuesto:
CREATE INDEX idx_orders_account_status_created
ON orders(account_id, status, created_at);
Pero tampoco deberíamos copiar ese índice sin medir.
El orden de las columnas debe responder a cómo se filtran los datos, a su distribución y a las consultas reales de la aplicación.
El flujo correcto sigue siendo:
consulta
↓
EXPLAIN ANALYZE
↓
filas procesadas
↓
selectividad
↓
diseño del índice
↓
nuevo EXPLAIN ANALYZE
No:
consulta lenta
↓
crear índice
↓
esperar
Cuándo un índice no resolverá el COUNT#
Hay un límite fundamental.
Si realmente necesitamos contar una gran parte de una tabla enorme, PostgreSQL tiene que procesar una cantidad importante de información para producir un resultado exacto.
Un índice puede cambiar cómo se accede a esos datos.
No puede hacer desaparecer las filas que forman parte del conteo.
Si una aplicación solicita constantemente:
SELECT COUNT(*)
FROM orders;
sobre una tabla muy grande y necesita una respuesta prácticamente inmediata, puede ser necesario preguntarse si ejecutar el conteo exacto en cada petición es el modelo correcto.
En ese punto aparecen otras estrategias.
Mantener un contador precalculado#
Una posibilidad es almacenar el conteo por separado.
Por ejemplo:
order_counters
----------------
total_orders
pending_orders
completed_orders
La aplicación actualiza estos valores conforme cambia el estado de los datos.
Esto transforma una operación que requiere contar muchas filas en una lectura muy pequeña.
El coste se mueve a otro lugar.
Ahora hay que mantener el contador correctamente durante escrituras y gestionar la consistencia cuando varias operaciones ocurren concurrentemente.
También podrían utilizarse triggers para actualizar contadores, pero esto añade lógica al camino de escritura y debe diseñarse con cuidado.
No es una optimización gratuita.
Es un trade-off entre coste de lectura y complejidad de mantenimiento.
Conteos aproximados con pg_class.reltuples#
Hay situaciones en las que no necesitamos saber que una tabla contiene exactamente:
10,042,817
filas.
Quizá basta con saber que contiene aproximadamente 10 millones.
PostgreSQL mantiene una estimación del número de filas en pg_class.reltuples.
Podemos consultarla:
SELECT reltuples
FROM pg_class
WHERE oid = 'orders'::regclass;
Esta cifra no es un contador en tiempo real.
Se mantiene de forma aproximada a partir de operaciones que actualizan las estadísticas y puede diferir del número exacto de filas.
Eso la hace útil para determinadas interfaces, herramientas internas o decisiones donde una aproximación sea suficiente.
Pero no reemplaza:
SELECT COUNT(*)
FROM orders
WHERE status = 'pending';
porque reltuples representa una estimación de la relación completa, no el resultado exacto de cualquier filtro arbitrario.
La decisión depende del requisito.
Si necesitas exactitud transaccional, una estimación no sirve.
Si solo necesitas mostrar:
aproximadamente 10 millones de registros
hacer un conteo exacto cada vez puede ser trabajo innecesario.
Qué revisar cuando COUNT es lento#
Cuando una consulta de conteo empieza a degradarse, este es un buen punto de partida:
- Ejecuta
EXPLAIN (ANALYZE, BUFFERS). - Comprueba cuántas filas debe procesar PostgreSQL.
- Revisa la selectividad del filtro.
- Comprueba si el índice realmente reduce el conjunto de búsqueda.
- No asumas que un índice debería producir un
Index Only Scan. - Si existe un
Index Only Scan, revisaHeap Fetches. - Considera índices parciales para subconjuntos pequeños y consultados con frecuencia.
- Evalúa índices compuestos cuando intervienen varios filtros.
- Decide si el resultado necesita ser exacto.
- Si el conteo se ejecuta constantemente, considera precalcularlo.
La pregunta que une todos esos pasos es simple:
¿Cuánto trabajo necesita realizar PostgreSQL para obtener este número?
Conclusión#
Optimizar un COUNT en PostgreSQL no consiste en añadir un índice hasta que aparezca un Index Only Scan.
El coste depende de cuántas filas forman parte del resultado, qué tan selectivo es el filtro, qué información tiene el planner y cuánto trabajo puede evitar realmente un índice.
Un COUNT(*) exacto sobre una tabla grande puede seguir siendo costoso porque PostgreSQL necesita determinar qué filas forman parte del resultado visible. Cuando existe un filtro selectivo, un índice puede reducir ese trabajo. Si el filtro coincide con casi toda la tabla, el beneficio puede ser mucho menor.
Y cuando el producto no necesita exactitud absoluta en cada petición, también conviene plantearse alternativas: contadores precalculados o estimaciones pueden ser más adecuadas que repetir un conteo completo.
El proceso sigue siendo el mismo:
medir → entender cuánto se procesa → reducir trabajo cuando sea posible → volver a medir.
Para un proceso más amplio de diagnóstico, consulta nuestra guía de optimización de consultas PostgreSQL, donde explicamos cómo analizar planes de ejecución, estadísticas e índices antes de modificar una consulta.

