Subject: A very quick observation of dangling pointers in Postgres pathlists

From: Andrei Lepikhov <[email-masked]>
Date: Fri, 17 Apr 2026 10:56:34 +0200

Hi,

It looks like a community decision has been developing that Postgres should
separate optimisation features into 'conventional' and 'magic' classes [1]. This
has raised my concern that hidden contracts about pathlists' state and ordering
could lead to subtle bugs if an extension optimisation goes too far.

I think this topic is of interest because of the growing number of features that
impact path choice, such as ‘disable node’ or pg_plan_advice. Also, emerging
techniques that involve two or more levels of plan trees, like ‘eager
aggregation’, might catch another dangling pointer hidden in path lists for a
while. Don’t forget complicated cases with FDW and Custom nodes too.

For this purpose, a tiny debugging extension module, pg_pathcheck [2], has been
invented. It uses create_upper_paths_hook and planner_shutdown_hook. The
extension walks the entire Path tree, starting from the top PlannerInfo, then
recurses into glob::subroots, traversing each RelOptInfo and each pathlist.
Also, it traverses the path→subpath subtrees to ensure that potentially quite
complex path trees are covered when implemented as a single RelOptInfo. For each
pointer it visits, it checks if the NodeTag matches a known Path type. If not,
the memory was freed (and, with CLOBBER_FREED_MEMORY, set to 0x7F) or reused for
something else.

This approach is not free of caveats. For example, most Path nodes and many Plan
nodes fall within the 128-byte gap of the minimal allocated chunk. That means
freeing one path allows the optimiser to immediately allocate another Path node
at a potentially different query tree level. I had such a case at least once in
production. It was actually hard to realise, reproduce, and fix.

Running make check-world tests with the debug module loaded at startup revealed
many cases in which RelOptInfo structures contain dangling pointers. What
exactly do we see there?

The pathlist contents at the moment of an ‘Invalid’ path detection:

* ProjectionPath, Invalid — by far the most common, on JOIN RelOptInfos.
* ProjectionPath, Invalid, SortPath.
* AggPath, Invalid.
* NestPath, Invalid
* HashPath, Invalid
* cheapest_startup_path referencing a dangling pointer, on what looks
like a join of two partitions.
* cheapest_startup_path referencing a dangling pointer on a plain base
RelOptInfo.

The best-known problematic code example causing this issue is
apply_scanjoin_target_to_paths(), and the current_rel/final_rel game from commit
0927d2f46dd.  Quickly fixing it, I see some more combinations have emerged:

* UniquePath, Invalid
* MergePath, Invalid
* SubqueryScanPath, Invalid
* SetOpPath, Invalid
* GatherPath, Path, Invalid
* AppendPath, AggPath, Invalid, AggPath
* HashPath, Invalid
* AppendPath, HashPath, Invalid

