Received: from malur.postgresql.org ([217.196.149.56]) by arkaria.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1w2bXa-000R52-1v for pgsql-performance@arkaria.postgresql.org; Tue, 17 Mar 2026 21:01:26 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.96) (envelope-from ) id 1w2bXY-005BjO-24 for pgsql-performance@arkaria.postgresql.org; Tue, 17 Mar 2026 21:01:24 +0000 Received: from magus.postgresql.org ([2a02:c0:301:0:ffff::29]) by malur.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1w2bXY-005BjG-0q for pgsql-performance@lists.postgresql.org; Tue, 17 Mar 2026 21:01:24 +0000 Received: from mail-vs1-xe32.google.com ([2607:f8b0:4864:20::e32]) by magus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (Exim 4.98.2) (envelope-from ) id 1w2bXV-00000000g6X-2YNn for pgsql-performance@lists.postgresql.org; Tue, 17 Mar 2026 21:01:24 +0000 Received: by mail-vs1-xe32.google.com with SMTP id ada2fe7eead31-5fff77ff719so2783166137.2 for ; Tue, 17 Mar 2026 14:01:21 -0700 (PDT) ARC-Seal: i=1; a=rsa-sha256; t=1773781280; cv=none; d=google.com; s=arc-20240605; b=gOsudys8+hHFaQuwwvBLPe/lqMDviaNEIhvrxerFGDQtNGRcIpecJUA2bPxsbRV1ip T0B1v8qSyke+zMBsXXcoUMAMth6h056MRN5/1PYv91tF9MrkfLqTxM/76ZiB6v2VEE6a dqutOljKTI68uXGgw/spw9h9yMpB1ZPsQjMxm5Ze+BsSuTTm/j8js272lv5JdaZxHS64 dlhZJMExWq3asB03k3a+S5DxisYnnkC6T4GbpsEATDPJbJX8QM/pss4SpUsvKJohZUE4 EJmKJhlqvAw9QVBecKTRsqNA9q9x1Gei5j/tkIjN/EOHj53Jp/0OHJEkg92uhjSxubdZ SzSA== ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605; h=to:subject:message-id:date:from:mime-version:dkim-signature; bh=BKmRCfZQHq8gYagVmCHPCv001MQln4fWg0xbhexcD/E=; fh=z/JQ2tSXVGoYKKvtD/yyy3g3xqdpsR82Ngoo7GcsvL4=; b=GHX1xsktsx47Gotq2UqFCzFJIPE0eoJEaJ4fnxSYyhr7BUd2OK0h+I7jJfwe/VN9bx dFWY8iCDdqqH+Ti7/y2stPTbMenGk/LTr7PrTqj/GQ/IFSSS6qUS6ImbJjvXK0D3mtpr yhXqqpEgBJDhAI7QcVS11yCDa3ZAQEfrpdn1AtxEWGr+dATkxlCY9q0k4/Dj4ocl39PX cY6vZsbvoUefXjCkiaen+0Bac9HgorXhkuc8jusrfL2r1pco4DjcCcVHdYuFmN7Sw2A/ v4/C8tN1Jj01z1wocGKePhdKrz2UAJJVC+m7LcXz430gASEtDFRUOIejVCc47uIM8BP3 1Kjg==; darn=lists.postgresql.org ARC-Authentication-Results: i=1; mx.google.com; arc=none DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20230601; t=1773781280; x=1774386080; darn=lists.postgresql.org; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject :date:message-id:reply-to; bh=BKmRCfZQHq8gYagVmCHPCv001MQln4fWg0xbhexcD/E=; b=OVfNV68lzb/t0IKM/mUwNUT3Q3QMm1Bs4rbzcHywVozHy/8r2B1mDJvp4+KMYPevTX nk9I+DUvXNqfw9DjTfZLoNpgYEeHRWOJ8RNPZ0zYYZEDMr1Jmb3Cs6q/H6slOzGA5Xrj Io8glRzlzCRMD/dtU/mDk4S95lNQoNwDWidfiBQP+aqYVzINnUSrQlcMePvvyS5se7+U 4zzq0Z/pT4GNVIwYmhapfAYQOl4N2e+EcltEMe4JQMzPrgiEvnpX2SlpBUp1IQwoSM3E pP+6EC6AagDFWlrRj/bmHgw1XaI/Nw2dyk4dsZeCETyefdigeCgo1FXstVaM0YqK7adP 5xgQ== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1773781280; x=1774386080; h=to:subject:message-id:date:from:mime-version:x-gm-gg :x-gm-message-state:from:to:cc:subject:date:message-id:reply-to; bh=BKmRCfZQHq8gYagVmCHPCv001MQln4fWg0xbhexcD/E=; b=mlyaV3Bd75aYcBKfuYUkf+wyp4/oxzS/PUsgLEDxeAHl+JMJpoBjCTm+SrtjHZzi0z t6lxYTNK9VRmCvbZ9F2CtfuOi9O6+n1GcvZZQyaJW8W+ZgweOOtsImOmsmfkb70mHMQW qrs/Cwv/F5N46VzPE6GqLGPp73TENm052rkLkuPCsqA0HiaNWoRGI7hD93HjUg117/aS 6HUq9o8TBPkDVxtyGikBqzYcPrFTJaT7RJc/9CKwrvfOxETkUgpGtIt3f67pPogZSvFV 3MiTh9ZMSNf/ZbZfq8/cDtJNddRrT3fww6xLTs78pNXqS3+gYhgejcWeUsaksEX6OA+Z PwXA== X-Gm-Message-State: AOJu0YxM2f/xVCGi82az6Y5XL4a2RErwTiTxzsrhcpY29R5ry/54GaXD pcv9+pIUk1NLRBxninVVxe5LVsY4N9eFRL/0UrI+yxRt55kEZTeQrZ20JjTEACyXZafNVUz6CZz YTZjWr+mRg/AiFySmqcoro36z0rC5pRIGNbrV X-Gm-Gg: ATEYQzyvJgM8+vcZJrHkQbweq5/is82E/SzMp6aHqjVCfhFe4bVpbwFDbdK8S3R6vSR gyokTDzr/wRmluEJW9yqKiIhJyIGdj1Kbe+aIoJXL1gLKFIGZTa23wL/ZWfnN20lOlD37ZYBptD B3UqoBONg1FhgqwIcoMtFhivVF4v23HPRbuwC8E9JXkPr4IouHAOOEvCRNid8iKFdxRptdT4HVJ /xonA8cUuZCG+4uCY07GQYZ1JkY/6fozSma3X1atnGBIO7tAFlJb6bThUbh8FS6WEubuISg6QhT kJj6QMcfu21PqwqmexZq6q8XOHcHUTF4EJjKizi/5OgtKtdUFG4KK8dCfaiUnOjHdaMdqfTq1ze YdDqdnRA= X-Received: by 2002:a05:6102:cc8:b0:602:6987:e233 with SMTP id ada2fe7eead31-6027d429404mr613877137.37.1773781279422; Tue, 17 Mar 2026 14:01:19 -0700 (PDT) MIME-Version: 1.0 From: Merlin Moncure Date: Tue, 17 Mar 2026 15:01:09 -0600 X-Gm-Features: AaiRm50m_LE3ffwhEsYsyo_YJMtnwdb10_FENpiAjllrmbgsjGrkvCUqgaITHsw Message-ID: Subject: postgres chooses objectively wrong index To: pgsql-performance@lists.postgresql.org Content-Type: multipart/alternative; boundary="00000000000011bee0064d3ea32e" List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk --00000000000011bee0064d3ea32e Content-Type: text/plain; charset="UTF-8" Content-Transfer-Encoding: quoted-printable I've been maintaining an airflow style orchestrator in pl/pgsql, and it's revealed a performance issue I just can't solve. There is a table, task, which may normally contain billions of rows, but only a tiny portion is interesting for specific reasons=E2=80=94a common pattern in task-type syst= ems. CREATE TABLE async.task ( task_id BIGSERIAL PRIMARY KEY, target TEXT REFERENCES async.target ON UPDATE CASCADE ON DELETE CASCADE, priority INT DEFAULT 0, entered TIMESTAMPTZ DEFAULT clock_timestamp(), consumed TIMESTAMPTZ, processed TIMESTAMPTZ, yielded TIMESTAMPTZ, times_up TIMESTAMPTZ, concurrency_pool TEXT ); CREATE OR REPLACE FUNCTION async.task_execution_state(t async.task) RETURNS async.task_execution_state_t AS $$ SELECT CASE WHEN t.processed IS NOT NULL THEN 'FINISHED' WHEN t.consumed IS NULL AND t.yielded IS NULL THEN 'READY' WHEN t.yielded IS NOT NULL THEN 'YIELDED' WHEN t.consumed IS NOT NULL AND t.yielded IS NULL THEN 'RUNNING' END::async.task_execution_state_t; $$ LANGUAGE SQL IMMUTABLE; "processed NOT NULL" defines the 'needle', let's say typically <0.01%. Of those cases, a few patterns need defense from a performance standpoint. Naturally, partial indexes are used because we don't want to index the entire table. /* supports fetching eligible tasks */ CREATE INDEX ON async.task(concurrency_pool, priority, entered) WHERE async.task_execution_state(task) =3D 'READY'; /* look up expired tasks. Times up qual is to prevent index being used for * any other purpose. */ CREATE INDEX ON async.task(times_up) WHERE async.task_execution_state(task) IN('READY', 'RUNNING', 'YIELDED') AND times_up IS NOT NULL; /* supports cleaning up dead tasks on startup and other needs for * processing unfinished tasks. */ CREATE INDEX ON async.task(task_id) WHERE async.task_execution_state(task) IN('READY', 'RUNNING', 'YIELDED'); These indexes support queries called in a tight loop, for example: SELECT * FRROM async.task WHERE async.task_execution_state(task.*) =3D 'READY'::async.task_execution_state_t AND concurrency_pool =3D 'xyz' ORDER BY priority, entered LIMIT 10; Usually, we get a plan that looks like this: Limit (cost=3D0.38..39.74 rows=3D10 width=3D563) (actual time=3D0.054..0.= 054 rows=3D0 loops=3D1) -> Index Scan using task_concurrency_pool_priority_entered_idx on task (cost=3D0.38..705.08 rows=3D179 width=3D563) (actual time=3D0.053..0.053 r= ows=3D0 loops=3D1) Index Cond: (concurrency_pool =3D 'xyz'::text) Planning Time: 0.234 ms Execution Time: 0.072 ms Let's note that the partial index predicate exactly matches the where clause, and that the index from left to right matches in terms of equality and ordering. No sorting is required, and the results are excellent. The final costing here is IMNSHO very high: 39.74, and I believe that is the fundamental issue. Sometimes, based on a certain data distribution, we get results like this: Limit (cost=3D25.75..25.78 rows=3D10 width=3D563) (actual time=3D8.909..8.= 911 rows=3D0 loops=3D1) -> Sort (cost=3D25.75..26.20 rows=3D179 width=3D563) (actual time=3D8.908..8.909 rows=3D0 loops=3D1) Sort Key: priority, entered Sort Method: quicksort Memory: 25kB -> Bitmap Heap Scan on task (cost=3D9.10..21.89 rows=3D179 width= =3D563) (actual time=3D8.902..8.903 rows=3D0 loops=3D1) Recheck Cond: ((async.task_execution_state(task.*) =3D ANY ('{READY,RUNNING,YIELDED}'::async.task_execution_state_t[])) AND (concurrency_pool =3D 'xyz'::text) AND (async.task_execution_state(task.*) = =3D 'READY'::async.task_execution_state_t)) -> BitmapAnd (cost=3D9.10..9.10 rows=3D3 width=3D0) (actual time=3D8.883..8.883 rows=3D0 loops=3D1) -> Bitmap Index Scan on task_task_id_idx (cost=3D0.00..4.38 rows=3D575191 width=3D0) (actual time=3D8.828..8.828 ro= ws=3D16 loops=3D1) -> Bitmap Index Scan on task_concurrency_pool_priority_entered_idx (cost=3D0.00..4.38 rows=3D179 width=3D0) (actual time=3D0.053..0.053 rows=3D0 loops=3D1) Index Cond: (concurrency_pool =3D 'xyz'::text) Planning Time: 0.262 ms Execution Time: 8.946 ms In this case, we get an explicit sort and other unnecessary work for a 100x degradation in runtime. My basic issue is that I do not believe any data distribution that allows plan #2 to beat plan #1, given the more specific predicate and index order matching result order. I suspect this is a very long standing issue concerning insufficient weight given to partial indexes, predicate matching, and possibly index-supported sorting. I've dealt with some variant of this problem for many years. Sometimes, there can be even worse plans, running into 10-20 seconds, for a ~ 10 order of magnitude miss. I can manage this at the query level by: * turning off various planner directives, heap scan, etc * adding faux columns to the table to support forcing index selection in particular cases (CREATE INDEX ON foo WHERE this_case IS NULL....SELECT * FROM foo WHERE this_case IS NULL...) I'm wondering if there are other tricks that might apply here, for example, multi column index statistics...curious if anyone has thoughts on that. Any suggestions? merlin --00000000000011bee0064d3ea32e Content-Type: text/html; charset="UTF-8" Content-Transfer-Encoding: quoted-printable
I've been maintaining an airflow= style orchestrator in pl/pgsql, and it's revealed a performance issue = I just can't solve.=C2=A0 There is a table, task, which may normally=C2= =A0contain billions of rows, but only a tiny portion is interesting for spe= cific reasons=E2=80=94a common pattern in task-type systems.

