I read in the SQL standard that SELECT * in EXISTS is not supposed to be
expanded to all columns, but only to an arbitrary literal. This
corresponds to the recommendation in the PostgreSQL documentation to
write EXISTS (SELECT 1 ...) instead. But not even our own tests and
example code use that latter convention consistently, so I think many
users don't know it or observe it either. So implementing that little
optimization for SELECT * seems reasonable.
The attached patch implements the transformation, meaning in EXISTS
(SELECT * FROM ...), the star is replaced by an empty select list
(taking advantage of the support for zero-column tables in PostgreSQL).
There are plenty of tests involving this construct, so I didn't add any
more explicit tests. (But it might be worth adding a test involving
column privileges.)
Thoughts?
From 5391f1b8a8f8a7bb2fc0e3c6fc355fc0a147849d Mon Sep 17 00:00:00 2001
From: Peter Eisentraut <[email protected]>
Date: Mon, 23 Feb 2026 08:43:39 +0100
Subject: [PATCH] Optimize SELECT * in EXISTS
Transform a SELECT * inside an EXISTS into an empty select list. This
allows writing
EXISTS (SELECT * FROM ...)
which is a common convention, without the overhead of expanding all
the columns.
The PostgreSQL documentation suggests writing EXISTS (SELECT 1 FROM
...), but not even all our tests and example code use that, so it's
probably not universally known.
The SQL standard also specifies this optimization. (It says that *
expands to an arbitrary literal, but we can use an empty select list.)
---
doc/src/sgml/func/func-subquery.sgml | 21 +++++++++++++++------
src/backend/parser/parse_expr.c | 27 +++++++++++++++++++++++++++
2 files changed, 42 insertions(+), 6 deletions(-)
diff --git a/doc/src/sgml/func/func-subquery.sgml
b/doc/src/sgml/func/func-subquery.sgml
index a9f2b12e48c..d51d32baa9c 100644
--- a/doc/src/sgml/func/func-subquery.sgml
+++ b/doc/src/sgml/func/func-subquery.sgml
@@ -66,12 +66,21 @@ <title><literal>EXISTS</literal></title>
</para>
<para>
- Since the result depends only on whether any rows are returned,
- and not on the contents of those rows, the output list of the
- subquery is normally unimportant. A common coding convention is
- to write all <literal>EXISTS</literal> tests in the form
- <literal>EXISTS(SELECT 1 WHERE ...)</literal>. There are exceptions to
- this rule however, such as subqueries that use <token>INTERSECT</token>.
+ Since the result depends only on whether any rows are returned, and not on
+ the contents of those rows, the output list of the subquery is normally
+ unimportant. A possible coding convention is to write all
+ <literal>EXISTS</literal> tests in the form <literal>EXISTS(SELECT * FROM
+ ... WHERE ...)</literal>. The <literal>*</literal> is optimized away in
+ this case, and no columns are actually fetched. (This optimization only
+ applies to output lists consisting solely of a single asterisk.) Another
+ common convention is to write <literal>EXISTS(SELECT 1 FROM ... WHERE
+ ...)</literal> or some other dummy constant. (Another alternative is to
+ omit the select list altogether (<literal>EXISTS(SELECT FROM ... WHERE
+ ...</literal>), but that is a PostgreSQL extension and would make your code
+ less portable and arguably harder to read.) These conventions are not
+ always applicable, such as in subqueries that use <token>INTERSECT</token>,
+ where the output list of the subquery affects the result of the subquery
+ itself.
</para>
<para>
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index dcfe1acc4c3..cb2239cb250 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -1888,6 +1888,33 @@ transformSubLink(ParseState *pstate, SubLink *sublink)
pstate->p_hasSubLinks = true;
+ /*
+ * If EXISTS(SELECT * ...), remove the output list. (See SQL:2023
<query
+ * specification> SR 7.)
+ */
+ if (sublink->subLinkType == EXISTS_SUBLINK)
+ {
+ if (IsA(sublink->subselect, SelectStmt))
+ {
+ SelectStmt *s = castNode(SelectStmt,
sublink->subselect);
+
+ if (list_length(s->targetList) == 1)
+ {
+ ResTarget *rt = linitial_node(ResTarget,
s->targetList);
+
+ if (IsA(rt->val, ColumnRef) && !rt->name &&
!rt->indirection)
+ {
+ ColumnRef *cr = castNode(ColumnRef,
rt->val);
+
+ if (list_length(cr->fields) == 1 &&
IsA(linitial(cr->fields), A_Star))
+ {
+ s->targetList = NIL;
+ }
+ }
+ }
+ }
+ }
+
/*
* OK, let's transform the sub-SELECT.
*/
--
2.53.0