From b577803b9c61c2618d6ba326828c641fff932d7a Mon Sep 17 00:00:00 2001
From: Nik Samokhvalov <nik@postgres.ai>
Date: Sat, 27 Dec 2025 19:20:52 +0000
Subject: [PATCH] pg_stat_statements: Add rows_filtered column

Add a new rows_filtered column to pg_stat_statements that tracks the
total number of rows removed by filter conditions across all plan nodes.
This helps identify queries that would benefit from better indexing.

The feature is controlled by a new GUC pg_stat_statements.track_rows_filtered
(default: off). When enabled, the module uses INSTRUMENT_ROWS instrumentation
to collect nfiltered1 (scan/join qual) and nfiltered2 (recheck conditions)
from all plan nodes via planstate_tree_walker.

Key implementation details:
- Uses INSTRUMENT_ROWS (not INSTRUMENT_ALL) to avoid timing overhead
- Only increments tuple counters, which has negligible performance impact
- GUC is superuser-only (PGC_SUSET), can be changed at runtime
- Stats file header bumped to invalidate old stats files

This addresses a common DBA need to identify inefficient sequential scans
where adding an index could significantly improve performance.
---
 contrib/pg_stat_statements/Makefile           |   1 +
 .../expected/rows_filtered.out                | 208 ++++++++++++++++++
 contrib/pg_stat_statements/meson.build        |   2 +
 .../pg_stat_statements--1.13--1.14.sql        |  79 +++++++
 .../pg_stat_statements/pg_stat_statements.c   | 110 ++++++++-
 .../pg_stat_statements.control                |   2 +-
 .../pg_stat_statements/sql/rows_filtered.sql  | 127 +++++++++++
 doc/src/sgml/pgstatstatements.sgml            |  35 +++
 8 files changed, 561 insertions(+), 3 deletions(-)
 create mode 100644 contrib/pg_stat_statements/expected/rows_filtered.out
 create mode 100644 contrib/pg_stat_statements/pg_stat_statements--1.13--1.14.sql
 create mode 100644 contrib/pg_stat_statements/sql/rows_filtered.sql

diff --git a/contrib/pg_stat_statements/Makefile b/contrib/pg_stat_statements/Makefile
index fe0478ac55266..ee41ee0311e70 100644
--- a/contrib/pg_stat_statements/Makefile
+++ b/contrib/pg_stat_statements/Makefile
@@ -7,6 +7,7 @@ OBJS = \
 
 EXTENSION = pg_stat_statements
 DATA = pg_stat_statements--1.4.sql \
+	pg_stat_statements--1.13--1.14.sql \
 	pg_stat_statements--1.12--1.13.sql \
 	pg_stat_statements--1.11--1.12.sql pg_stat_statements--1.10--1.11.sql \
 	pg_stat_statements--1.9--1.10.sql pg_stat_statements--1.8--1.9.sql \