=
CREATE TABLE async.task
(
=C2=A0 task_id BIGSERIAL PRIMARY= KEY,
=C2=A0 target TEXT REFERENCES async.target ON UPDATE CASCADE ON DE= LETE CASCADE,
=C2=A0 priority INT DEFAULT 0,
=C2=A0 entered TIMESTAMP= TZ DEFAULT clock_timestamp(),
=C2=A0 consumed TIMESTAMPTZ,
=C2=A0 p= rocessed TIMESTAMPTZ,
=C2=A0 yielded TIMESTAMPTZ,
=C2=A0 time= s_up TIMESTAMPTZ,
=C2=A0 concurrency_pool TEXT
);
CREATE OR REPLACE FUNCTION async.task_execution_state(t async.= task)
=C2=A0 RETURNS async.task_execution_state_t AS
$$
=C2=A0 SE= LECT
=C2=A0 =C2=A0 CASE
=C2=A0 =C2=A0 =C2=A0 WHEN t.processed IS NO= T NULL THEN 'FINISHED'
=C2=A0 =C2=A0 =C2=A0 WHEN t.consumed IS N= ULL AND t.yielded IS NULL THEN 'READY'
=C2=A0 =C2=A0 =C2=A0 WHEN= t.yielded IS NOT NULL THEN 'YIELDED'
=C2=A0 =C2=A0 =C2=A0 WHEN = t.consumed IS NOT NULL AND t.yielded IS NULL THEN 'RUNNING'
=C2= =A0 =C2=A0 END::async.task_execution_state_t;
$$ LANGUAGE SQL IMMUTABLE;=