These new invalid references occur outside the originally identified code path,
showing that fixing one place does not address the broader issue (maybe my fixes
were wrong?). While some claim that the cost-dominance principle ('the cheapest
path is never invalid') provides safety, I have not found any acknowledgment of
this. As the planner is expanded, undocumented rules leave the system vulnerable.

The purpose of this email is basically to highlight the issue and raise a
discussion on how to solve it. Ashutosh designed a 'smart pointer' approach,
which seems the most balanced and bulletproof way. Another approach: 'used' flag
seems less interesting as well as local memory contexts - we should always
remember about multi-children cases that need freeing unnecessary paths in-place
to reduce memory consumption. But before diving into the code and identifying
origins of these cases, I’d like to know: is it an actual problem, or is the
cost-dominance contract enough?

[1]
https://www.postgresql.org/message-id/CA+TgmoaPgXYYEivQWxyVV=[email-masked]
[2] https://github.com/danolivo/pg_pathcheck

-- 
regards, Andrei Lepikhov,
pgEdge

---

From: Andrei Lepikhov <[email-masked]>
Date: Tue, 21 Apr 2026 09:29:01 +0200

> 0927d2f46dd.  Quickly fixing it, I see some more combinations have emerged:

On closer inspection, it looks like all the detected cases come from the same
issue in create_ordered_paths. The ordered_rel has the same path in its pathlist
as the input_rel. Sometimes, this path is removed and freed from ordered_rel,
which leads to a dangling pointer in the child RelOptInfo.

I've attached a patch that shows how to fix the issue. Some regression tests
change because of a hidden rule where a projection and its subpath have
different target lists. Right now, the patch always enforces a projection, even
if the target lists are the same. This is still open for discussion on whether
there's a better way to handle it.

From 3bbde842ad2da44acd47170b3e9949f621102d50 Mon Sep 17 00:00:00 2001
From: "Andrei V. Lepikhov" <[email-masked]>
Date: Mon, 20 Apr 2026 17:25:27 +0200
Subject: [PATCH v0] Do not put one path into different pathlists

---
 src/backend/optimizer/plan/planner.c          | 34 ++++++++++++++-----
 src/test/regress/expected/limit.out           |  6 ++--
 .../regress/expected/select_distinct_on.out   | 26 +++++++-------
 src/test/regress/expected/select_parallel.out | 32 ++++++++---------
 src/test/regress/expected/tsrf.out            |  8 ++---
 5 files changed, 60 insertions(+), 46 deletions(-)

diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 56bb1d798e3..cd3250c9672 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -5462,7 +5462,20 @@ create_ordered_paths(PlannerInfo *root,
 												input_path->pathkeys, &presorted_keys);
 
 		if (is_sorted)
-			sorted_path = input_path;
+		{
+			/*
+			 * The input_path is already sorted; we would like to reuse it as
+			 * the ordered rel's path.  But we must not share the pointer with
+			 * input_rel->pathlist.  Wrap it in a fresh ProjectionPath.
+			 */
+			Path	   *wrap_target = input_path;
+
+			if (IsA(wrap_target, ProjectionPath))
+				wrap_target = ((ProjectionPath *) wrap_target)->subpath;
+
+			sorted_path = (Path *) create_projection_path(root, ordered_rel,
+														  wrap_target, target);
+		}
 		else
 		{
 			/*
@@ -5494,15 +5507,18 @@ create_ordered_paths(PlannerInfo *root,
 																	root->sort_pathkeys,
 																	presorted_keys,
 																	limit_tuples);
-		}
 
-		/*
-		 * If the pathtarget of the result path has different expressions from
-		 * the target to be applied, a projection step is needed.
-		 */
-		if (!equal(sorted_path->pathtarget->exprs, target->exprs))
-			sorted_path = apply_projection_to_path(root, ordered_rel,
-												   sorted_path, target);
+			/*
+			 * If the pathtarget of the result path has different expressions
+			 * from the target to be applied, a projection step is needed.
+			 * When is_sorted is true the wrap above already carries the
+			 * ordered rel's target, so this only applies to the sorted
+			 * branch.
+			 */
+			if (!equal(sorted_path->pathtarget->exprs, target->exprs))
+				sorted_path = apply_projection_to_path(root, ordered_rel,
+													   sorted_path, target);
+		}
 
 		add_path(ordered_rel, sorted_path);
 	}
diff --git a/src/test/regress/expected/limit.out b/src/test/regress/expected/limit.out
index e3bcc680653..c12b2498f65 100644
--- a/src/test/regress/expected/limit.out
+++ b/src/test/regress/expected/limit.out
@@ -439,14 +439,14 @@ select currval('testseq');
 explain (verbose, costs off)
 select unique1, unique2, generate_series(1,10)
   from tenk1 order by unique2 limit 7;
-                                                                         QUERY PLAN                                                                          
--------------------------------------------------------------------------------------------------------------------------------------------------------------
+                         QUERY PLAN                         
+------------------------------------------------------------
  Limit
    Output: unique1, unique2, (generate_series(1, 10))
    ->  ProjectSet
          Output: unique1, unique2, generate_series(1, 10)
          ->  Index Scan using tenk1_unique2 on public.tenk1
-               Output: unique1, unique2, two, four, ten, twenty, hundred, thousand, twothousand, fivethous, tenthous, odd, even, stringu1, stringu2, string4
+               Output: unique1, unique2
 (6 rows)
 
 select unique1, unique2, generate_series(1,10)
