{"id":184,"date":"2026-02-23T10:12:15","date_gmt":"2026-02-23T14:12:15","guid":{"rendered":"https:\/\/juredev.com\/blog\/?p=184"},"modified":"2026-02-23T10:14:53","modified_gmt":"2026-02-23T14:14:53","slug":"postgresql-como-hacer-que-consultas-en-tablas-de-millones-de-filas","status":"publish","type":"post","link":"https:\/\/juredev.com\/blog\/2026\/02\/postgresql-como-hacer-que-consultas-en-tablas-de-millones-de-filas\/","title":{"rendered":"PostgreSQL: C\u00f3mo hacer que consultas en tablas de millones de filas dejen de ser un dolor de cabeza"},"content":{"rendered":"\n<p>Si ya has peleado con tablas grandes en <a href=\"https:\/\/juredev.com\/blog\/2026\/01\/domina-las-consultas-sql-en-mysql-y-mariadb-guia-practica-desde-cero-para-tablas-grandes\/\">MySQL<\/a> o <a href=\"https:\/\/juredev.com\/blog\/2026\/01\/domina-sql-server-con-tablas-grandes-guia-practica-de-optimizacion-t-sql\/\">SQL Server<\/a>, sabes perfectamente que el rendimiento no es un \u201cnice to have\u201d: es supervivencia. En PostgreSQL, cuando una tabla de logs, auditor\u00eda o transacciones cruza c\u00f3modamente los 20\u201330 millones de filas, una consulta mal pensada no solo se pone lenta\u2026 puede poner a gemir el CPU, saturar el disco y dejar bloat por todos lados que afecta incluso a otras bases del mismo cl\u00faster.<\/p>\n\n\n\n<p>Esta gu\u00eda va dirigida a quienes ya trabajan en producci\u00f3n y quieren mejoras concretas, r\u00e1pidas de aplicar y que realmente se noten, sin teor\u00eda pesada ni academicismos.<\/p>\n\n\n\n<p>Empecemos:<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1. SELECT: s\u00e9 radicalmente selectivo con las columnas<\/h2>\n\n\n\n<p>PostgreSQL usa <a href=\"https:\/\/www.postgresql.org\/docs\/current\/storage-toast.html\" data-type=\"link\" data-id=\"https:\/\/www.postgresql.org\/docs\/current\/storage-toast.html\">TOAST <\/a>para sacar fuera de la p\u00e1gina principal de la tabla esos valores gordos (TEXT largos, JSONB, BYTEA, etc.). Es genial\u2026 hasta que t\u00fa los pides todos.<\/p>\n\n\n\n<p><strong>Error muy t\u00edpico que vemos todos los d\u00edas:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT *\nFROM facturas\nWHERE cliente_id = 500;<\/code><\/pre>\n\n\n\n<p>Si facturas tiene <code>descripcion TEXT<\/code>, <code>xml_factura XML<\/code>, <code>metadata JSONB<\/code> o similar, est\u00e1s forzando al motor a:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Leer p\u00e1ginas extras del heap<\/li>\n\n\n\n<li>Descomprimir TOASTs que quiz\u00e1s ni ibas a usar<\/li>\n\n\n\n<li>Mandar por red kilobytes (o megas) que tu aplicaci\u00f3n va a ignorar<\/li>\n<\/ul>\n\n\n\n<p>La versi\u00f3n adulta y responsable:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT id, numero_factura, fecha_emision, total, estado\nFROM facturas\nWHERE cliente_id = 500;<\/code><\/pre>\n\n\n\n<p><strong>\u00bfQu\u00e9 ganas de verdad?<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Menos presi\u00f3n en memoria del backend<\/li>\n\n\n\n<li>Mucho menos tr\u00e1fico de red (importante si est\u00e1s en RDS o similar con costo por GB transferido)<\/li>\n\n\n\n<li>M\u00e1s chances de que el \u00edndice cubra toda la consulta (index-only scan)<\/li>\n\n\n\n<li>Bastante menos I\/O aleatorio en discos<\/li>\n<\/ul>\n\n\n\n<p>Cuando la tabla ya tiene millones de filas, esto deja de ser micro-optimizaci\u00f3n y pasa a ser <strong>dise\u00f1o sensato<\/strong>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">2. SARGable conditions: deja que el \u00edndice haga su trabajo<\/h2>\n\n\n\n<p>El query planner de Postgres solo puede aprovechar un \u00edndice si la condici\u00f3n del <code>WHERE<\/code> es \u201camigable\u201d con \u00e9l (SARGable = Search ARGument able).<\/p>\n\n\n\n<p><strong>Cl\u00e1sico anti-patr\u00f3n que mata rendimiento:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT COUNT(*)\nFROM logs\nWHERE DATE(created_at) = '2026-02-23';<\/code><\/pre>\n\n\n\n<p>Cada fila tiene que pasar por la funci\u00f3n <code>DATE()<\/code>. Adi\u00f3s \u00edndice \u2192 hola Sequential Scan casi garantizado.<\/p>\n\n\n\n<p><strong>Versi\u00f3n que s\u00ed usa \u00edndice (y vuela):<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT COUNT(*)\nFROM logs\nWHERE created_at >= '2026-02-23 00:00:00'\n  AND created_at  &lt; '2026-02-24 00:00:00';<\/code><\/pre>\n\n\n\n<p>El \u00edndice B-tree normal sobre <code>created_at<\/code> ahora s\u00ed puede hacer su magia.<\/p>\n\n\n\n<p><strong>\u00bfY si de verdad necesitas la funci\u00f3n?<\/strong><\/p>\n\n\n\n<p>PostgreSQL te da una salida muy potente: \u00edndices de expresi\u00f3n.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE INDEX idx_logs_dia ON logs (DATE(created_at));<\/code><\/pre>\n\n\n\n<p>O incluso con truncado m\u00e1s agresivo si solo te interesa mes\/a\u00f1o:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE INDEX idx_logs_mes ON logs (DATE_TRUNC('month', created_at));<\/code><\/pre>\n\n\n\n<p>Es una de las cosas que m\u00e1s me gustan de Postgres: te deja indexar casi cualquier expresi\u00f3n que uses en el <code>WHERE<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">3. Sequential Scan no es autom\u00e1ticamente el diablo<\/h2>\n\n\n\n<p>Ver en el plan<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Seq Scan on logs  (cost=...)<\/code><\/pre>\n\n\n\n<p>no significa que est\u00e9s haciendo todo mal.<\/p>\n\n\n\n<p>Est\u00e1 bien cuando:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>La consulta devuelve un porcentaje alto de la tabla (digamos > 5\u201315 %)<\/li>\n\n\n\n<li>La selectividad es baj\u00edsima<\/li>\n\n\n\n<li>El planner calcula (correctamente) que saltar p\u00e1ginas del \u00edndice ser\u00eda m\u00e1s caro que leer todo secuencialmente<\/li>\n<\/ul>\n\n\n\n<p>Se vuelve problem\u00e1tico cuando:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Esperas pocas filas<\/li>\n\n\n\n<li>La tabla es gigante<\/li>\n\n\n\n<li>Y aun as\u00ed te hace Seq Scan<\/li>\n<\/ul>\n\n\n\n<p>Primer paso r\u00e1pido: refrescar estad\u00edsticas<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ANALYZE logs;\n-- o mejor a\u00fan, si puedes permitirte un ratito:\nVACUUM ANALYZE logs;<\/code><\/pre>\n\n\n\n<p>Muchas veces el planner estaba tomando decisiones tontas solo porque las estad\u00edsticas estaban desactualizadas.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">4. Olv\u00eddate del OFFSET para paginaci\u00f3n profunda: usa keyset (o seek method)<\/h2>\n\n\n\n<p><strong>El cl\u00e1sico que mata bases:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT id, evento, created_at\nFROM logs\nORDER BY id DESC\nOFFSET 1000000\nLIMIT 50;<\/code><\/pre>\n\n\n\n<p>Para saltar un mill\u00f3n de filas, Postgres tiene que:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Encontrarlas<\/li>\n\n\n\n<li>Ordenarlas<\/li>\n\n\n\n<li>Descartarlas una por una<\/li>\n<\/ol>\n\n\n\n<p>Eso escala p\u00e9simo. A partir de la p\u00e1gina 10.000 ya duele.<\/p>\n\n\n\n<p><strong>Soluci\u00f3n pragm\u00e1tica y escalable: paginaci\u00f3n por ancla (keyset)<\/strong><\/p>\n\n\n\n<p>P\u00e1gina 1:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT id, evento, created_at\nFROM logs\nORDER BY id DESC\nLIMIT 50;<\/code><\/pre>\n\n\n\n<p>Digamos que el \u00faltimo <code>id<\/code> que te devolvi\u00f3 es <code>105432<\/code>.<\/p>\n\n\n\n<p>P\u00e1gina 2:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT id, evento, created_at\nFROM logs\nWHERE id &lt; 105432\nORDER BY id DESC\nLIMIT 50;<\/code><\/pre>\n\n\n\n<p><strong>Ventajas reales:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Tiempo casi constante independientemente de la p\u00e1gina<\/li>\n\n\n\n<li>Usa el \u00edndice de forma directa (sin tener que contar filas previas)<\/li>\n\n\n\n<li>Mucho m\u00e1s resistente a inserts concurrentes (menos riesgo de saltos\/duplicados)<\/li>\n<\/ul>\n\n\n\n<p>Requisito indispensable: la columna de orden debe tener \u00edndice y ser \u00fanica\/determinista (id autoincremental, created_at + id, etc.).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">5. Index-Only Scans + INCLUDE: menos I\/O es felicidad<\/h2>\n\n\n\n<p>Un Index-Only Scan es cuando Postgres encuentra todo lo que necesita directamente en el \u00edndice y ni toca la tabla (heap).<\/p>\n\n\n\n<p>Ejemplo pr\u00e1ctico:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE INDEX idx_ventas_cliente_info\nON ventas (cliente_id)\nINCLUDE (monto_total, fecha_emision, estado);<\/code><\/pre>\n\n\n\n<p>Consulta:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT cliente_id, monto_total, fecha_emision\nFROM ventas\nWHERE cliente_id = 98765;<\/code><\/pre>\n\n\n\n<p>Si el <strong><a href=\"https:\/\/www.postgresql.org\/docs\/current\/storage-vm.html\" data-type=\"link\" data-id=\"https:\/\/www.postgresql.org\/docs\/current\/storage-vm.html\">Visibility Map<\/a><\/strong> est\u00e1 al d\u00eda (gracias a un VACUUM decente), Postgres lee solo el \u00edndice \u2192 I\/O brutalmente reducido.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">6. BRIN: el \u00edndice peque\u00f1o y sorprendentemente \u00fatil para tablas enormes<\/h2>\n\n\n\n<p>Los \u00edndices B-tree son geniales\u2026 hasta que la tabla tiene cientos de millones de filas y el \u00edndice mismo se vuelve gigante y caro de mantener.<\/p>\n\n\n\n<p><strong>BRIN<\/strong> (Block Range INdex) guarda res\u00famenes por rangos de p\u00e1ginas f\u00edsicas, no por fila individual.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE INDEX idx_logs_created_brin\nON logs USING BRIN (created_at);<\/code><\/pre>\n\n\n\n<p><strong>Cu\u00e1ndo BRIN brilla:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Tablas append-only o casi (logs, mediciones, transacciones por fecha)<\/li>\n\n\n\n<li>Datos f\u00edsicamente ordenados (o casi) por el campo indexado<\/li>\n\n\n\n<li>Filtrado por rangos grandes (d\u00edas, meses, a\u00f1os)<\/li>\n<\/ul>\n\n\n\n<p><strong>Cu\u00e1ndo NO usarlo:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>B\u00fasquedas puntuales por UUID o email<\/li>\n\n\n\n<li>Tablas con updates\/deletes frecuentes y aleatorios<\/li>\n<\/ul>\n\n\n\n<p>BRIN ocupa muy poco espacio, se crea rapid\u00edsimo y en logs masivos puede darte un speedup sorprendente con casi cero costo.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">7. MVCC y el drama del bloat: no ignores autovacuum<\/h2>\n\n\n\n<p>PostgreSQL es MVCC puro. Cada UPDATE o DELETE genera una nueva versi\u00f3n de la fila; la antigua queda \u201cmuerta\u201d hasta que autovacuum la reclama.<\/p>\n\n\n\n<p>Si autovacuum no sigue el ritmo:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>La tabla y los \u00edndices se inflan (bloat)<\/li>\n\n\n\n<li>Lecturas tienen que revisar m\u00e1s versiones obsoletas (\u201c Heap Fetches\u201d innecesarios)<\/li>\n\n\n\n<li>Rendimiento general se va al suelo<\/li>\n<\/ul>\n\n\n\n<p>Optimizar consultas sin monitorear y tunear VACUUM es ponerle una curita a una hemorragia.<\/p>\n\n\n\n<p>Revisa al menos:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT relname, n_dead_tup, n_live_tup, last_autovacuum\nFROM pg_stat_all_tables\nWHERE relname = 'logs'\nORDER BY n_dead_tup DESC;<\/code><\/pre>\n\n\n\n<p>Y considera tunear <code>autovacuum_vacuum_scale_factor<\/code>, <code>autovacuum_vacuum_cost_limit<\/code>, etc. en tablas muy activas.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">8. La herramienta que nunca debes saltarte: EXPLAIN ANALYZE<\/h2>\n\n\n\n<p>Nunca optimices gui\u00e1ndote por corazonadas.<\/p>\n\n\n\n<p>Ejecuta siempre:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>EXPLAIN (ANALYZE, BUFFERS, SETTINGS)\nSELECT ...<\/code><\/pre>\n\n\n\n<p>F\u00edjate en:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Diferencia grande entre Rows Removed by Filter \/ Estimated Rows<\/li>\n\n\n\n<li>Actual time vs cost (\u00bfel planner se equivoc\u00f3 mucho?)<\/li>\n\n\n\n<li>Loops altos &#8211;> nested loops caros<\/li>\n\n\n\n<li>Shared hit\/read &#8211;> cu\u00e1nto I\/O real est\u00e1s haciendo<\/li>\n<\/ul>\n\n\n\n<p>Decisiones basadas en n\u00fameros, no en \u00aba m\u00ed me parece que\u2026\u00bb<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Caso real: de segundos a milisegundos (sucede m\u00e1s de lo que crees)<\/h2>\n\n\n\n<p><strong>Consulta problem\u00e1tica que encontramos hace poco:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT *\nFROM transacciones\nWHERE EXTRACT(MONTH FROM fecha) = 1;<\/code><\/pre>\n\n\n\n<p>Problemas:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Funci\u00f3n sobre columna &#8211;> adi\u00f3s \u00edndice<\/li>\n\n\n\n<li>Trae enero de todos los a\u00f1os<\/li>\n\n\n\n<li>Casi seguro Sequential Scan<\/li>\n<\/ul>\n\n\n\n<p><strong>Versi\u00f3n corregida:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT id, monto, referencia\nFROM transacciones\nWHERE fecha >= '2026-01-01'\n  AND fecha  &lt; '2026-02-01';<\/code><\/pre>\n\n\n\n<p>Resultado t\u00edpico (tabla ~80M filas):<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Index Scan o Bitmap Index Scan<\/li>\n\n\n\n<li>I\/O reducido dr\u00e1sticamente<\/li>\n\n\n\n<li>Tiempo: de 4\u201312 segundos &#8211;> 15\u201380 ms<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Cierre: el mindset que realmente cambia el juego<\/h2>\n\n\n\n<p>Optimizar PostgreSQL en tablas grandes no se trata de coleccionar truquitos de Reddit. Se trata de entender c\u00f3mo razona el query planner y dise\u00f1ar con eso en mente:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Escribe condiciones WHERE que el \u00edndice pueda usar sin sudar<\/li>\n\n\n\n<li>Pide solo las columnas que de verdad necesitas<\/li>\n\n\n\n<li>Aprovecha INCLUDE para index-only scans cuando el patr\u00f3n lo justifique<\/li>\n\n\n\n<li>Considera BRIN en datasets masivos y ordenados naturalmente<\/li>\n\n\n\n<li>Trata el VACUUM y las estad\u00edsticas como parte del desarrollo, no como \u00abmantenimiento opcional\u00bb<\/li>\n<\/ul>\n\n\n\n<p>Cuando interiorizas estos conceptos, pasar de cientos de miles a decenas de millones de filas deja de ser una pesadilla y se convierte en una cuesti\u00f3n de aplicar buenas decisiones de dise\u00f1o desde el principio.<\/p>\n\n\n\n<p>\u00bfTienes una tabla que ya te est\u00e1 haciendo sufrir? Prueba una de estas cosas esta semana y cu\u00e9ntame en los comentarios c\u00f3mo te fue. Muchas veces el 80 % de la ganancia viene del primer 20 % de esfuerzo. Sigamos codificando.<\/p>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Si ya has peleado con tablas grandes en MySQL o SQL Server, sabes perfectamente que el rendimiento no es un \u201cnice to have\u201d: es supervivencia. En PostgreSQL, cuando una tabla de logs, auditor\u00eda o transacciones cruza c\u00f3modamente los 20\u201330 millones de filas, una consulta mal pensada no solo se pone lenta\u2026 puede poner a gemir [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[18],"tags":[20],"class_list":["post-184","post","type-post","status-publish","format-standard","hentry","category-guia","tag-sql"],"_links":{"self":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/184","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/comments?post=184"}],"version-history":[{"count":0,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/184\/revisions"}],"wp:attachment":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/media?parent=184"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/categories?post=184"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/tags?post=184"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}