"processed NOT NULL" defines the &#= 39;needle', let's say typically <0.01%.=C2=A0 =C2=A0Of those cas= es, a few patterns need defense from a performance standpoint. Naturally, p= artial indexes are used because we don't want to index the entire table= .

/* supports fetching eligible tasks */
CREATE INDEX ON async.task(concurrency_pool, priority, entered)
WHERE = async.task_execution_state(task) =3D 'READY';

/* look up exp= ired tasks.=C2=A0 Times up qual is to prevent index being used for
=C2= =A0* any other purpose.
=C2=A0*/
CREATE INDEX ON async.task(times_up)=
=C2=A0 WHERE
=C2=A0 =C2=A0 async.task_execution_state(task) IN('= ;READY', 'RUNNING', 'YIELDED')
=C2=A0 =C2=A0 AND tim= es_up IS NOT NULL;

/* supports cleaning up dead tasks on startup and= other needs for
=C2=A0* processing unfinished tasks.
=C2=A0*/
CR= EATE INDEX ON async.task(task_id)
=C2=A0 WHERE async.task_execution_stat= e(task) IN('READY', 'RUNNING', 'YIELDED');

These indexes support queries called in a tight loop, for = example:

SELECT *
FRROM async.task=C2=A0=
WHERE=C2=A0
=C2=A0 async.task_execution_state(task.*) = =3D 'READY'::async.task_execution_state_t =C2=A0
=C2=A0 A= ND concurrency_pool =3D 'xyz'=C2=A0
ORDER BY priority, en= tered=C2=A0
LIMIT 10;