diff --git a/src/test/regress/expected/select_distinct_on.out b/src/test/regress/expected/select_distinct_on.out
index 75b1e7d300f..4ae09c8b181 100644
--- a/src/test/regress/expected/select_distinct_on.out
+++ b/src/test/regress/expected/select_distinct_on.out
@@ -81,12 +81,13 @@ select distinct on (1) floor(random()) as r, f1 from int4_tbl order by 1,2;
 EXPLAIN (COSTS OFF)
 SELECT DISTINCT ON (four) four,two
    FROM tenk1 WHERE four = 0 ORDER BY 1;
-         QUERY PLAN         
-----------------------------
- Limit
-   ->  Seq Scan on tenk1
-         Filter: (four = 0)
-(3 rows)
+            QUERY PLAN            
+----------------------------------
+ Result
+   ->  Limit
+         ->  Seq Scan on tenk1
+               Filter: (four = 0)
+(4 rows)
 
 -- and check the result of the above query is correct
 SELECT DISTINCT ON (four) four,two
@@ -114,12 +115,13 @@ SELECT DISTINCT ON (four) four,two
 EXPLAIN (COSTS OFF)
 SELECT DISTINCT ON (four) four,hundred
    FROM tenk1 WHERE four = 0 ORDER BY 1,2;
-                  QUERY PLAN                   
------------------------------------------------
- Limit
-   ->  Index Scan using tenk1_hundred on tenk1
-         Filter: (four = 0)
-(3 rows)
+                     QUERY PLAN                      
+-----------------------------------------------------
+ Result
+   ->  Limit
+         ->  Index Scan using tenk1_hundred on tenk1
+               Filter: (four = 0)
+(4 rows)
 
 --
 -- Test the planner's ability to reorder the distinctClause Pathkeys to match
diff --git a/src/test/regress/expected/select_parallel.out b/src/test/regress/expected/select_parallel.out
index 933921d1860..a3d6f3d4576 100644
--- a/src/test/regress/expected/select_parallel.out
+++ b/src/test/regress/expected/select_parallel.out
@@ -753,20 +753,18 @@ end;
 $$ language plpgsql PARALLEL SAFE;
 explain (costs off, verbose)
     select ten, sp_simple_func(ten) from tenk1 where ten < 100 order by ten;
-                     QUERY PLAN                      
------------------------------------------------------
+                  QUERY PLAN                   
+-----------------------------------------------
  Gather Merge
-   Output: ten, (sp_simple_func(ten))
+   Output: ten, sp_simple_func(ten)
    Workers Planned: 4
-   ->  Result
-         Output: ten, sp_simple_func(ten)
-         ->  Sort
+   ->  Sort
+         Output: ten
+         Sort Key: tenk1.ten
+         ->  Parallel Seq Scan on public.tenk1
                Output: ten
-               Sort Key: tenk1.ten
-               ->  Parallel Seq Scan on public.tenk1
-                     Output: ten
-                     Filter: (tenk1.ten < 100)
-(11 rows)
+               Filter: (tenk1.ten < 100)
+(9 rows)
 
 drop function sp_simple_func(integer);
 -- test handling of SRFs in targetlist (bug in 10.0)
@@ -1261,18 +1259,16 @@ SELECT generate_series(1, two), array(select generate_series(1, two))
    ->  Gather Merge
          Output: tenk1.two, tenk1.tenthous
          Workers Planned: 4
-         ->  Result
-               Output: tenk1.two, tenk1.tenthous
-               ->  Sort
+         ->  Sort
+               Output: tenk1.tenthous, tenk1.two
+               Sort Key: tenk1.tenthous
+               ->  Parallel Seq Scan on public.tenk1
                      Output: tenk1.tenthous, tenk1.two
-                     Sort Key: tenk1.tenthous
-                     ->  Parallel Seq Scan on public.tenk1
-                           Output: tenk1.tenthous, tenk1.two
    SubPlan array_1
      ->  ProjectSet
            Output: generate_series(1, tenk1.two)
            ->  Result
-(16 rows)
+(14 rows)
 
 -- must disallow pushing sort below gather when pathkey contains an SRF
 EXPLAIN (VERBOSE, COSTS OFF)