diff --git a/contrib/pg_stat_statements/expected/rows_filtered.out b/contrib/pg_stat_statements/expected/rows_filtered.out
new file mode 100644
index 0000000000000..a1665e0ececb3
--- /dev/null
+++ b/contrib/pg_stat_statements/expected/rows_filtered.out
@@ -0,0 +1,208 @@
+--
+-- Test rows_filtered tracking
+--
+-- rows_filtered tracks the number of rows removed by filter conditions
+-- across all plan nodes. This requires pg_stat_statements.track_rows_filtered
+-- to be enabled.
+-- Ensure the extension is set up
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t
+---
+ t
+(1 row)
+
+-- Create test table
+CREATE TABLE filter_test (id int PRIMARY KEY, val int);
+INSERT INTO filter_test SELECT g, g FROM generate_series(1, 1000) g;
+ANALYZE filter_test;
+--
+-- Test 1: rows_filtered is 0 when tracking is disabled (default)
+--
+SHOW pg_stat_statements.track_rows_filtered;
+ pg_stat_statements.track_rows_filtered
+----------------------------------------
+ off
+(1 row)
+
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t
+---
+ t
+(1 row)
+
+-- Force a sequential scan with filter
+SET enable_indexscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM filter_test WHERE val > 900;
+ count
+-------
+   100
+(1 row)
+
+RESET enable_indexscan;
+RESET enable_bitmapscan;
+-- rows_filtered should be 0 because tracking is disabled
+SELECT query, calls, rows, rows_filtered
+FROM pg_stat_statements
+WHERE query LIKE '%filter_test WHERE val%'
+ORDER BY query COLLATE "C";
+                       query                       | calls | rows | rows_filtered
+---------------------------------------------------+-------+------+---------------
+ SELECT count(*) FROM filter_test WHERE val > $1   |     1 |    1 |             0
+(1 row)
+
+--
+-- Test 2: rows_filtered is tracked when enabled
+--
+SET pg_stat_statements.track_rows_filtered = on;
+SHOW pg_stat_statements.track_rows_filtered;
+ pg_stat_statements.track_rows_filtered
+----------------------------------------
+ on
+(1 row)
+
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t
+---
+ t
+(1 row)
+
+-- Force a sequential scan with filter: 100 rows pass (val > 900), 900 filtered
+SET enable_indexscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM filter_test WHERE val > 900;
+ count
+-------
+   100
+(1 row)
+
+RESET enable_indexscan;
+RESET enable_bitmapscan;
+-- rows_filtered should be 900 (the 900 rows that didn't pass the filter)
+SELECT query, calls, rows, rows_filtered
+FROM pg_stat_statements
+WHERE query LIKE '%filter_test WHERE val%'
+ORDER BY query COLLATE "C";
+                       query                       | calls | rows | rows_filtered
+---------------------------------------------------+-------+------+---------------
+ SELECT count(*) FROM filter_test WHERE val > $1   |     1 |    1 |           900
+(1 row)
+
+--
+-- Test 3: rows_filtered accumulates across multiple executions
+--
+SELECT count(*) FROM filter_test WHERE val > 900;
+ count
+-------
+   100
+(1 row)
+
+SELECT count(*) FROM filter_test WHERE val > 900;
+ count
+-------
+   100
+(1 row)
+
+-- After 3 total executions, rows_filtered remains 900. The subsequent
+-- executions used an index scan which accesses only matching rows
+-- directly, so no additional rows are filtered.
+SELECT query, calls, rows, rows_filtered
+FROM pg_stat_statements
+WHERE query LIKE '%filter_test WHERE val%'
+ORDER BY query COLLATE "C";
+                       query                       | calls | rows | rows_filtered
+---------------------------------------------------+-------+------+---------------
+ SELECT count(*) FROM filter_test WHERE val > $1   |     3 |    3 |           900
+(1 row)
+
+--
+-- Test 4: Different filter selectivities
+--
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t
+---
+ t
+(1 row)
+
+SET enable_indexscan = off;
+SET enable_bitmapscan = off;
+-- Highly selective: 1 row passes, 999 filtered
+SELECT * FROM filter_test WHERE val = 500;
+ id  | val
+-----+-----
+ 500 | 500
+(1 row)
+
+-- Low selectivity: 500 rows pass, 500 filtered
+SELECT count(*) FROM filter_test WHERE val > 500;
+ count
+-------
+   500
+(1 row)
+
+RESET enable_indexscan;
+RESET enable_bitmapscan;
+SELECT query, calls, rows, rows_filtered
+FROM pg_stat_statements
+WHERE query LIKE '%filter_test WHERE val%'
+ORDER BY query COLLATE "C";
+                       query                       | calls | rows | rows_filtered
+---------------------------------------------------+-------+------+---------------
+ SELECT * FROM filter_test WHERE val = $1          |     1 |    1 |           999
+ SELECT count(*) FROM filter_test WHERE val > $1   |     1 |    1 |           500
+(2 rows)
+
+--
+-- Test 5: Verify rows_filtered with JOIN queries
+--
+CREATE TABLE filter_test2 (id int PRIMARY KEY, ref_id int);
+INSERT INTO filter_test2 SELECT g, g % 100 FROM generate_series(1, 500) g;
+ANALYZE filter_test2;
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t
+---
+ t
+(1 row)
+
+SET enable_indexscan = off;
+SET enable_bitmapscan = off;
+SET enable_hashjoin = off;
+SET enable_mergejoin = off;
+-- Nested loop join with filter on inner table
+SELECT count(*)
+FROM filter_test2 t2
+JOIN filter_test t1 ON t1.id = t2.ref_id
+WHERE t1.val < 50;
+ count
+-------
+   245
+(1 row)
+
+RESET enable_indexscan;
+RESET enable_bitmapscan;
+RESET enable_hashjoin;
+RESET enable_mergejoin;
+-- rows_filtered should be large (many rows scanned but filtered by join/filter).
+-- Use threshold check to avoid plan-dependent exact values.
+SELECT query, calls, rows, rows_filtered > 0 AS has_filtered_rows
+FROM pg_stat_statements
+WHERE query LIKE '%filter_test2%'
+  AND query LIKE '%JOIN%'
+ORDER BY query COLLATE "C";
+                                         query                                          | calls | rows | has_filtered_rows
+----------------------------------------------------------------------------------------+-------+------+-------------------
+ SELECT count(*)                                                                       +|     1 |    1 | t
+ FROM filter_test2 t2                                                                  +|       |      |
+ JOIN filter_test t1 ON t1.id = t2.ref_id                                              +|       |      |
+ WHERE t1.val < $1                                                                      |       |      |
+(1 row)
+
+-- Cleanup
+RESET pg_stat_statements.track_rows_filtered;
+DROP TABLE filter_test, filter_test2;
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t
+---
+ t
+(1 row)
+
diff --git a/contrib/pg_stat_statements/meson.build b/contrib/pg_stat_statements/meson.build
index 7b8bfbb1de78c..2702a76e3cc21 100644
--- a/contrib/pg_stat_statements/meson.build
+++ b/contrib/pg_stat_statements/meson.build
@@ -21,6 +21,7 @@ contrib_targets += pg_stat_statements
 install_data(
   'pg_stat_statements.control',
   'pg_stat_statements--1.4.sql',
+  'pg_stat_statements--1.13--1.14.sql',
   'pg_stat_statements--1.12--1.13.sql',
   'pg_stat_statements--1.11--1.12.sql',
   'pg_stat_statements--1.10--1.11.sql',
@@ -56,6 +57,7 @@ tests += {
       'extended',
       'parallel',
       'plancache',
+      'rows_filtered',
       'cleanup',
       'oldextversions',
       'squashing',
diff --git a/contrib/pg_stat_statements/pg_stat_statements--1.13--1.14.sql b/contrib/pg_stat_statements/pg_stat_statements--1.13--1.14.sql
new file mode 100644
index 0000000000000..97979270e8f86
--- /dev/null
+++ b/contrib/pg_stat_statements/pg_stat_statements--1.13--1.14.sql
@@ -0,0 +1,79 @@
+/* contrib/pg_stat_statements/pg_stat_statements--1.13--1.14.sql */
+
+-- complain if script is sourced in psql, rather than via ALTER EXTENSION
+\echo Use "ALTER EXTENSION pg_stat_statements UPDATE TO '1.14'" to load this file. \quit
+
+/* First we have to remove them from the extension */
+ALTER EXTENSION pg_stat_statements DROP VIEW pg_stat_statements;
+ALTER EXTENSION pg_stat_statements DROP FUNCTION pg_stat_statements(boolean);
+
+/* Then we can drop them */
+DROP VIEW pg_stat_statements;
+DROP FUNCTION pg_stat_statements(boolean);
+
+/* Now redefine */
+CREATE FUNCTION pg_stat_statements(IN showtext boolean,
+    OUT userid oid,
+    OUT dbid oid,
+    OUT toplevel bool,
+    OUT queryid bigint,
+    OUT query text,
+    OUT plans int8,
+    OUT total_plan_time float8,
+    OUT min_plan_time float8,
+    OUT max_plan_time float8,
+    OUT mean_plan_time float8,
+    OUT stddev_plan_time float8,
+    OUT calls int8,
+    OUT total_exec_time float8,
+    OUT min_exec_time float8,
+    OUT max_exec_time float8,
+    OUT mean_exec_time float8,
+    OUT stddev_exec_time float8,
+    OUT rows int8,
+    OUT rows_filtered int8,
+    OUT shared_blks_hit int8,
+    OUT shared_blks_read int8,
+    OUT shared_blks_dirtied int8,
+    OUT shared_blks_written int8,
+    OUT local_blks_hit int8,
+    OUT local_blks_read int8,
+    OUT local_blks_dirtied int8,
+    OUT local_blks_written int8,
+    OUT temp_blks_read int8,
+    OUT temp_blks_written int8,
+    OUT shared_blk_read_time float8,
+    OUT shared_blk_write_time float8,
+    OUT local_blk_read_time float8,
+    OUT local_blk_write_time float8,
+    OUT temp_blk_read_time float8,
+    OUT temp_blk_write_time float8,
+    OUT wal_records int8,
+    OUT wal_fpi int8,
+    OUT wal_bytes numeric,
+    OUT wal_buffers_full int8,
+    OUT jit_functions int8,
+    OUT jit_generation_time float8,
+    OUT jit_inlining_count int8,
+    OUT jit_inlining_time float8,
+    OUT jit_optimization_count int8,
+    OUT jit_optimization_time float8,
+    OUT jit_emission_count int8,
+    OUT jit_emission_time float8,
+    OUT jit_deform_count int8,
+    OUT jit_deform_time float8,
+    OUT parallel_workers_to_launch int8,
+    OUT parallel_workers_launched int8,
+    OUT generic_plan_calls int8,
+    OUT custom_plan_calls int8,
+    OUT stats_since timestamp with time zone,
+    OUT minmax_stats_since timestamp with time zone
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_stat_statements_1_14'
+LANGUAGE C STRICT VOLATILE PARALLEL SAFE;
+
+CREATE VIEW pg_stat_statements AS
+  SELECT * FROM pg_stat_statements(true);
+
+GRANT SELECT ON pg_stat_statements TO PUBLIC;
diff --git a/contrib/pg_stat_statements/pg_stat_statements.c b/contrib/pg_stat_statements/pg_stat_statements.c
index 39208f80b5bb7..6867402532eff 100644
--- a/contrib/pg_stat_statements/pg_stat_statements.c
+++ b/contrib/pg_stat_statements/pg_stat_statements.c
@@ -56,6 +56,7 @@
 #include "jit/jit.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/planner.h"
 #include "parser/analyze.h"
@@ -86,7 +87,7 @@ PG_MODULE_MAGIC_EXT(
 #define PGSS_TEXT_FILE	PG_STAT_TMP_DIR "/pgss_query_texts.stat"
 
 /* Magic number identifying the stats file format */
-static const uint32 PGSS_FILE_HEADER = 0x20250731;
+static const uint32 PGSS_FILE_HEADER = 0x20251227;
 
 /* PostgreSQL major version number, changes in which invalidate all entries */
 static const uint32 PGSS_PG_MAJOR_VERSION = PG_VERSION_NUM / 100;
@@ -116,6 +117,7 @@ typedef enum pgssVersion
 	PGSS_V1_11,
 	PGSS_V1_12,
 	PGSS_V1_13,
+	PGSS_V1_14,
 } pgssVersion;
 
 typedef enum pgssStoreKind
@@ -166,6 +168,7 @@ typedef struct Counters
 	double		sum_var_time[PGSS_NUMKIND]; /* sum of variances in
 											 * planning/execution time in msec */
 	int64		rows;			/* total # of retrieved or affected rows */
+	int64		rows_filtered;	/* # of rows removed by filter conditions */
 	int64		shared_blks_hit;	/* # of shared buffer hits */
 	int64		shared_blks_read;	/* # of shared disk blocks read */
 	int64		shared_blks_dirtied;	/* # of shared disk blocks dirtied */
@@ -300,6 +303,8 @@ static int	pgss_track = PGSS_TRACK_TOP;	/* tracking level */
 static bool pgss_track_utility = true;	/* whether to track utility commands */
 static bool pgss_track_planning = false;	/* whether to track planning
 											 * duration */
+static bool pgss_track_rows_filtered = false;	/* whether to track rows
+												 * removed by filter conditions */
 static bool pgss_save = true;	/* whether to save stats across shutdown */
 
 #define pgss_enabled(level) \
@@ -327,6 +332,7 @@ PG_FUNCTION_INFO_V1(pg_stat_statements_1_10);
 PG_FUNCTION_INFO_V1(pg_stat_statements_1_11);
 PG_FUNCTION_INFO_V1(pg_stat_statements_1_12);
 PG_FUNCTION_INFO_V1(pg_stat_statements_1_13);
+PG_FUNCTION_INFO_V1(pg_stat_statements_1_14);
 PG_FUNCTION_INFO_V1(pg_stat_statements);
 PG_FUNCTION_INFO_V1(pg_stat_statements_info);
 
@@ -355,6 +361,7 @@ static void pgss_store(const char *query, int64 queryId,
 					   int query_location, int query_len,
 					   pgssStoreKind kind,
 					   double total_time, uint64 rows,
+					   int64 rows_filtered,
 					   const BufferUsage *bufusage,
 					   const WalUsage *walusage,
 					   const struct JitInstrumentation *jitusage,
@@ -457,6 +464,17 @@ _PG_init(void)
 							 NULL,
 							 NULL);
 
+	DefineCustomBoolVariable("pg_stat_statements.track_rows_filtered",
+							 "Selects whether rows removed by filter conditions are tracked.",
+							 NULL,
+							 &pgss_track_rows_filtered,
+							 false,
+							 PGC_SUSET,
+							 0,
+							 NULL,
+							 NULL,
+							 NULL);
+
 	DefineCustomBoolVariable("pg_stat_statements.save",
 							 "Save pg_stat_statements statistics across server shutdowns.",
 							 NULL,
@@ -878,6 +896,7 @@ pgss_post_parse_analyze(ParseState *pstate, Query *query, JumbleState *jstate)
 				   PGSS_INVALID,
 				   0,
 				   0,
+				   0,
 				   NULL,
 				   NULL,
 				   NULL,
@@ -960,6 +979,7 @@ pgss_planner(Query *parse,
 				   PGSS_PLAN,
 				   INSTR_TIME_GET_MILLISEC(duration),
 				   0,
+				   0,
 				   &bufusage,
 				   &walusage,
 				   NULL,
@@ -1001,6 +1021,24 @@ pgss_planner(Query *parse,
 static void
 pgss_ExecutorStart(QueryDesc *queryDesc, int eflags)
 {
+	/*
+	 * If rows_filtered tracking is enabled, ensure per-node instrumentation
+	 * is set up so we can collect nfiltered1/nfiltered2 statistics.  This
+	 * must be done before calling standard_ExecutorStart, which is when the
+	 * instrumentation structures are allocated for each plan node.
+	 *
+	 * We use INSTRUMENT_ROWS rather than INSTRUMENT_ALL to avoid the overhead
+	 * of timing instrumentation (which requires system calls per node).
+	 * INSTRUMENT_ROWS only increments tuple counters, which has negligible
+	 * overhead.
+	 */
+	if (pgss_track_rows_filtered &&
+		pgss_enabled(nesting_level) &&
+		queryDesc->plannedstmt->queryId != INT64CONST(0))
+	{
+		queryDesc->instrument_options |= INSTRUMENT_ROWS;
+	}
+
 	if (prev_ExecutorStart)
 		prev_ExecutorStart(queryDesc, eflags);
 	else
@@ -1071,6 +1109,44 @@ pgss_ExecutorFinish(QueryDesc *queryDesc)
 	PG_END_TRY();
 }
 
+/*
+ * Walker function to collect rows_filtered from all plan nodes.
+ */
+static bool
+pgss_collect_filtered_walker(PlanState *planstate, int64 *rows_filtered)
+{
+	if (planstate->instrument)
+	{
+		Instrumentation *instr = planstate->instrument;
+
+		/*
+		 * Collect rows_filtered from all nodes. nfiltered1 tracks tuples
+		 * removed by scanqual or joinqual, nfiltered2 tracks tuples removed
+		 * by "other" quals (e.g., recheck conditions in bitmap index scans).
+		 *
+		 * The nfiltered counters are doubles, but they represent integer row
+		 * counts so the truncation to int64 is safe.
+		 */
+		*rows_filtered += (int64) (instr->nfiltered1 + instr->nfiltered2);
+	}
+
+	return planstate_tree_walker(planstate, pgss_collect_filtered_walker, rows_filtered);
+}
+
+/*
+ * Collect rows_filtered from the entire plan tree.
+ */
+static int64
+pgss_collect_rows_filtered(PlanState *planstate)
+{
+	int64		rows_filtered = 0;
+
+	if (planstate)
+		pgss_collect_filtered_walker(planstate, &rows_filtered);
+
+	return rows_filtered;
+}
+
 /*
  * ExecutorEnd hook: store results if needed
  */
@@ -1082,12 +1158,20 @@ pgss_ExecutorEnd(QueryDesc *queryDesc)
 	if (queryId != INT64CONST(0) && queryDesc->totaltime &&
 		pgss_enabled(nesting_level))
 	{
+		int64		rows_filtered;
+
 		/*
 		 * Make sure stats accumulation is done.  (Note: it's okay if several
 		 * levels of hook all do this.)
 		 */
 		InstrEndLoop(queryDesc->totaltime);
 
+		/*
+		 * Collect rows_filtered from the plan tree.  This must be done
+		 * before standard_ExecutorEnd which will destroy the planstate.
+		 */
+		rows_filtered = pgss_collect_rows_filtered(queryDesc->planstate);
+
 		pgss_store(queryDesc->sourceText,
 				   queryId,
 				   queryDesc->plannedstmt->stmt_location,
@@ -1095,6 +1179,7 @@ pgss_ExecutorEnd(QueryDesc *queryDesc)
 				   PGSS_EXEC,
 				   queryDesc->totaltime->total * 1000.0,	/* convert to msec */
 				   queryDesc->estate->es_total_processed,
+				   rows_filtered,
 				   &queryDesc->totaltime->bufusage,
 				   &queryDesc->totaltime->walusage,
 				   queryDesc->estate->es_jit ? &queryDesc->estate->es_jit->instr : NULL,
@@ -1229,6 +1314,7 @@ pgss_ProcessUtility(PlannedStmt *pstmt, const char *queryString,
 				   PGSS_EXEC,
 				   INSTR_TIME_GET_MILLISEC(duration),
 				   rows,
+				   0,	/* rows_filtered - not tracked for utility */
 				   &bufusage,
 				   &walusage,
 				   NULL,
@@ -1293,6 +1379,7 @@ pgss_store(const char *query, int64 queryId,
 		   int query_location, int query_len,
 		   pgssStoreKind kind,
 		   double total_time, uint64 rows,
+		   int64 rows_filtered,
 		   const BufferUsage *bufusage,
 		   const WalUsage *walusage,
 		   const struct JitInstrumentation *jitusage,
@@ -1460,6 +1547,7 @@ pgss_store(const char *query, int64 queryId,
 			}
 		}
 		entry->counters.rows += rows;
+		entry->counters.rows_filtered += rows_filtered;
 		entry->counters.shared_blks_hit += bufusage->shared_blks_hit;
 		entry->counters.shared_blks_read += bufusage->shared_blks_read;
 		entry->counters.shared_blks_dirtied += bufusage->shared_blks_dirtied;
@@ -1581,7 +1669,8 @@ pg_stat_statements_reset(PG_FUNCTION_ARGS)
 #define PG_STAT_STATEMENTS_COLS_V1_11	49
 #define PG_STAT_STATEMENTS_COLS_V1_12	52
 #define PG_STAT_STATEMENTS_COLS_V1_13	54
-#define PG_STAT_STATEMENTS_COLS			54	/* maximum of above */
+#define PG_STAT_STATEMENTS_COLS_V1_14	55
+#define PG_STAT_STATEMENTS_COLS			55	/* maximum of above */
 
 /*
  * Retrieve statement statistics.
@@ -1593,6 +1682,16 @@ pg_stat_statements_reset(PG_FUNCTION_ARGS)
  * expected API version is identified by embedding it in the C name of the
  * function.  Unfortunately we weren't bright enough to do that for 1.1.
  */
+Datum
+pg_stat_statements_1_14(PG_FUNCTION_ARGS)
+{
+	bool		showtext = PG_GETARG_BOOL(0);
+
+	pg_stat_statements_internal(fcinfo, PGSS_V1_14, showtext);
+
+	return (Datum) 0;
+}
+
 Datum
 pg_stat_statements_1_13(PG_FUNCTION_ARGS)
 {
@@ -1765,6 +1864,10 @@ pg_stat_statements_internal(FunctionCallInfo fcinfo,
 			if (api_version != PGSS_V1_13)
 				elog(ERROR, "incorrect number of output arguments");
 			break;
+		case PG_STAT_STATEMENTS_COLS_V1_14:
+			if (api_version != PGSS_V1_14)
+				elog(ERROR, "incorrect number of output arguments");
+			break;
 		default:
 			elog(ERROR, "incorrect number of output arguments");
 	}
@@ -1948,6 +2051,8 @@ pg_stat_statements_internal(FunctionCallInfo fcinfo,
 			}
 		}
 		values[i++] = Int64GetDatumFast(tmp.rows);
+		if (api_version >= PGSS_V1_14)
+			values[i++] = Int64GetDatumFast(tmp.rows_filtered);
 		values[i++] = Int64GetDatumFast(tmp.shared_blks_hit);
 		values[i++] = Int64GetDatumFast(tmp.shared_blks_read);
 		if (api_version >= PGSS_V1_1)
@@ -2038,6 +2143,7 @@ pg_stat_statements_internal(FunctionCallInfo fcinfo,
 					 api_version == PGSS_V1_11 ? PG_STAT_STATEMENTS_COLS_V1_11 :
 					 api_version == PGSS_V1_12 ? PG_STAT_STATEMENTS_COLS_V1_12 :
 					 api_version == PGSS_V1_13 ? PG_STAT_STATEMENTS_COLS_V1_13 :
+					 api_version == PGSS_V1_14 ? PG_STAT_STATEMENTS_COLS_V1_14 :
 					 -1 /* fail if you forget to update this assert */ ));
 
 		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls);
diff --git a/contrib/pg_stat_statements/pg_stat_statements.control b/contrib/pg_stat_statements/pg_stat_statements.control
index 2eee0ceffa894..61ae41efc1472 100644
--- a/contrib/pg_stat_statements/pg_stat_statements.control
+++ b/contrib/pg_stat_statements/pg_stat_statements.control
@@ -1,5 +1,5 @@
 # pg_stat_statements extension
 comment = 'track planning and execution statistics of all SQL statements executed'
-default_version = '1.13'
+default_version = '1.14'
 module_pathname = '$libdir/pg_stat_statements'
 relocatable = true
diff --git a/contrib/pg_stat_statements/sql/rows_filtered.sql b/contrib/pg_stat_statements/sql/rows_filtered.sql
new file mode 100644
index 0000000000000..aaf12d128e402
--- /dev/null
+++ b/contrib/pg_stat_statements/sql/rows_filtered.sql
@@ -0,0 +1,127 @@
+--
+-- Test rows_filtered tracking
+--
+-- rows_filtered tracks the number of rows removed by filter conditions
+-- across all plan nodes. This requires pg_stat_statements.track_rows_filtered
+-- to be enabled.
+
+-- Ensure the extension is set up
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+
+-- Create test table
+CREATE TABLE filter_test (id int PRIMARY KEY, val int);
+INSERT INTO filter_test SELECT g, g FROM generate_series(1, 1000) g;
+ANALYZE filter_test;
+
+--
+-- Test 1: rows_filtered is 0 when tracking is disabled (default)
+--
+SHOW pg_stat_statements.track_rows_filtered;
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+
+-- Force a sequential scan with filter
+SET enable_indexscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM filter_test WHERE val > 900;
+RESET enable_indexscan;
+RESET enable_bitmapscan;
+
+-- rows_filtered should be 0 because tracking is disabled
+SELECT query, calls, rows, rows_filtered
+FROM pg_stat_statements
+WHERE query LIKE '%filter_test WHERE val%'
+ORDER BY query COLLATE "C";
+
+--
+-- Test 2: rows_filtered is tracked when enabled
+--
+SET pg_stat_statements.track_rows_filtered = on;
+SHOW pg_stat_statements.track_rows_filtered;
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+
+-- Force a sequential scan with filter: 100 rows pass (val > 900), 900 filtered
+SET enable_indexscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM filter_test WHERE val > 900;
+RESET enable_indexscan;
+RESET enable_bitmapscan;
+
+-- rows_filtered should be 900 (the 900 rows that didn't pass the filter)
+SELECT query, calls, rows, rows_filtered
+FROM pg_stat_statements
+WHERE query LIKE '%filter_test WHERE val%'
+ORDER BY query COLLATE "C";
+
+--
+-- Test 3: rows_filtered accumulates across multiple executions
+--
+SELECT count(*) FROM filter_test WHERE val > 900;
+SELECT count(*) FROM filter_test WHERE val > 900;
+
+-- After 3 total executions, rows_filtered remains 900. The subsequent
+-- executions used an index scan which accesses only matching rows
+-- directly, so no additional rows are filtered.
+SELECT query, calls, rows, rows_filtered
+FROM pg_stat_statements
+WHERE query LIKE '%filter_test WHERE val%'
+ORDER BY query COLLATE "C";
+
+--
+-- Test 4: Different filter selectivities
+--
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+
+SET enable_indexscan = off;
+SET enable_bitmapscan = off;
+
+-- Highly selective: 1 row passes, 999 filtered
+SELECT * FROM filter_test WHERE val = 500;
+
+-- Low selectivity: 500 rows pass, 500 filtered
+SELECT count(*) FROM filter_test WHERE val > 500;
+
+RESET enable_indexscan;
+RESET enable_bitmapscan;
+
+SELECT query, calls, rows, rows_filtered
+FROM pg_stat_statements
+WHERE query LIKE '%filter_test WHERE val%'
+ORDER BY query COLLATE "C";
+
+--
+-- Test 5: Verify rows_filtered with JOIN queries
+--
+CREATE TABLE filter_test2 (id int PRIMARY KEY, ref_id int);
+INSERT INTO filter_test2 SELECT g, g % 100 FROM generate_series(1, 500) g;
+ANALYZE filter_test2;
+
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+
+SET enable_indexscan = off;
+SET enable_bitmapscan = off;
+SET enable_hashjoin = off;
+SET enable_mergejoin = off;
+
+-- Nested loop join with filter on inner table
+SELECT count(*)
+FROM filter_test2 t2
+JOIN filter_test t1 ON t1.id = t2.ref_id
+WHERE t1.val < 50;
+
+RESET enable_indexscan;
+RESET enable_bitmapscan;
+RESET enable_hashjoin;
+RESET enable_mergejoin;
+
+-- rows_filtered should be large (many rows scanned but filtered by join/filter).
+-- Use threshold check to avoid plan-dependent exact values.
+SELECT query, calls, rows, rows_filtered > 0 AS has_filtered_rows
+FROM pg_stat_statements
+WHERE query LIKE '%filter_test2%'
+  AND query LIKE '%JOIN%'
+ORDER BY query COLLATE "C";
+
+-- Cleanup
+RESET pg_stat_statements.track_rows_filtered;
+DROP TABLE filter_test, filter_test2;
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
diff --git a/doc/src/sgml/pgstatstatements.sgml b/doc/src/sgml/pgstatstatements.sgml
index d753de5836efb..635977f09fd80 100644
--- a/doc/src/sgml/pgstatstatements.sgml
+++ b/doc/src/sgml/pgstatstatements.sgml
@@ -257,6 +257,18 @@
       </para></entry>
      </row>
 
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rows_filtered</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Total number of rows removed by filter conditions across all plan nodes.
+       This includes rows filtered by scan quals, join quals, and recheck
+       conditions (e.g., in bitmap index scans). This field is zero when
+       <varname>pg_stat_statements.track_rows_filtered</varname> is disabled.
+      </para></entry>
+     </row>
+
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>shared_blks_hit</structfield> <type>bigint</type>
@@ -984,6 +996,29 @@ calls | 2
     </listitem>
    </varlistentry>
 
+   <varlistentry>
+    <term>
+     <varname>pg_stat_statements.track_rows_filtered</varname> (<type>boolean</type>)
+     <indexterm>
+      <primary><varname>pg_stat_statements.track_rows_filtered</varname> configuration parameter</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <varname>pg_stat_statements.track_rows_filtered</varname> controls whether
+      rows removed by filter conditions are tracked by the module.
+      When enabled, the module collects <structfield>rows_filtered</structfield>
+      statistics from all plan nodes, which can help identify queries that
+      would benefit from better indexing.  This uses <literal>INSTRUMENT_ROWS</literal>
+      instrumentation which only increments tuple counters and has negligible
+      overhead (no timing system calls).
+      The default value is <literal>off</literal>.
+      Only superusers can change this setting.
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry>
     <term>
      <varname>pg_stat_statements.save</varname> (<type>boolean</type>)