Usually, we get a = plan that looks like this:=C2=A0

=C2=A0Limit =C2= =A0(cost=3D0.38..39.74 rows=3D10 width=3D563) (actual time=3D0.054..0.054 r= ows=3D0 loops=3D1)
=C2=A0 =C2=A0-> =C2=A0Index Scan using task_concur= rency_pool_priority_entered_idx on task =C2=A0(cost=3D0.38..705.08 rows=3D1= 79 width=3D563) (actual time=3D0.053..0.053 rows=3D0 loops=3D1)
=C2=A0 = =C2=A0 =C2=A0 =C2=A0 =C2=A0Index Cond: (concurrency_pool =3D 'xyz':= :text)
=C2=A0Planning Time: 0.234 ms
=C2=A0Execution Time: 0.072 ms

Let's note that the partial=C2=A0index predicat= e exactly matches the where clause, and that the index from left to right m= atches in terms of equality and ordering.=C2=A0 No sorting is required, and= the results are excellent.=C2=A0 The final costing here is IMNSHO very hig= h: 39.74, and I believe that is the fundamental issue.

=
Sometimes, based on a certain data distribution, we get results like t= his:

Limit =C2=A0(cost=3D25.75..25.78 rows=3D10 wi= dth=3D563) (actual time=3D8.909..8.911 rows=3D0 loops=3D1)
=C2=A0 -> = =C2=A0Sort =C2=A0(cost=3D25.75..26.20 rows=3D179 width=3D563) (actual time= =3D8.908..8.909 rows=3D0 loops=3D1)
=C2=A0 =C2=A0 =C2=A0 =C2=A0 Sort Key= : priority, entered
=C2=A0 =C2=A0 =C2=A0 =C2=A0 Sort Method: quicksort = =C2=A0Memory: 25kB
=C2=A0 =C2=A0 =C2=A0 =C2=A0 -> =C2=A0Bitmap Heap S= can on task =C2=A0(cost=3D9.10..21.89 rows=3D179 width=3D563) (actual time= =3D8.902..8.903 rows=3D0 loops=3D1)
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 Recheck Cond: ((async.task_execution_state(task.*) =3D ANY (&= #39;{READY,RUNNING,YIELDED}'::async.task_execution_state_t[])) AND (con= currency_pool =3D 'xyz'::text) AND (async.task_execution_state(task= .*) =3D 'READY'::async.task_execution_state_t))
=C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 -> =C2=A0BitmapAnd =C2=A0(cost=3D9.10= ..9.10 rows=3D3 width=3D0) (actual time=3D8.883..8.883 rows=3D0 loops=3D1)<= br>=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 -&= gt; =C2=A0Bitmap Index Scan on task_task_id_idx =C2=A0(cost=3D0.00..4.38 ro= ws=3D575191 width=3D0) (actual time=3D8.828..8.828 rows=3D16 loops=3D1)
= =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 ->= =C2=A0Bitmap Index Scan on task_concurrency_pool_priority_entered_idx =C2= =A0(cost=3D0.00..4.38 rows=3D179 width=3D0) (actual time=3D0.053..0.053 row= s=3D0 loops=3D1)
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 Index Cond: (concurrency_pool =3D '= xyz'::text)
Planning Time: 0.262 ms
Execution Time: 8.946 ms

In this case, we get an explicit sort and other u= nnecessary work for a 100x degradation in runtime.=C2=A0 My basic issue is = that I do not believe any data distribution that allows plan #2 to beat pla= n #1, given the more specific predicate and index order matching result ord= er.=C2=A0 I suspect this is a very long standing issue concerning insuffici= ent weight given to partial indexes, predicate matching, and possibly index= -supported sorting.=C2=A0 I've dealt with some variant of this problem = for many years.

Sometimes, there = can be even worse plans, running into 10-20 seconds, for a ~ 10 order of ma= gnitude miss.=C2=A0 I can manage this at the query level by:
* tu= rning off various planner directives, heap scan, etc
* adding fau= x columns to the table to support forcing index selection in particular cas= es (CREATE INDEX ON foo WHERE this_case IS NULL....SELECT * FROM foo WHERE = this_case IS NULL...)

I'm wondering if there a= re other tricks that might apply here, for example, multi column index stat= istics...curious if anyone has thoughts on that.

A= ny suggestions?

merlin




--00000000000011bee0064d3ea32e--