diff --git a/src/test/regress/expected/tsrf.out b/src/test/regress/expected/tsrf.out
index c4f7b187f5b..a0d295859ed 100644
--- a/src/test/regress/expected/tsrf.out
+++ b/src/test/regress/expected/tsrf.out
@@ -459,12 +459,12 @@ reset enable_hashagg;
 -- case with degenerate ORDER BY
 explain (verbose, costs off)
 select 'foo' as f, generate_series(1,2) as g from few order by 1;
-                  QUERY PLAN                  
-----------------------------------------------
+                   QUERY PLAN                   
+------------------------------------------------
  ProjectSet
-   Output: 'foo'::text, generate_series(1, 2)
+   Output: ('foo'::text), generate_series(1, 2)
    ->  Seq Scan on public.few
-         Output: id, dataa, datab
+         Output: 'foo'::text
 (4 rows)
 
 select 'foo' as f, generate_series(1,2) as g from few order by 1;
-- 
2.53.0

---

From: David Rowley <[email-masked]>
Date: Tue, 21 Apr 2026 20:35:42 +1200

> there's a better way to handle it.

IMO, we should write a function like copy_path() or reparent_path(),
which creates a copy of the given Path, or the latter also would copy
then set the ->parent to the given RelOptInfo.  Any time we use a path
directly from the pathlist of another RelOptInfo, we should reparent
or copy it. We could add an Assert in add_path() to check the new path
has the correct parent to help us find the places where we forget to
do this.

David

---

From: Alena Rybakina <[email-masked]>
Date: Tue, 21 Apr 2026 11:46:33 +0300

> production. It was actually hard to realise, reproduce, and fix.

Hi! I raised such a problem before in this thread and proposed a patch 
to delete freed refused paths from pathlist.

You can find it here [0] of you are interested.

[0] 
https://www.postgresql.org/message-id/flat/CAExHW5uhc5JVOUExjo24oYLLcJAyD04%2BBRb080sV08pO_%3D7w%3DA%40mail.gmail.com#2c0c5f2aca79753e1c8886d3f54e7d25

-- 
-----------
Best regards,
Alena Rybakina

---

From: Andrei Lepikhov <[email-masked]>
Date: Tue, 21 Apr 2026 10:54:21 +0200

> has the correct parent to help us find the places where we forget to

It would be great to have a copy_path() function. At the moment, I create a
limited version each time in an extension module, using
reparameterize_path_by_child as a guide since it ensures the core can handle
path copies.
Do you mean we can introduce such a copy routine to fix current issue? Here is
the problem: dangling pointers are detected only by external tools. I can't
imagine an SQL reproducer to test this machinery.

---

From: David Rowley <[email-masked]>
Date: Tue, 21 Apr 2026 22:45:10 +1200

> imagine an SQL reproducer to test this machinery.

I had anticipated that we'd only fix in master as we'd probably need a
new callback in CustomPathMethods.

David

---

From: Andrei Lepikhov <[email-masked]>
Date: Mon, 27 Apr 2026 10:19:46 +0200

> has the correct parent to help us find the places where we forget to

I've attached the patch so we can keep the discussion going.

I used a shallow copy since re-parenting is not that obvious. As I see it, the
parent pointer does not indicate ownership. Instead, it points to the source
operator (RelOptInfo). For instance, createplan.c uses it to get the relid of
the scan operation.

Should the parent point to the pathlist's owner? Possibly, but right now I am
not sure how introducing such an explicit contract would affect the optimiser.

This issue can't be explicitly reproduced with the current optimiser without
deep code intervention. So, if tests are needed, I propose a minor debug-only
check inside the plan-building code: with best_path, we can scan RelOptInfo's
pathlist and partial_pathlist to detect dangling pointers. It seems not stable
enough, so I just left the patch without a test infrastructure.

From 7183d587a69860663d0d0cc90170b645a6bed0a5 Mon Sep 17 00:00:00 2001
From: "Andrei V. Lepikhov" <[email-masked]>
Date: Mon, 27 Apr 2026 09:54:11 +0200
Subject: [PATCH] Fix dangling Path pointer in create_ordered_paths

When create_ordered_paths() found that an input path was already
sufficiently sorted, it set sorted_path = input_path, aliasing the
pointer with input_rel->pathlist. It causes the situation when one path exists
in pathlists of two RelOptInfo's that might cause use-after-free if upper rel
would wash out and free this path later.

Fix by introducing a shallow-copy helper, copy_path(), that returns a fresh
palloc'd Path of the same concrete type as its argument, with all fields copied
and pointer-typed substructure (subpaths, lists, pathtargets, ...) shared with
the original.  Use copy_path(input_path) at the is_sorted short-cut so that any
subsequent in-place mutation or pfree affects only the ordered_rel-owned node.

For T_CustomPath the helper dispatches to a new optional CopyCustomPath callback
in CustomPathMethods, falling back to a sizeof(CustomPath) memcpy when
the extension does not supply one. Extensions that subclass CustomPath with
private trailing fields should implement the callback.  The CustomPathMethods
extension is an ABI change, so this fix is master-only as committed; a back-
branch variant could simply elog on T_CustomPath.

Discussion: https://postgr.es/m/adab9758-f346-4263-93af-3e37b7b315b7%40gmail.com
---
 src/backend/optimizer/plan/planner.c  |   2 +-
 src/backend/optimizer/util/pathnode.c | 158 +++++++++++++++++++++++++-
 src/include/nodes/extensible.h        |   7 ++
 src/include/optimizer/pathnode.h      |   1 +
 4 files changed, 166 insertions(+), 2 deletions(-)

diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 4ec76ce31a9..33dc2eb2e75 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -5440,7 +5440,7 @@ create_ordered_paths(PlannerInfo *root,
 												input_path->pathkeys, &presorted_keys);
 
 		if (is_sorted)
-			sorted_path = input_path;
+			sorted_path = copy_path(input_path);
 		else
 		{
 			/*
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 73518c8f870..5eae724db80 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -237,6 +237,161 @@ compare_path_costs_fuzzily(Path *path1, Path *path2, double fuzz_factor)
 #undef CONSIDER_PATH_STARTUP_COST
 }
 
+/*
+ * copy_path
+ *		Make a shallow copy of a Path node.
+ *
+ * The new node deliberately retains path->parent.  The parent field is not an
+ * ownership marker — it is a stable identity link. For example, it is used in
+ * createplan.c to identify base relation.
+ *
+ * For T_CustomPath, we dispatch to the optional CopyCustomPath callback if
+ * provided; otherwise we fall back to a sizeof(CustomPath) memcpy, which is
+ * correct only when the extension hasn't appended its own private fields.
+ *
+ * Note: this is intentionally shallower than copyObject() (which deep-copies
+ * sublists and substructure) and lighter than reparameterize_path() (which
+ * re-runs the constructor and re-costs).  We need only a fresh top-level
+ * node so add_path()'s pfree and apply_projection_to_path()'s in-place
+ * mutation cannot affect the original.
+ */
+Path *
+copy_path(Path *path)
+{
+	Path	   *newpath;
+
+	Assert(path != NULL);
+
+#define FLAT_COPY_PATH(newnode_, node_, nodetype_) \
+	( (newnode_) = (Path *) palloc(sizeof(nodetype_)), \
+	  memcpy((newnode_), (node_), sizeof(nodetype_)) )
+
+	switch (nodeTag(path))
+	{
+		case T_Path:
+			FLAT_COPY_PATH(newpath, path, Path);
+			break;
+		case T_IndexPath:
+			FLAT_COPY_PATH(newpath, path, IndexPath);
+			break;
+		case T_BitmapHeapPath:
+			FLAT_COPY_PATH(newpath, path, BitmapHeapPath);
+			break;
+		case T_BitmapAndPath:
+			FLAT_COPY_PATH(newpath, path, BitmapAndPath);
+			break;
+		case T_BitmapOrPath:
+			FLAT_COPY_PATH(newpath, path, BitmapOrPath);
+			break;
+		case T_TidPath:
+			FLAT_COPY_PATH(newpath, path, TidPath);
+			break;
+		case T_TidRangePath:
+			FLAT_COPY_PATH(newpath, path, TidRangePath);
+			break;
+		case T_SubqueryScanPath:
+			FLAT_COPY_PATH(newpath, path, SubqueryScanPath);
+			break;
+		case T_ForeignPath:
+			FLAT_COPY_PATH(newpath, path, ForeignPath);
+			break;
+		case T_CustomPath:
+			{
+				CustomPath *cpath = (CustomPath *) path;
+
+				Assert(cpath->methods != NULL);
+				if (cpath->methods->CopyCustomPath)
+					newpath = (Path *) cpath->methods->CopyCustomPath(cpath);
+				else
+					FLAT_COPY_PATH(newpath, path, CustomPath);
+			}
+			break;
+		case T_AppendPath:
+			FLAT_COPY_PATH(newpath, path, AppendPath);
+			break;
+		case T_MergeAppendPath:
+			FLAT_COPY_PATH(newpath, path, MergeAppendPath);
+			break;
+		case T_GroupResultPath:
+			FLAT_COPY_PATH(newpath, path, GroupResultPath);
+			break;
+		case T_MaterialPath:
+			FLAT_COPY_PATH(newpath, path, MaterialPath);
+			break;
+		case T_MemoizePath:
+			FLAT_COPY_PATH(newpath, path, MemoizePath);
+			break;
+		case T_GatherPath:
+			FLAT_COPY_PATH(newpath, path, GatherPath);
+			break;
+		case T_GatherMergePath:
+			FLAT_COPY_PATH(newpath, path, GatherMergePath);
+			break;
+		case T_NestPath:
+			FLAT_COPY_PATH(newpath, path, NestPath);
+			break;
+		case T_MergePath:
+			FLAT_COPY_PATH(newpath, path, MergePath);
+			break;
+		case T_HashPath:
+			FLAT_COPY_PATH(newpath, path, HashPath);
+			break;
+		case T_ProjectionPath:
+			FLAT_COPY_PATH(newpath, path, ProjectionPath);
+			break;
+		case T_ProjectSetPath:
+			FLAT_COPY_PATH(newpath, path, ProjectSetPath);
+			break;
+		case T_SortPath:
+			FLAT_COPY_PATH(newpath, path, SortPath);
+			break;
+		case T_IncrementalSortPath:
+			FLAT_COPY_PATH(newpath, path, IncrementalSortPath);
+			break;
+		case T_GroupPath:
+			FLAT_COPY_PATH(newpath, path, GroupPath);
+			break;
+		case T_UniquePath:
+			FLAT_COPY_PATH(newpath, path, UniquePath);
+			break;
+		case T_AggPath:
+			FLAT_COPY_PATH(newpath, path, AggPath);
+			break;
+		case T_GroupingSetsPath:
+			FLAT_COPY_PATH(newpath, path, GroupingSetsPath);
+			break;
+		case T_MinMaxAggPath:
+			FLAT_COPY_PATH(newpath, path, MinMaxAggPath);
+			break;
+		case T_WindowAggPath:
+			FLAT_COPY_PATH(newpath, path, WindowAggPath);
+			break;
+		case T_SetOpPath:
+			FLAT_COPY_PATH(newpath, path, SetOpPath);
+			break;
+		case T_RecursiveUnionPath:
+			FLAT_COPY_PATH(newpath, path, RecursiveUnionPath);
+			break;
+		case T_LockRowsPath:
+			FLAT_COPY_PATH(newpath, path, LockRowsPath);
+			break;
+		case T_ModifyTablePath:
+			FLAT_COPY_PATH(newpath, path, ModifyTablePath);
+			break;
+		case T_LimitPath:
+			FLAT_COPY_PATH(newpath, path, LimitPath);
+			break;
+		default:
+			elog(ERROR, "unrecognized path type: %d", (int) nodeTag(path));
+			newpath = NULL;		/* keep compiler quiet */
+			break;
+	}
+
+#undef FLAT_COPY_PATH
+
+	return newpath;
+}
+
 /*
  * set_cheapest
  *	  Find the minimum-cost paths from among a relation's paths,
@@ -2678,7 +2833,8 @@ create_projection_path(PlannerInfo *root,
  * a separate Result plan node isn't needed, we just replace the given path's
  * pathtarget with the desired one.  This must be used only when the caller
  * knows that the given path isn't referenced elsewhere and so can be modified
- * in-place.
+ * in-place.  In particular, callers must not pass a Path that is currently
+ * reachable from another RelOptInfo's pathlist.
  *
  * If the input path is a GatherPath or GatherMergePath, we try to push the
  * new target down to its input as well; this is a yet more invasive
diff --git a/src/include/nodes/extensible.h b/src/include/nodes/extensible.h
index 517db95c4a3..762b09976f5 100644
--- a/src/include/nodes/extensible.h
+++ b/src/include/nodes/extensible.h
@@ -103,6 +103,13 @@ typedef struct CustomPathMethods
 	struct List *(*ReparameterizeCustomPathByChild) (PlannerInfo *root,
 													 List *custom_private,
 													 RelOptInfo *child_rel);
+
+	/*
+	 * Produce a shallow copy of a CustomPath, returning a freshly palloc'd
+	 * struct of the extension's concrete type.  Required when the extension's
+	 * CustomPath subclass embeds private fields beyond sizeof(CustomPath).
+	 */
+	struct CustomPath *(*CopyCustomPath) (struct CustomPath *path);
 }			CustomPathMethods;
 
 /*
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index e8db321f92b..fbafdfd1e6f 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -55,6 +55,7 @@ extern int	compare_path_costs(Path *path1, Path *path2,
 extern int	compare_fractional_path_costs(Path *path1, Path *path2,
 										  double fraction);
 extern void set_cheapest(RelOptInfo *parent_rel);
+extern Path *copy_path(Path *path);
 extern void add_path(RelOptInfo *parent_rel, Path *new_path);
 extern bool add_path_precheck(RelOptInfo *parent_rel, int disabled_nodes,
 							  Cost startup_cost, Cost total_cost,
-- 
2.54.0

---

From: Andrei Lepikhov <[email-masked]>
Date: Wed, 29 Apr 2026 14:43:02 +0200

> I've attached the patch so we can keep the discussion going.

While playing with random path choices [1], I found additional cases where a
path is assigned to two different RelOptInfos. See the attachment for a modified
patch.

[1] https://github.com/danolivo/pg-chaos-test

From 01c335316fa1ad0905d5e9f2f182eff824e24ff2 Mon Sep 17 00:00:00 2001
From: "Andrei V. Lepikhov" <[email-masked]>
Date: Mon, 27 Apr 2026 09:54:11 +0200
Subject: [PATCH v1] Fix dangling Path pointers when sharing paths across
 RelOptInfos

In three places the planner reused the same Path pointer across two RelOptInfo
pathlists without making a copy:

- create_ordered_paths() set sorted_path = input_path when the path was already
sorted, placing the same node in both input_rel->pathlist and
ordered_rel->pathlist.
- grouping_planner() passed paths from current_rel directly into add_path and
add_partial_path.

Since add_path() may pfree paths it displaces, and apply_projection_to_path()
may mutate a path in-place, a path shared between two rels can be freed or
corrupted while still referenced by the other.

Introduce copy_path(), a type-aware shallow copy (palloc + memcpy at the
concrete struct size), and use it at all three sites so each rel owns an
independent top-level node. For T_CustomPath, dispatch to a new optional
CopyCustomPath callback in CustomPathMethods; fall back to sizeof(CustomPath)
memcpy when the callback is not provided. Extensions embedding private
fields beyond sizeof(CustomPath) should implement the callback.

Discussion: https://postgr.es/m/adab9758-f346-4263-93af-3e37b7b315b7%40gmail.com
---
 src/backend/optimizer/plan/planner.c  |   6 +-
 src/backend/optimizer/util/pathnode.c | 158 +++++++++++++++++++++++++-
 src/include/nodes/extensible.h        |   7 ++
 src/include/optimizer/pathnode.h      |   1 +
 4 files changed, 168 insertions(+), 4 deletions(-)

diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 4ec76ce31a9..e920cbbaa8f 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -2226,7 +2226,7 @@ grouping_planner(PlannerInfo *root, double tuple_fraction,
 		}
 
 		/* And shove it into final_rel */
-		add_path(final_rel, path);
+		add_path(final_rel, copy_path(path));
 	}
 
 	/*
@@ -2241,7 +2241,7 @@ grouping_planner(PlannerInfo *root, double tuple_fraction,
 		{
 			Path	   *partial_path = (Path *) lfirst(lc);
 
-			add_partial_path(final_rel, partial_path);
+			add_partial_path(final_rel, copy_path(partial_path));
 		}
 	}

 		if (is_sorted)
 
+/*
+ * copy_path
 
 /*