Import Statistics in postgres_fdw before resorting to sampling.

Started by Corey Huinker5 months ago37 messages
#1Corey Huinker
corey.huinker@gmail.com
3 attachment(s)

Attached is my current work on adding remote fetching of statistics to
postgres_fdw, and opening the possibility of doing so to other foreign data
wrappers.

This involves adding two new options to postgres_fdw at the server and
table level.

The first option, fetch_stats, defaults to true at both levels. If enabled,
it will cause an ANALYZE of a postgres_fdw foreign table to first attempt
to fetch relation and attribute statistics from the remote table. If this
succeeds, then those statistics are imported into the local foreign table,
allowing us to skip a potentially expensive sampling operation.

The second option, remote_analyze, defaults to false at both levels, and
only comes into play if the first fetch succeeds but no attribute
statistics (i.e. the stats from pg_stats) are found. If enabled then the
function will attempt to ANALYZE the remote table, and if that is
successful then a second attempt at fetching remote statistics will be made.

If no statistics were fetched, then the operation will fall back to the
normal sampling operation per settings.

Note patches 0001 and 0002 are already a part of a separate thread
/messages/by-id/CADkLM=cpUiJ3QF7aUthTvaVMmgQcm7QqZBRMDLhBRTR+gJX-Og@mail.gmail.com
regarding a bug (0001) and a nitpick (0002) that came about as a
side-effect to this effort, and but I expect those to be resolved one way
or another soon. Any feedback on those two can be handled there.

Attachments:

v1-0001-Fix-remote-sampling-tests-in-postgres_fdw.patchtext/x-patch; charset=US-ASCII; name=v1-0001-Fix-remote-sampling-tests-in-postgres_fdw.patchDownload
From 98a15ff9a348f875558a5a9bba44a6e21f09d019 Mon Sep 17 00:00:00 2001
From: Corey Huinker <corey.huinker@gmail.com>
Date: Mon, 11 Aug 2025 10:05:08 -0400
Subject: [PATCH v1 1/3] Fix remote sampling tests in postgres_fdw.

These tests were changing the sampling setting of a foreign server, but
then were analyzing a local table, which doesn't actually test the
sampling.

Changed the ANALYZE commands to analyze the foreign table, and changed
the foreign table definition to point to a valid local table.
---
 contrib/postgres_fdw/expected/postgres_fdw.out | 12 ++++++------
 contrib/postgres_fdw/sql/postgres_fdw.sql      | 12 ++++++------
 2 files changed, 12 insertions(+), 12 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index a434eb1395e..d3323b04676 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -12649,7 +12649,7 @@ ALTER SERVER loopback2 OPTIONS (DROP parallel_abort);
 -- ===================================================================
 CREATE TABLE analyze_table (id int, a text, b bigint);
 CREATE FOREIGN TABLE analyze_ftable (id int, a text, b bigint)
-       SERVER loopback OPTIONS (table_name 'analyze_rtable1');
+       SERVER loopback OPTIONS (table_name 'analyze_table');
 INSERT INTO analyze_table (SELECT x FROM generate_series(1,1000) x);
 ANALYZE analyze_table;
 SET default_statistics_target = 10;
@@ -12657,15 +12657,15 @@ ANALYZE analyze_table;
 ALTER SERVER loopback OPTIONS (analyze_sampling 'invalid');
 ERROR:  invalid value for string option "analyze_sampling": invalid
 ALTER SERVER loopback OPTIONS (analyze_sampling 'auto');
-ANALYZE analyze_table;
+ANALYZE analyze_ftable;
 ALTER SERVER loopback OPTIONS (SET analyze_sampling 'system');
-ANALYZE analyze_table;
+ANALYZE analyze_ftable;
 ALTER SERVER loopback OPTIONS (SET analyze_sampling 'bernoulli');
-ANALYZE analyze_table;
+ANALYZE analyze_ftable;
 ALTER SERVER loopback OPTIONS (SET analyze_sampling 'random');
-ANALYZE analyze_table;
+ANALYZE analyze_ftable;
 ALTER SERVER loopback OPTIONS (SET analyze_sampling 'off');
-ANALYZE analyze_table;
+ANALYZE analyze_ftable;
 -- cleanup
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index d9bed565c81..2c609e060b7 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -4365,7 +4365,7 @@ ALTER SERVER loopback2 OPTIONS (DROP parallel_abort);
 CREATE TABLE analyze_table (id int, a text, b bigint);
 
 CREATE FOREIGN TABLE analyze_ftable (id int, a text, b bigint)
-       SERVER loopback OPTIONS (table_name 'analyze_rtable1');
+       SERVER loopback OPTIONS (table_name 'analyze_table');
 
 INSERT INTO analyze_table (SELECT x FROM generate_series(1,1000) x);
 ANALYZE analyze_table;
@@ -4376,19 +4376,19 @@ ANALYZE analyze_table;
 ALTER SERVER loopback OPTIONS (analyze_sampling 'invalid');
 
 ALTER SERVER loopback OPTIONS (analyze_sampling 'auto');
-ANALYZE analyze_table;
+ANALYZE analyze_ftable;
 
 ALTER SERVER loopback OPTIONS (SET analyze_sampling 'system');
-ANALYZE analyze_table;
+ANALYZE analyze_ftable;
 
 ALTER SERVER loopback OPTIONS (SET analyze_sampling 'bernoulli');
-ANALYZE analyze_table;
+ANALYZE analyze_ftable;
 
 ALTER SERVER loopback OPTIONS (SET analyze_sampling 'random');
-ANALYZE analyze_table;
+ANALYZE analyze_ftable;
 
 ALTER SERVER loopback OPTIONS (SET analyze_sampling 'off');
-ANALYZE analyze_table;
+ANALYZE analyze_ftable;
 
 -- cleanup
 DROP FOREIGN TABLE analyze_ftable;

base-commit: b227b0bb4e032e19b3679bedac820eba3ac0d1cf
-- 
2.50.1

v1-0002-Use-psql-vars-to-eliminate-the-need-for-DO-blocks.patchtext/x-patch; charset=US-ASCII; name=v1-0002-Use-psql-vars-to-eliminate-the-need-for-DO-blocks.patchDownload
From 652292d2c227c34588b130221b9e3efafbea28fb Mon Sep 17 00:00:00 2001
From: Corey Huinker <corey.huinker@gmail.com>
Date: Sun, 10 Aug 2025 19:36:52 -0400
Subject: [PATCH v1 2/3] Use psql vars to eliminate the need for DO blocks.

Several statements need to reference the current connection's current
database name and current port value. Until now, this has been
accomplished by creating dynamic SQL statements inside of a DO block,
which isn't as easy to read, and takes away some of the granularity of
any error messages that might occur, however unlikely.

By capturing the connection-specific settings into psql vars, it becomes
possible to write the SQL statements with simple :'varname'
interpolations.
---
 .../postgres_fdw/expected/postgres_fdw.out    | 44 ++++++------------
 contrib/postgres_fdw/sql/postgres_fdw.sql     | 46 ++++++-------------
 2 files changed, 29 insertions(+), 61 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index d3323b04676..6af35d04a4e 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -2,23 +2,16 @@
 -- create FDW objects
 -- ===================================================================
 CREATE EXTENSION postgres_fdw;
+SELECT current_database() AS current_database,
+    current_setting('port') AS current_port
+\gset
 CREATE SERVER testserver1 FOREIGN DATA WRAPPER postgres_fdw;
-DO $d$
-    BEGIN
-        EXECUTE $$CREATE SERVER loopback FOREIGN DATA WRAPPER postgres_fdw
-            OPTIONS (dbname '$$||current_database()||$$',
-                     port '$$||current_setting('port')||$$'
-            )$$;
-        EXECUTE $$CREATE SERVER loopback2 FOREIGN DATA WRAPPER postgres_fdw
-            OPTIONS (dbname '$$||current_database()||$$',
-                     port '$$||current_setting('port')||$$'
-            )$$;
-        EXECUTE $$CREATE SERVER loopback3 FOREIGN DATA WRAPPER postgres_fdw
-            OPTIONS (dbname '$$||current_database()||$$',
-                     port '$$||current_setting('port')||$$'
-            )$$;
-    END;
-$d$;
+CREATE SERVER loopback FOREIGN DATA WRAPPER postgres_fdw
+    OPTIONS (dbname :'current_database', port :'current_port');
+CREATE SERVER loopback2 FOREIGN DATA WRAPPER postgres_fdw
+    OPTIONS (dbname :'current_database', port :'current_port');
+CREATE SERVER loopback3 FOREIGN DATA WRAPPER postgres_fdw
+    OPTIONS (dbname :'current_database', port :'current_port');
 CREATE USER MAPPING FOR public SERVER testserver1
 	OPTIONS (user 'value', password 'value');
 CREATE USER MAPPING FOR CURRENT_USER SERVER loopback;
@@ -235,12 +228,7 @@ SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should work
 ALTER SERVER loopback OPTIONS (SET dbname 'no such database');
 SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should fail
 ERROR:  could not connect to server "loopback"
-DO $d$
-    BEGIN
-        EXECUTE $$ALTER SERVER loopback
-            OPTIONS (SET dbname '$$||current_database()||$$')$$;
-    END;
-$d$;
+ALTER SERVER loopback OPTIONS (SET dbname :'current_database');
 SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should work again
   c3   |              c4              
 -------+------------------------------
@@ -10643,14 +10631,8 @@ SHOW is_superuser;
 (1 row)
 
 -- This will be OK, we can create the FDW
-DO $d$
-    BEGIN
-        EXECUTE $$CREATE SERVER loopback_nopw FOREIGN DATA WRAPPER postgres_fdw
-            OPTIONS (dbname '$$||current_database()||$$',
-                     port '$$||current_setting('port')||$$'
-            )$$;
-    END;
-$d$;
+CREATE SERVER loopback_nopw FOREIGN DATA WRAPPER postgres_fdw
+    OPTIONS (dbname :'current_database', port :'current_port');
 -- But creation of user mappings for non-superusers should fail
 CREATE USER MAPPING FOR public SERVER loopback_nopw;
 CREATE USER MAPPING FOR CURRENT_USER SERVER loopback_nopw;
@@ -12724,3 +12706,5 @@ SELECT server_name,
 -- Clean up
 \set VERBOSITY default
 RESET debug_discard_caches;
+\unset current_database
+\unset current_port
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 2c609e060b7..63bba3982cb 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -4,24 +4,17 @@
 
 CREATE EXTENSION postgres_fdw;
 
-CREATE SERVER testserver1 FOREIGN DATA WRAPPER postgres_fdw;
-DO $d$
-    BEGIN
-        EXECUTE $$CREATE SERVER loopback FOREIGN DATA WRAPPER postgres_fdw
-            OPTIONS (dbname '$$||current_database()||$$',
-                     port '$$||current_setting('port')||$$'
-            )$$;
-        EXECUTE $$CREATE SERVER loopback2 FOREIGN DATA WRAPPER postgres_fdw
-            OPTIONS (dbname '$$||current_database()||$$',
-                     port '$$||current_setting('port')||$$'
-            )$$;
-        EXECUTE $$CREATE SERVER loopback3 FOREIGN DATA WRAPPER postgres_fdw
-            OPTIONS (dbname '$$||current_database()||$$',
-                     port '$$||current_setting('port')||$$'
-            )$$;
-    END;
-$d$;
+SELECT current_database() AS current_database,
+    current_setting('port') AS current_port
+\gset
 
+CREATE SERVER testserver1 FOREIGN DATA WRAPPER postgres_fdw;
+CREATE SERVER loopback FOREIGN DATA WRAPPER postgres_fdw
+    OPTIONS (dbname :'current_database', port :'current_port');
+CREATE SERVER loopback2 FOREIGN DATA WRAPPER postgres_fdw
+    OPTIONS (dbname :'current_database', port :'current_port');
+CREATE SERVER loopback3 FOREIGN DATA WRAPPER postgres_fdw
+    OPTIONS (dbname :'current_database', port :'current_port');
 CREATE USER MAPPING FOR public SERVER testserver1
 	OPTIONS (user 'value', password 'value');
 CREATE USER MAPPING FOR CURRENT_USER SERVER loopback;
@@ -233,12 +226,7 @@ ALTER FOREIGN TABLE ft2 ALTER COLUMN c1 OPTIONS (column_name 'C 1');
 SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should work
 ALTER SERVER loopback OPTIONS (SET dbname 'no such database');
 SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should fail
-DO $d$
-    BEGIN
-        EXECUTE $$ALTER SERVER loopback
-            OPTIONS (SET dbname '$$||current_database()||$$')$$;
-    END;
-$d$;
+ALTER SERVER loopback OPTIONS (SET dbname :'current_database');
 SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should work again
 
 -- Test that alteration of user mapping options causes reconnection
@@ -3375,14 +3363,8 @@ SET ROLE regress_nosuper;
 SHOW is_superuser;
 
 -- This will be OK, we can create the FDW
-DO $d$
-    BEGIN
-        EXECUTE $$CREATE SERVER loopback_nopw FOREIGN DATA WRAPPER postgres_fdw
-            OPTIONS (dbname '$$||current_database()||$$',
-                     port '$$||current_setting('port')||$$'
-            )$$;
-    END;
-$d$;
+CREATE SERVER loopback_nopw FOREIGN DATA WRAPPER postgres_fdw
+    OPTIONS (dbname :'current_database', port :'current_port');
 
 -- But creation of user mappings for non-superusers should fail
 CREATE USER MAPPING FOR public SERVER loopback_nopw;
@@ -4435,3 +4417,5 @@ SELECT server_name,
 -- Clean up
 \set VERBOSITY default
 RESET debug_discard_caches;
+\unset current_database
+\unset current_port
-- 
2.50.1

v1-0003-Add-remote-statistics-fetching-to-postgres_fdw.patchtext/x-patch; charset=US-ASCII; name=v1-0003-Add-remote-statistics-fetching-to-postgres_fdw.patchDownload
From c44fd420b1a2c9cb6475782867a4a339760367ad Mon Sep 17 00:00:00 2001
From: Corey Huinker <corey.huinker@gmail.com>
Date: Thu, 7 Aug 2025 23:58:38 -0400
Subject: [PATCH v1 3/3] Add remote statistics fetching to postgres_fdw.

This adds the ability to fetch and import statistics from a remote
server table table rather than fetching the data or data sample from
that table.

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will first attempt to fetch statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If no statistics are found, then ANALYZE will fall back to the normal
behavior of sampling and local analysis.

This operation will only work on remote relations that can have stored
statistics: tables, partitioned tables, and materialized views. If the
remote relation is a view then remote fetching/analyzing is just wasted
effort and the user is better of setting fetch_stats to false for that
table.

The default for fetch_stats is true at both server and table level. The
default for remote_analyze is false at both the server and table level.
In both cases, setting a value at the table level will override the
corresponding server-level setting.
---
 src/include/foreign/fdwapi.h                  |  12 +
 src/backend/commands/analyze.c                |  46 +-
 doc/src/sgml/postgres-fdw.sgml                |  35 +-
 .../postgres_fdw/expected/postgres_fdw.out    |  64 +-
 contrib/postgres_fdw/option.c                 |  10 +
 contrib/postgres_fdw/postgres_fdw.c           | 546 ++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  37 +-
 7 files changed, 746 insertions(+), 4 deletions(-)

diff --git a/src/include/foreign/fdwapi.h b/src/include/foreign/fdwapi.h
index b4da4e6a16a..f0a7db424db 100644
--- a/src/include/foreign/fdwapi.h
+++ b/src/include/foreign/fdwapi.h
@@ -19,6 +19,15 @@
 /* To avoid including explain.h here, reference ExplainState thus: */
 struct ExplainState;
 
+/* result of ImportStatistics */
+typedef enum
+{
+	FDW_IMPORT_STATS_OK = 0,		/* was able to import statistics */
+	FDW_IMPORT_STATS_DISABLED,		/* import disabled for this table */
+	FDW_IMPORT_STATS_NOTFOUND,		/* no remote attribute stats found */
+	FDW_IMPORT_STATS_FAILED			/* remote query failure of some kind */
+} FdwImportStatsResult;
+
 
 /*
  * Callback function signatures --- see fdwhandler.sgml for more info.
@@ -157,6 +166,8 @@ typedef bool (*AnalyzeForeignTable_function) (Relation relation,
 											  AcquireSampleRowsFunc *func,
 											  BlockNumber *totalpages);
 
+typedef FdwImportStatsResult (*ImportStatistics_function) (Relation relation);
+
 typedef List *(*ImportForeignSchema_function) (ImportForeignSchemaStmt *stmt,
 											   Oid serverOid);
 
@@ -255,6 +266,7 @@ typedef struct FdwRoutine
 
 	/* Support functions for ANALYZE */
 	AnalyzeForeignTable_function AnalyzeForeignTable;
+	ImportStatistics_function ImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	ImportForeignSchema_function ImportForeignSchema;
diff --git a/src/backend/commands/analyze.c b/src/backend/commands/analyze.c
index 40d66537ad7..6cd93ae173d 100644
--- a/src/backend/commands/analyze.c
+++ b/src/backend/commands/analyze.c
@@ -197,13 +197,57 @@ analyze_rel(Oid relid, RangeVar *relation,
 	{
 		/*
 		 * For a foreign table, call the FDW's hook function to see whether it
-		 * supports analysis.
+		 * supports statistics import and/or analysis.
 		 */
 		FdwRoutine *fdwroutine;
 		bool		ok = false;
 
 		fdwroutine = GetFdwRoutineForRelation(onerel, false);
 
+		if (fdwroutine->ImportStatistics != NULL)
+		{
+			FdwImportStatsResult	res;
+
+			/*
+			 * Fetching pre-existing remote stats is not guaranteed to be a quick
+			 * operation.
+			 *
+			 * XXX: Should this be it's own fetch type? If not, then there might be
+			 * confusion when a long stats-fetch fails, followed by a regular analyze,
+			 * which would make it look like the table was analyzed twice.
+			 */
+			pgstat_progress_start_command(PROGRESS_COMMAND_ANALYZE,
+										  RelationGetRelid(onerel));
+
+			res = fdwroutine->ImportStatistics(onerel);
+
+			pgstat_progress_end_command();
+
+			/*
+			 * If we were able to import statistics, then there is no need to collect
+			 * samples for local analysis.
+			 */
+			switch(res)
+			{
+				case FDW_IMPORT_STATS_OK:
+					relation_close(onerel, NoLock);
+					return;
+					break;
+				case FDW_IMPORT_STATS_DISABLED:
+					break;
+				case FDW_IMPORT_STATS_NOTFOUND:
+					ereport(INFO,
+							(errmsg("Found no remote statistics for \"%s\"",
+									RelationGetRelationName(onerel))));
+					break;
+				case FDW_IMPORT_STATS_FAILED:
+				default:
+					ereport(INFO,
+							(errmsg("Fetching remote statistics from \"%s\" failed",
+									RelationGetRelationName(onerel))));
+			}
+		}
+
 		if (fdwroutine->AnalyzeForeignTable != NULL)
 			ok = fdwroutine->AnalyzeForeignTable(onerel,
 												 &acquirefunc,
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index 781a01067f7..c395d1061d5 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -332,7 +332,7 @@ OPTIONS (ADD password_required 'false');
    </para>
 
    <para>
-    The following option controls how such an <command>ANALYZE</command>
+    The following options control how such an <command>ANALYZE</command>
     operation behaves:
    </para>
 
@@ -364,6 +364,39 @@ OPTIONS (ADD password_required 'false');
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>fetch_stats</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines if <command>ANALYZE</command> on a foreign table
+       will first attempt to fetch and import the existing relation and
+       attribute statistics from the remote table, and only attempt regular
+       data sampling if no statistics were availble. This option is only
+       useful if the remote relation is one that can have regular statistics
+       (tables and materialized views).
+       The default is <literal>true</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><literal>remote_analyze</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines wheter an <command>ANALYZE</command> on a foreign
+       table will attempt to <command>ANALYZE</command> the remote table if
+       the first attempt to fetch remote statistics fails, and will then
+       make a second and final attempt to fetch remote statistics. This option
+       is only meaningful if the foreign table has
+       <literal>fetch_stats</literal> enabled at either the server or table
+       level.
+       The default is <literal>false</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
    </variablelist>
 
   </sect3>
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 6af35d04a4e..35a6723f076 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -628,6 +628,7 @@ INSERT INTO loct_empty
   SELECT id, 'AAA' || to_char(id, 'FM000') FROM generate_series(1, 100) id;
 DELETE FROM loct_empty;
 ANALYZE ft_empty;
+INFO:  Found no remote statistics for "ft_empty"
 EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM ft_empty ORDER BY c1;
                                   QUERY PLAN                                   
 -------------------------------------------------------------------------------
@@ -4548,7 +4549,8 @@ REINDEX TABLE reind_fdw_parent; -- ok
 REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -4565,6 +4567,9 @@ CONTEXT:  whole-row reference to foreign table "ftx"
 SELECT sum(c2), array_agg(c8) FROM ft1 GROUP BY c8; -- ERROR
 ERROR:  invalid input syntax for type integer: "foo"
 CONTEXT:  processing expression at position 2 in select list
+ANALYZE ft1; -- WARNING
+WARNING:  invalid input syntax for type integer: "foo"
+ALTER FOREIGN TABLE ft1 OPTIONS ( fetch_stats 'false' );
 ANALYZE ft1; -- ERROR
 ERROR:  invalid input syntax for type integer: "foo"
 CONTEXT:  column "c8" of foreign table "ft1"
@@ -7097,6 +7102,7 @@ INSERT INTO loct2 VALUES (1002, 'bar');
 CREATE FOREIGN TABLE remt2 (c1 int, c2 text) SERVER loopback OPTIONS (table_name 'loct2');
 ANALYZE loct1;
 ANALYZE remt2;
+INFO:  Found no remote statistics for "remt2"
 SET enable_mergejoin TO false;
 SET enable_hashjoin TO false;
 SET enable_material TO false;
@@ -8776,6 +8782,7 @@ alter foreign table foo2 options (use_remote_estimate 'true');
 create index i_loct1_f1 on loct1(f1);
 create index i_foo_f1 on foo(f1);
 analyze foo;
+INFO:  Found no remote statistics for "foo2"
 analyze loct1;
 -- inner join; expressions in the clauses appear in the equivalence class list
 explain (verbose, costs off)
@@ -9005,7 +9012,9 @@ insert into remt1 values (2, 'bar');
 insert into remt2 values (1, 'foo');
 insert into remt2 values (2, 'bar');
 analyze remt1;
+INFO:  Found no remote statistics for "remt1"
 analyze remt2;
+INFO:  Found no remote statistics for "remt2"
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
                                                    QUERY PLAN                                                   
@@ -10305,6 +10314,8 @@ CREATE FOREIGN TABLE ftprt1_p1 PARTITION OF fprt1 FOR VALUES FROM (0) TO (250)
 CREATE FOREIGN TABLE ftprt1_p2 PARTITION OF fprt1 FOR VALUES FROM (250) TO (500)
 	SERVER loopback OPTIONS (TABLE_NAME 'fprt1_p2');
 ANALYZE fprt1;
+INFO:  Found no remote statistics for "ftprt1_p1"
+INFO:  Found no remote statistics for "ftprt1_p2"
 ANALYZE fprt1_p1;
 ANALYZE fprt1_p2;
 CREATE TABLE fprt2 (a int, b int, c varchar) PARTITION BY RANGE(b);
@@ -10320,6 +10331,8 @@ ALTER TABLE fprt2 ATTACH PARTITION ftprt2_p1 FOR VALUES FROM (0) TO (250);
 CREATE FOREIGN TABLE ftprt2_p2 PARTITION OF fprt2 FOR VALUES FROM (250) TO (500)
 	SERVER loopback OPTIONS (table_name 'fprt2_p2', use_remote_estimate 'true');
 ANALYZE fprt2;
+INFO:  Found no remote statistics for "ftprt2_p1"
+INFO:  Found no remote statistics for "ftprt2_p2"
 ANALYZE fprt2_p1;
 ANALYZE fprt2_p2;
 -- inner join three tables
@@ -10507,9 +10520,15 @@ CREATE FOREIGN TABLE fpagg_tab_p1 PARTITION OF pagg_tab FOR VALUES FROM (0) TO (
 CREATE FOREIGN TABLE fpagg_tab_p2 PARTITION OF pagg_tab FOR VALUES FROM (10) TO (20) SERVER loopback OPTIONS (table_name 'pagg_tab_p2');
 CREATE FOREIGN TABLE fpagg_tab_p3 PARTITION OF pagg_tab FOR VALUES FROM (20) TO (30) SERVER loopback OPTIONS (table_name 'pagg_tab_p3');
 ANALYZE pagg_tab;
+INFO:  Found no remote statistics for "fpagg_tab_p1"
+INFO:  Found no remote statistics for "fpagg_tab_p2"
+INFO:  Found no remote statistics for "fpagg_tab_p3"
 ANALYZE fpagg_tab_p1;
+INFO:  Found no remote statistics for "fpagg_tab_p1"
 ANALYZE fpagg_tab_p2;
+INFO:  Found no remote statistics for "fpagg_tab_p2"
 ANALYZE fpagg_tab_p3;
+INFO:  Found no remote statistics for "fpagg_tab_p3"
 -- When GROUP BY clause matches with PARTITION KEY.
 -- Plan with partitionwise aggregates is disabled
 SET enable_partitionwise_aggregate TO false;
@@ -11455,6 +11474,8 @@ CREATE FOREIGN TABLE async_p2 PARTITION OF async_pt FOR VALUES FROM (2000) TO (3
 INSERT INTO async_p1 SELECT 1000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 INSERT INTO async_p2 SELECT 2000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
 -- simple queries
 CREATE TABLE result_tbl (a int, b int, c text);
 EXPLAIN (VERBOSE, COSTS OFF)
@@ -11561,6 +11582,9 @@ CREATE FOREIGN TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4
   SERVER loopback2 OPTIONS (table_name 'base_tbl3');
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
+INFO:  Found no remote statistics for "async_p3"
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
                            QUERY PLAN                           
@@ -11597,6 +11621,8 @@ DROP TABLE base_tbl3;
 CREATE TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4000);
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
                            QUERY PLAN                           
@@ -12652,6 +12678,42 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 -- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table', remote_analyze 'true');
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+ tablename | num_stats 
+-----------+-----------
+(0 rows)
+
+ANALYZE remote_analyze_ftable;
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+       tablename       | num_stats 
+-----------------------+-----------
+ remote_analyze_ftable |         3
+ remote_analyze_table  |         3
+(2 rows)
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+-- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
 -- Disable debug_discard_caches in order to manage remote connections
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 04788b7e8b3..7f069373e82 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -120,6 +120,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 			strcmp(def->defname, "async_capable") == 0 ||
 			strcmp(def->defname, "parallel_commit") == 0 ||
 			strcmp(def->defname, "parallel_abort") == 0 ||
+			strcmp(def->defname, "fetch_stats") == 0 ||
+			strcmp(def->defname, "remote_analyze") == 0 ||
 			strcmp(def->defname, "keep_connections") == 0)
 		{
 			/* these accept only boolean values */
@@ -278,6 +280,14 @@ InitPgFdwOptions(void)
 		{"use_scram_passthrough", ForeignServerRelationId, false},
 		{"use_scram_passthrough", UserMappingRelationId, false},
 
+		/* fetch_size is available on both server and table */
+		{"fetch_stats", ForeignServerRelationId, false},
+		{"fetch_stats", ForeignTableRelationId, false},
+
+		/* remote_analyze is available on both server and table */
+		{"remote_analyze", ForeignServerRelationId, false},
+		{"remote_analyze", ForeignTableRelationId, false},
+
 		/*
 		 * sslcert and sslkey are in fact libpq options, but we repeat them
 		 * here to allow them to appear in both foreign server context (when
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 456b267f70b..096cd4d0fdd 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -22,6 +22,8 @@
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "executor/execAsync.h"
+#include "executor/spi.h"
+#include "fmgr.h"
 #include "foreign/fdwapi.h"
 #include "funcapi.h"
 #include "miscadmin.h"
@@ -402,6 +404,7 @@ static void postgresExecForeignTruncate(List *rels,
 static bool postgresAnalyzeForeignTable(Relation relation,
 										AcquireSampleRowsFunc *func,
 										BlockNumber *totalpages);
+static FdwImportStatsResult postgresImportStatistics(Relation relation);
 static List *postgresImportForeignSchema(ImportForeignSchemaStmt *stmt,
 										 Oid serverOid);
 static void postgresGetForeignJoinPaths(PlannerInfo *root,
@@ -546,6 +549,114 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
 
+/*
+ * Static queries for querying remote statistics.
+ */
+
+
+/* relallfrozen introduced in v18 */
+static const char *relstats_query_18 =
+	"SELECT c.relpages, c.reltuples, c.relallvisible, c.relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* trust reltuples = 0 as of v14 */
+static const char *relstats_query_14 =
+	"SELECT c.relpages, c.reltuples, c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/*
+ * Before v14, a reltuples value of 0 was ambiguous: it could either mean
+ * the relation is empty, or it could mean that it hadn't yet been
+ * vacuumed or analyzed.  (Newer versions use -1 for the latter case.)
+ * This ambiguity allegedly can cause the planner to choose inefficient
+ * plans after restoring to v18 or newer.  To deal with this, let's just
+ * set reltuples to -1 in that case.
+ */
+static const char *relstats_query_default =
+	"SELECT c.relpages, "
+	"CASE c.reltuples WHEN 0 THEN -1 ELSE c.reltuples END AS reltuples, "
+	"c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* All static relstats queries have the same column order */
+enum RelStatsColumns {
+	RELSTATS_RELPAGES = 0,
+	RELSTATS_RELTUPLES,
+	RELSTATS_RELALLVISIBLE,
+	RELSTATS_RELALLFROZEN,
+	RELSTATS_NUM_FIELDS
+};
+
+/* range stats introduced in v17 */
+static const char *attstats_query_17 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"s.range_length_histogram, s.range_empty_frac, s.range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* elements stats introduced in 9.2 */
+static const char *attstats_query_9_2 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* inherited introduced in 9.0 */
+static const char *attstats_query_9_0 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+static const char *attstats_query_default =
+	"SELECT s.attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname";
+
+/* All static attstats queries have the same column order */
+enum AttStatsColumns {
+	ATTSTATS_ATTNAME = 0,
+	ATTSTATS_NULL_FRAC,
+	ATTSTATS_AVG_WIDTH,
+	ATTSTATS_N_DISTINCT,
+	ATTSTATS_MOST_COMMON_VALS,
+	ATTSTATS_MOST_COMMON_FREQS,
+	ATTSTATS_HISTOGRAM_BOUNDS,
+	ATTSTATS_CORRELATION,
+	ATTSTATS_MOST_COMMON_ELEMS,
+	ATTSTATS_MOST_COMMON_ELEM_FREQS,
+	ATTSTATS_ELEM_COUNT_HISTOGRAM,
+	ATTSTATS_RANGE_LENGTH_HISTOGRAM,
+	ATTSTATS_RANGE_EMPTY_FRAC,
+	ATTSTATS_RANGE_BOUNDS_HISTOGRAM,
+	ATTSTATS_NUM_FIELDS
+};
 
 /*
  * Foreign-data wrapper handler function: return a struct with pointers
@@ -595,6 +706,7 @@ postgres_fdw_handler(PG_FUNCTION_ARGS)
 
 	/* Support functions for ANALYZE */
 	routine->AnalyzeForeignTable = postgresAnalyzeForeignTable;
+	routine->ImportStatistics = postgresImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	routine->ImportForeignSchema = postgresImportForeignSchema;
@@ -4935,6 +5047,440 @@ postgresAnalyzeForeignTable(Relation relation,
 	return true;
 }
 
+/*
+ * Process optional argument.
+ *
+ * Cannot be the first argument in the SQL function call.
+ *
+ * It is safe to presume that argname and argtype are quote-safe.
+ *
+ * Argument values can potentially be quite large, so free the quoted string
+ * after use.
+ */
+static void
+append_optional(StringInfo str, PGresult *res, int row, int field,
+				const char *argname, const char *argtype)
+{
+	if (!PQgetisnull(res, row, field))
+	{
+		/* Argument values can be quite large, so free after use */
+		char *argval_l = quote_literal_cstr(PQgetvalue(res, row, field));
+
+		appendStringInfo(str, ",\n\t'%s', %s::%s", argname, argval_l, argtype);
+
+		pfree(argval_l);
+	}
+}
+
+
+/*
+ * Generate a pg_restore_relation_stats command.
+ */
+static char *
+restore_relation_stats_sql(PGresult *res, const char *schemaname_l,
+						   const char *relname_l,
+						   const int server_version_num)
+{
+	StringInfoData	sql;
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_relation_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s",
+					 server_version_num, schemaname_l, relname_l);
+
+	append_optional(&sql, res, 0, RELSTATS_RELPAGES, "relpages", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELTUPLES, "reltuples", "real");
+	append_optional(&sql, res, 0, RELSTATS_RELALLVISIBLE, "relallvisible", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELALLFROZEN, "relallfrozen", "integer");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * Generate a pg_restore_attribute_stats command.
+ */
+static char *
+restore_attribute_stats_sql(PGresult *res, int row, const char *schemaname_l,
+							const char *relname_l, const AttrNumber attnum,
+							const int server_version_num)
+{
+	StringInfoData	sql;
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_attribute_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s,\n"
+					 "\t'attnum', %d::smallint,\n"
+					 "\t'inherited', false::boolean",
+					 server_version_num, schemaname_l, relname_l, attnum);
+
+	append_optional(&sql, res, row, ATTSTATS_NULL_FRAC, "null_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_AVG_WIDTH, "avg_width", "integer");
+	append_optional(&sql, res, row, ATTSTATS_N_DISTINCT, "n_distinct", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_VALS, "most_common_vals", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_FREQS, "most_common_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_HISTOGRAM_BOUNDS, "histogram_bounds", "text");
+	append_optional(&sql, res, row, ATTSTATS_CORRELATION, "correlation", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEMS, "most_common_elems", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEM_FREQS, "most_common_elem_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_ELEM_COUNT_HISTOGRAM, "elem_count_histogram", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_LENGTH_HISTOGRAM, "range_length_histogram", "text");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_EMPTY_FRAC, "range_empty_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_BOUNDS_HISTOGRAM, "range_bounds_histogram", "text");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * postgresImportStatistics
+ * 		Attempt to fetch remote statistics and apply those instead of analyzing.
+ */
+static FdwImportStatsResult
+postgresImportStatistics(Relation relation)
+{
+
+	ForeignTable   *table;
+	ForeignServer  *server;
+	UserMapping	   *user;
+	PGconn		   *conn;
+	ListCell	   *lc;
+	bool			fetch_stats = true;
+	bool			remote_analyze = false;
+	int				server_version_num;
+
+	const char	   *relation_sql;
+	const char	   *attribute_sql;
+	const char	   *schemaname;
+	const char	   *relname;
+	const char	   *remote_schemaname = NULL;
+	const char	   *remote_relname = NULL;
+
+	const char	   *schemaname_l;
+	const char	   *relname_l;
+
+	char   *relimport_sql;
+
+	PGresult   *res;
+	TupleDesc	tupdesc;
+	const char *sql_params[2];
+	int			sql_param_formats[2] = {0, 0};
+
+	table = GetForeignTable(RelationGetRelid(relation));
+	server = GetForeignServer(table->serverid);
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+			fetch_stats = defGetBoolean(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+			fetch_stats = defGetBoolean(def);
+		else if (strcmp(def->defname, "schema_name") == 0)
+			remote_schemaname = defGetString(def);
+		else if (strcmp(def->defname, "table_name") == 0)
+			remote_relname = defGetString(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	if (fetch_stats == false)
+		return FDW_IMPORT_STATS_DISABLED;
+
+	user = GetUserMapping(GetUserId(), table->serverid);
+	conn = GetConnection(user, false, NULL);
+	server_version_num = PQserverVersion(conn);
+
+	schemaname = get_namespace_name(RelationGetNamespace(relation));
+
+	relname = RelationGetRelationName(relation);
+
+	schemaname_l = quote_literal_cstr(schemaname);
+	relname_l = quote_literal_cstr(relname);
+
+	if (remote_schemaname == NULL)
+		remote_schemaname = schemaname;
+	if (remote_relname == NULL)
+		remote_relname = relname;
+	sql_params[0] = remote_schemaname;
+	sql_params[1] = remote_relname;
+
+	if (server_version_num >= 180000)
+		relation_sql = relstats_query_18;
+	else if (server_version_num >= 140000)
+		relation_sql = relstats_query_14;
+	else
+	 	relation_sql = relstats_query_default;
+
+	if (server_version_num >= 170000)
+		attribute_sql = attstats_query_17;
+	else if (server_version_num >= 90200)
+		attribute_sql = attstats_query_9_2;
+	else if (server_version_num >= 90000)
+		attribute_sql = attstats_query_9_0;
+	else
+		attribute_sql = attstats_query_default;
+
+	/*
+	 * pg_restore_attribute_stats
+	 *
+	 * We do this before relation stats because we may retry it if no stats were
+	 * found.
+	 */
+	if (!PQsendQueryParams(conn, attribute_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, attribute_sql);
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	res = pgfdw_get_result(conn);
+	if (PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQnfields(res) != ATTSTATS_NUM_FIELDS)
+	{
+		pgfdw_report(INFO, res, conn, attribute_sql);
+		PQclear(res);
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * If we got a query response of the right shape, but there were no rows,
+	 * then the remote is just missing statistics
+	 */
+	if (PQntuples(res) == 0)
+	{
+		StringInfoData	buf;
+
+		PQclear(res);
+
+		/*
+		 * If remote_analyze is not enabled, any failure to find statistics are
+		 * considered temporary. This is not an error, but we should fall back
+		 * to regular local analyzis if enabled.
+		 */
+		if (!remote_analyze)
+		{
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_NOTFOUND;
+		}
+
+		/*
+		 * Analyze the remote table and try again. If it's still empty, then that's
+		 * an error indicating that we probably shouldn't do remote analysis going
+		 * forward.
+		 */
+		initStringInfo(&buf);
+
+		appendStringInfo(&buf, "ANALYZE %s.%s",
+						 quote_identifier(remote_schemaname),
+						 quote_identifier(remote_relname));
+
+		res = pgfdw_exec_query(conn, buf.data, NULL);
+
+		if (res == NULL || PQresultStatus(res) != PGRES_COMMAND_OK)
+		{
+			pgfdw_report(NOTICE, res, conn, buf.data);
+			pfree(buf.data);
+			PQclear(res);
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_FAILED;
+		}
+
+		PQclear(res);
+		pfree(buf.data);
+
+		/* retry attribute stats query */
+		if (!PQsendQueryParams(conn, attribute_sql, 2, NULL, sql_params, NULL,
+							sql_param_formats, 0))
+		{
+			pgfdw_report(INFO, NULL, conn, attribute_sql);
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_FAILED;
+		}
+		res = pgfdw_get_result(conn);
+
+		/* getting nothing on the second try is a failure */
+		if (PQresultStatus(res) != PGRES_TUPLES_OK
+			|| PQntuples(res) == 0
+			|| PQnfields(res) != ATTSTATS_NUM_FIELDS)
+		{
+			pgfdw_report(INFO, res, conn, attribute_sql);
+			PQclear(res);
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_FAILED;
+		}
+	}
+
+	SPI_connect();
+
+	/*
+	 * Walk all local table attributes looking for name matches in the result
+	 * set and perform a pg_restore_attribute_stats() on each match.
+	 *
+	 * XXX: the result set is sorted by attname, so perhaps we could do a binary
+	 * search of the result set. Alternately we could collect the local attributes
+	 * in a list and sort that by remote name, which would allow us to iterate via
+	 * a merge.
+	 *
+	 * XXX: what should be done if match_found = false?
+	 */
+	tupdesc = RelationGetDescr(relation);
+	for (int i = 0; i < tupdesc->natts; i++)
+	{
+		char	   *remote_colname;
+		List	   *fc_options;
+		ListCell   *fc_lc;
+		AttrNumber	attnum;
+		bool		match_found = false;
+
+		/* Ignore dropped columns. */
+		if (TupleDescAttr(tupdesc, i)->attisdropped)
+			continue;
+
+		/* Ignore generated columns, but maybe this should fail the import? */
+		if (TupleDescAttr(tupdesc, i)->attgenerated)
+			continue;
+
+		attnum = TupleDescAttr(tupdesc, i)->attnum;
+
+		/* default remote_colname is attname */
+		remote_colname = NameStr(TupleDescAttr(tupdesc, i)->attname);
+		fc_options = GetForeignColumnOptions(RelationGetRelid(relation), i + 1);
+
+		foreach(fc_lc, fc_options)
+		{
+			DefElem    *def = (DefElem *) lfirst(fc_lc);
+
+			if (strcmp(def->defname, "column_name") == 0)
+			{
+				remote_colname = defGetString(def);
+				break;
+			}
+		}
+
+		for (int j = 0; j < PQntuples(res); j++)
+		{
+			char   *attimport_sql;
+
+			if (PQgetisnull(res, j, ATTSTATS_ATTNAME))
+				continue;
+
+			if (strcmp(PQgetvalue(res, j, ATTSTATS_ATTNAME), remote_colname) != 0)
+				continue;
+
+			match_found = true;
+			attimport_sql = restore_attribute_stats_sql(res, j, schemaname_l, relname_l, attnum, server_version_num);
+
+			if (SPI_execute(attimport_sql, false, 1) != SPI_OK_SELECT)
+			{
+				/*
+				 * It takes a lot to make a restore command fail outright, so any actual
+				 * failure is a sign that the statistics are seriously malformed, and
+				 * we should give up on importing stats for this table.
+				 */
+				ereport(INFO,
+						(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+							errmsg("Attribute statistics import failed %s", attimport_sql)));
+				SPI_finish();
+				ReleaseConnection(conn);
+				pfree(attimport_sql);
+				return FDW_IMPORT_STATS_FAILED;
+			}
+
+			pfree(attimport_sql);
+		}
+
+		/* TODO: should this be an error? What action could we take to remediate? */
+		if (!match_found)
+			ereport(INFO,
+					(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+						errmsg("Attribute statistics found for %s.%s but no columns matched",
+							   quote_identifier(schemaname),
+							   quote_identifier(relname))));
+	}
+	PQclear(res);
+
+	/*
+	 * pg_restore_relation_stats
+	 */
+	if (!PQsendQueryParams(conn, relation_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, relation_sql);
+		SPI_finish();
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	res = pgfdw_get_result(conn);
+	if (PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQntuples(res) != 1
+		|| PQnfields(res) != RELSTATS_NUM_FIELDS)
+	{
+		/* unable to get relation stats, fall back on table sampling */
+		pgfdw_report(INFO, res, conn, attribute_sql);
+		PQclear(res);
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	relimport_sql = restore_relation_stats_sql(res, schemaname_l, relname_l, server_version_num);
+
+	if (SPI_execute(relimport_sql, false, 0) != SPI_OK_SELECT)
+	{
+		/*
+		 * It takes a lot to make a restore command fail outright, so any actual
+		 * failure is a sign that the statistics are seriously malformed, and
+		 * we should give up on importing stats for this table.
+		 */
+		ereport(INFO,
+				(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+					errmsg("Relation statistics import failed %s", relimport_sql)));
+		SPI_finish();
+		ReleaseConnection(conn);
+		pfree(relimport_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	pfree(relimport_sql);
+
+	SPI_finish();
+
+	return FDW_IMPORT_STATS_OK;
+}
+
+
 /*
  * postgresGetAnalyzeInfoForForeignTable
  *		Count tuples in foreign table (just get pg_class.reltuples).
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 63bba3982cb..de575ce5bbd 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -1278,7 +1278,8 @@ REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -1287,6 +1288,8 @@ SELECT ftx.x1, ft2.c2, ftx.x8 FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8), ft2
 SELECT ftx.x1, ft2.c2, ftx FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8), ft2
   WHERE ftx.x1 = ft2.c1 AND ftx.x1 = 1; -- ERROR
 SELECT sum(c2), array_agg(c8) FROM ft1 GROUP BY c8; -- ERROR
+ANALYZE ft1; -- WARNING
+ALTER FOREIGN TABLE ft1 OPTIONS ( fetch_stats 'false' );
 ANALYZE ft1; -- ERROR
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE user_enum;
 
@@ -4376,6 +4379,38 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 
+-- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table', remote_analyze 'true');
+
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+ANALYZE remote_analyze_ftable;
+
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
-- 
2.50.1

#2Ashutosh Bapat
ashutosh.bapat.oss@gmail.com
In reply to: Corey Huinker (#1)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Tue, Aug 12, 2025 at 10:33 PM Corey Huinker <corey.huinker@gmail.com> wrote:

Attached is my current work on adding remote fetching of statistics to postgres_fdw, and opening the possibility of doing so to other foreign data wrappers.

This involves adding two new options to postgres_fdw at the server and table level.

The first option, fetch_stats, defaults to true at both levels. If enabled, it will cause an ANALYZE of a postgres_fdw foreign table to first attempt to fetch relation and attribute statistics from the remote table. If this succeeds, then those statistics are imported into the local foreign table, allowing us to skip a potentially expensive sampling operation.

The second option, remote_analyze, defaults to false at both levels, and only comes into play if the first fetch succeeds but no attribute statistics (i.e. the stats from pg_stats) are found. If enabled then the function will attempt to ANALYZE the remote table, and if that is successful then a second attempt at fetching remote statistics will be made.

If no statistics were fetched, then the operation will fall back to the normal sampling operation per settings.

Note patches 0001 and 0002 are already a part of a separate thread /messages/by-id/CADkLM=cpUiJ3QF7aUthTvaVMmgQcm7QqZBRMDLhBRTR+gJX-Og@mail.gmail.com regarding a bug (0001) and a nitpick (0002) that came about as a side-effect to this effort, and but I expect those to be resolved one way or another soon. Any feedback on those two can be handled there.

I think this is very useful to avoid fetching rows from foreign server
and analyzing them locally.

This isn't a full review. I looked at the patches mainly to find out
how does it fit into the current method of analysing a foreign table.
Right now, do_analyze_rel() is called with FDW specific acquireFunc,
which collects a sample of rows. The sample is passed to attribute
specific compute_stats which fills VacAttrStats for that attribute.
VacAttrStats for all the attributes is passed to update_attstats(),
which updates pg_statistics. The patch changes that to fetch the
statistics from the foreign server and call pg_restore_attribute_stats
for each attribute. Instead I was expecting that after fetching the
stats from the foreign server, it would construct VacAttrStats and
call update_attstats(). That might be marginally faster since it
avoids SPI call and updates stats for all the attributes. Did you
consider this alternate approach and why it was discarded?

If a foreign table points to an inheritance parent on the foreign
server, we will receive two rows for that table - one with inherited =
false and other with true in that order. I think the stats with
inherited=true are relevant to the local server since querying the
parent will fetch rows from children. Since that stats is applied
last, the pg_statistics will retain the intended statistics. But why
to fetch two rows in the first place and waste computing cycles?

--
Best Wishes,
Ashutosh Bapat

#3Corey Huinker
corey.huinker@gmail.com
In reply to: Ashutosh Bapat (#2)
Re: Import Statistics in postgres_fdw before resorting to sampling.

This isn't a full review. I looked at the patches mainly to find out
how does it fit into the current method of analysing a foreign table.

Any degree of review is welcome. We're chasing views, reviews, etc.

Right now, do_analyze_rel() is called with FDW specific acquireFunc,
which collects a sample of rows. The sample is passed to attribute
specific compute_stats which fills VacAttrStats for that attribute.
VacAttrStats for all the attributes is passed to update_attstats(),
which updates pg_statistics. The patch changes that to fetch the
statistics from the foreign server and call pg_restore_attribute_stats
for each attribute.

That recap is accurate.

Instead I was expecting that after fetching the
stats from the foreign server, it would construct VacAttrStats and
call update_attstats(). That might be marginally faster since it
avoids SPI call and updates stats for all the attributes. Did you
consider this alternate approach and why it was discarded?

It might be marginally faster, but it would duplicate a lot of the
pair-checking (must have a most-common-freqs with a most-common-vals, etc)
and type-checking logic (the vals in a most-common vals must all input
coerce to the correct datatype for the _destination_ column, etc), and
we've already got that in pg_restore_attribute_stats. There used to be a
non-fcinfo function that took a long list of Datums and isnull boolean
pairs, but that wasn't pretty to look at and was replaced with the
positional fcinfo version we have today. This use case might be a reason to
bring that back, or expose the existing positional fcinfo function
(presently static) if we want to avoid SPI badly enough. As it is, the SPI
code is fairly future proof in that it isn't required to add new stat types
as they are created. My first attempt at this patch attempted to make a
FunctionCallInvoke() on the variadic pg_restore_attribute_stats, but that
would require a filled out fn_expr, and to get that we'd have to duplicate
a lot of logic from the parser, so the infrastructure isn't available to
easily call a variadic function.

If a foreign table points to an inheritance parent on the foreign
server, we will receive two rows for that table - one with inherited =
false and other with true in that order. I think the stats with
inherited=true are relevant to the local server since querying the
parent will fetch rows from children. Since that stats is applied
last, the pg_statistics will retain the intended statistics. But why
to fetch two rows in the first place and waste computing cycles?

Glad you agree that we're fetching the right statistics.

That was the only way I could think of to do one client fetch and still get
exactly one row back.

Anything else involved fetching the inh=true first, and if that failed
fetching the inh=false statistics. That adds extra round-trips especially
given that inherited statistics are more rare than non-inherited
statistics. Moreoever, we're making decisions (analyze yes/no, fallback to
sampling yes/no) based on whether or not we got statistics back from the
foreign server at all, and having to consider the result of two fetches
instead of one makes that logic more complicated.

If, however, you were referring to the work we're handing the remote
server, I'm open to queries that you think would be more lightweight.
However, the pg_stats view is a security barrier view, so we reduce
overhead by passing through that barrier as few times as possible.

#4Corey Huinker
corey.huinker@gmail.com
In reply to: Corey Huinker (#3)
1 attachment(s)
Re: Import Statistics in postgres_fdw before resorting to sampling.

Rebased.

Attachments:

v2-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchtext/x-patch; charset=US-ASCII; name=v2-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchDownload
From 85f4d02ccef96d80063457a7870caf5f94e12859 Mon Sep 17 00:00:00 2001
From: Corey Huinker <corey.huinker@gmail.com>
Date: Thu, 7 Aug 2025 23:58:38 -0400
Subject: [PATCH v2] Add remote statistics fetching to postgres_fdw.

This adds the ability to fetch and import statistics from a remote
server table table rather than fetching the data or data sample from
that table.

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will first attempt to fetch statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If no statistics are found, then ANALYZE will fall back to the normal
behavior of sampling and local analysis.

This operation will only work on remote relations that can have stored
statistics: tables, partitioned tables, and materialized views. If the
remote relation is a view then remote fetching/analyzing is just wasted
effort and the user is better of setting fetch_stats to false for that
table.

The default for fetch_stats is true at both server and table level. The
default for remote_analyze is false at both the server and table level.
In both cases, setting a value at the table level will override the
corresponding server-level setting.
---
 src/include/foreign/fdwapi.h                  |  12 +
 src/backend/commands/analyze.c                |  46 +-
 doc/src/sgml/postgres-fdw.sgml                |  35 +-
 .../postgres_fdw/expected/postgres_fdw.out    |  64 +-
 contrib/postgres_fdw/option.c                 |  10 +
 contrib/postgres_fdw/postgres_fdw.c           | 546 ++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  37 +-
 7 files changed, 746 insertions(+), 4 deletions(-)

diff --git a/src/include/foreign/fdwapi.h b/src/include/foreign/fdwapi.h
index b4da4e6a16a..f0a7db424db 100644
--- a/src/include/foreign/fdwapi.h
+++ b/src/include/foreign/fdwapi.h
@@ -19,6 +19,15 @@
 /* To avoid including explain.h here, reference ExplainState thus: */
 struct ExplainState;
 
+/* result of ImportStatistics */
+typedef enum
+{
+	FDW_IMPORT_STATS_OK = 0,		/* was able to import statistics */
+	FDW_IMPORT_STATS_DISABLED,		/* import disabled for this table */
+	FDW_IMPORT_STATS_NOTFOUND,		/* no remote attribute stats found */
+	FDW_IMPORT_STATS_FAILED			/* remote query failure of some kind */
+} FdwImportStatsResult;
+
 
 /*
  * Callback function signatures --- see fdwhandler.sgml for more info.
@@ -157,6 +166,8 @@ typedef bool (*AnalyzeForeignTable_function) (Relation relation,
 											  AcquireSampleRowsFunc *func,
 											  BlockNumber *totalpages);
 
+typedef FdwImportStatsResult (*ImportStatistics_function) (Relation relation);
+
 typedef List *(*ImportForeignSchema_function) (ImportForeignSchemaStmt *stmt,
 											   Oid serverOid);
 
@@ -255,6 +266,7 @@ typedef struct FdwRoutine
 
 	/* Support functions for ANALYZE */
 	AnalyzeForeignTable_function AnalyzeForeignTable;
+	ImportStatistics_function ImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	ImportForeignSchema_function ImportForeignSchema;
diff --git a/src/backend/commands/analyze.c b/src/backend/commands/analyze.c
index 8ea2913d906..eada41660ee 100644
--- a/src/backend/commands/analyze.c
+++ b/src/backend/commands/analyze.c
@@ -196,13 +196,57 @@ analyze_rel(Oid relid, RangeVar *relation,
 	{
 		/*
 		 * For a foreign table, call the FDW's hook function to see whether it
-		 * supports analysis.
+		 * supports statistics import and/or analysis.
 		 */
 		FdwRoutine *fdwroutine;
 		bool		ok = false;
 
 		fdwroutine = GetFdwRoutineForRelation(onerel, false);
 
+		if (fdwroutine->ImportStatistics != NULL)
+		{
+			FdwImportStatsResult	res;
+
+			/*
+			 * Fetching pre-existing remote stats is not guaranteed to be a quick
+			 * operation.
+			 *
+			 * XXX: Should this be it's own fetch type? If not, then there might be
+			 * confusion when a long stats-fetch fails, followed by a regular analyze,
+			 * which would make it look like the table was analyzed twice.
+			 */
+			pgstat_progress_start_command(PROGRESS_COMMAND_ANALYZE,
+										  RelationGetRelid(onerel));
+
+			res = fdwroutine->ImportStatistics(onerel);
+
+			pgstat_progress_end_command();
+
+			/*
+			 * If we were able to import statistics, then there is no need to collect
+			 * samples for local analysis.
+			 */
+			switch(res)
+			{
+				case FDW_IMPORT_STATS_OK:
+					relation_close(onerel, NoLock);
+					return;
+					break;
+				case FDW_IMPORT_STATS_DISABLED:
+					break;
+				case FDW_IMPORT_STATS_NOTFOUND:
+					ereport(INFO,
+							(errmsg("Found no remote statistics for \"%s\"",
+									RelationGetRelationName(onerel))));
+					break;
+				case FDW_IMPORT_STATS_FAILED:
+				default:
+					ereport(INFO,
+							(errmsg("Fetching remote statistics from \"%s\" failed",
+									RelationGetRelationName(onerel))));
+			}
+		}
+
 		if (fdwroutine->AnalyzeForeignTable != NULL)
 			ok = fdwroutine->AnalyzeForeignTable(onerel,
 												 &acquirefunc,
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index 781a01067f7..c395d1061d5 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -332,7 +332,7 @@ OPTIONS (ADD password_required 'false');
    </para>
 
    <para>
-    The following option controls how such an <command>ANALYZE</command>
+    The following options control how such an <command>ANALYZE</command>
     operation behaves:
    </para>
 
@@ -364,6 +364,39 @@ OPTIONS (ADD password_required 'false');
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>fetch_stats</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines if <command>ANALYZE</command> on a foreign table
+       will first attempt to fetch and import the existing relation and
+       attribute statistics from the remote table, and only attempt regular
+       data sampling if no statistics were availble. This option is only
+       useful if the remote relation is one that can have regular statistics
+       (tables and materialized views).
+       The default is <literal>true</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><literal>remote_analyze</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines wheter an <command>ANALYZE</command> on a foreign
+       table will attempt to <command>ANALYZE</command> the remote table if
+       the first attempt to fetch remote statistics fails, and will then
+       make a second and final attempt to fetch remote statistics. This option
+       is only meaningful if the foreign table has
+       <literal>fetch_stats</literal> enabled at either the server or table
+       level.
+       The default is <literal>false</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
    </variablelist>
 
   </sect3>
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 18d727d7790..78ed7631025 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -628,6 +628,7 @@ INSERT INTO loct_empty
   SELECT id, 'AAA' || to_char(id, 'FM000') FROM generate_series(1, 100) id;
 DELETE FROM loct_empty;
 ANALYZE ft_empty;
+INFO:  Found no remote statistics for "ft_empty"
 EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM ft_empty ORDER BY c1;
                                   QUERY PLAN                                   
 -------------------------------------------------------------------------------
@@ -4548,7 +4549,8 @@ REINDEX TABLE reind_fdw_parent; -- ok
 REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -4565,6 +4567,9 @@ CONTEXT:  whole-row reference to foreign table "ftx"
 SELECT sum(c2), array_agg(c8) FROM ft1 GROUP BY c8; -- ERROR
 ERROR:  invalid input syntax for type integer: "foo"
 CONTEXT:  processing expression at position 2 in select list
+ANALYZE ft1; -- WARNING
+WARNING:  invalid input syntax for type integer: "foo"
+ALTER FOREIGN TABLE ft1 OPTIONS ( fetch_stats 'false' );
 ANALYZE ft1; -- ERROR
 ERROR:  invalid input syntax for type integer: "foo"
 CONTEXT:  column "c8" of foreign table "ft1"
@@ -7097,6 +7102,7 @@ INSERT INTO loct2 VALUES (1002, 'bar');
 CREATE FOREIGN TABLE remt2 (c1 int, c2 text) SERVER loopback OPTIONS (table_name 'loct2');
 ANALYZE loct1;
 ANALYZE remt2;
+INFO:  Found no remote statistics for "remt2"
 SET enable_mergejoin TO false;
 SET enable_hashjoin TO false;
 SET enable_material TO false;
@@ -8776,6 +8782,7 @@ alter foreign table foo2 options (use_remote_estimate 'true');
 create index i_loct1_f1 on loct1(f1);
 create index i_foo_f1 on foo(f1);
 analyze foo;
+INFO:  Found no remote statistics for "foo2"
 analyze loct1;
 -- inner join; expressions in the clauses appear in the equivalence class list
 explain (verbose, costs off)
@@ -9005,7 +9012,9 @@ insert into remt1 values (2, 'bar');
 insert into remt2 values (1, 'foo');
 insert into remt2 values (2, 'bar');
 analyze remt1;
+INFO:  Found no remote statistics for "remt1"
 analyze remt2;
+INFO:  Found no remote statistics for "remt2"
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
                                                    QUERY PLAN                                                   
@@ -10305,6 +10314,8 @@ CREATE FOREIGN TABLE ftprt1_p1 PARTITION OF fprt1 FOR VALUES FROM (0) TO (250)
 CREATE FOREIGN TABLE ftprt1_p2 PARTITION OF fprt1 FOR VALUES FROM (250) TO (500)
 	SERVER loopback OPTIONS (TABLE_NAME 'fprt1_p2');
 ANALYZE fprt1;
+INFO:  Found no remote statistics for "ftprt1_p1"
+INFO:  Found no remote statistics for "ftprt1_p2"
 ANALYZE fprt1_p1;
 ANALYZE fprt1_p2;
 CREATE TABLE fprt2 (a int, b int, c varchar) PARTITION BY RANGE(b);
@@ -10320,6 +10331,8 @@ ALTER TABLE fprt2 ATTACH PARTITION ftprt2_p1 FOR VALUES FROM (0) TO (250);
 CREATE FOREIGN TABLE ftprt2_p2 PARTITION OF fprt2 FOR VALUES FROM (250) TO (500)
 	SERVER loopback OPTIONS (table_name 'fprt2_p2', use_remote_estimate 'true');
 ANALYZE fprt2;
+INFO:  Found no remote statistics for "ftprt2_p1"
+INFO:  Found no remote statistics for "ftprt2_p2"
 ANALYZE fprt2_p1;
 ANALYZE fprt2_p2;
 -- inner join three tables
@@ -10507,9 +10520,15 @@ CREATE FOREIGN TABLE fpagg_tab_p1 PARTITION OF pagg_tab FOR VALUES FROM (0) TO (
 CREATE FOREIGN TABLE fpagg_tab_p2 PARTITION OF pagg_tab FOR VALUES FROM (10) TO (20) SERVER loopback OPTIONS (table_name 'pagg_tab_p2');
 CREATE FOREIGN TABLE fpagg_tab_p3 PARTITION OF pagg_tab FOR VALUES FROM (20) TO (30) SERVER loopback OPTIONS (table_name 'pagg_tab_p3');
 ANALYZE pagg_tab;
+INFO:  Found no remote statistics for "fpagg_tab_p1"
+INFO:  Found no remote statistics for "fpagg_tab_p2"
+INFO:  Found no remote statistics for "fpagg_tab_p3"
 ANALYZE fpagg_tab_p1;
+INFO:  Found no remote statistics for "fpagg_tab_p1"
 ANALYZE fpagg_tab_p2;
+INFO:  Found no remote statistics for "fpagg_tab_p2"
 ANALYZE fpagg_tab_p3;
+INFO:  Found no remote statistics for "fpagg_tab_p3"
 -- When GROUP BY clause matches with PARTITION KEY.
 -- Plan with partitionwise aggregates is disabled
 SET enable_partitionwise_aggregate TO false;
@@ -11455,6 +11474,8 @@ CREATE FOREIGN TABLE async_p2 PARTITION OF async_pt FOR VALUES FROM (2000) TO (3
 INSERT INTO async_p1 SELECT 1000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 INSERT INTO async_p2 SELECT 2000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
 -- simple queries
 CREATE TABLE result_tbl (a int, b int, c text);
 EXPLAIN (VERBOSE, COSTS OFF)
@@ -11561,6 +11582,9 @@ CREATE FOREIGN TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4
   SERVER loopback2 OPTIONS (table_name 'base_tbl3');
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
+INFO:  Found no remote statistics for "async_p3"
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
                            QUERY PLAN                           
@@ -11597,6 +11621,8 @@ DROP TABLE base_tbl3;
 CREATE TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4000);
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
                            QUERY PLAN                           
@@ -12652,6 +12678,42 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 -- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table', remote_analyze 'true');
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+ tablename | num_stats 
+-----------+-----------
+(0 rows)
+
+ANALYZE remote_analyze_ftable;
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+       tablename       | num_stats 
+-----------------------+-----------
+ remote_analyze_ftable |         3
+ remote_analyze_table  |         3
+(2 rows)
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+-- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
 -- Disable debug_discard_caches in order to manage remote connections
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 04788b7e8b3..7f069373e82 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -120,6 +120,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 			strcmp(def->defname, "async_capable") == 0 ||
 			strcmp(def->defname, "parallel_commit") == 0 ||
 			strcmp(def->defname, "parallel_abort") == 0 ||
+			strcmp(def->defname, "fetch_stats") == 0 ||
+			strcmp(def->defname, "remote_analyze") == 0 ||
 			strcmp(def->defname, "keep_connections") == 0)
 		{
 			/* these accept only boolean values */
@@ -278,6 +280,14 @@ InitPgFdwOptions(void)
 		{"use_scram_passthrough", ForeignServerRelationId, false},
 		{"use_scram_passthrough", UserMappingRelationId, false},
 
+		/* fetch_size is available on both server and table */
+		{"fetch_stats", ForeignServerRelationId, false},
+		{"fetch_stats", ForeignTableRelationId, false},
+
+		/* remote_analyze is available on both server and table */
+		{"remote_analyze", ForeignServerRelationId, false},
+		{"remote_analyze", ForeignTableRelationId, false},
+
 		/*
 		 * sslcert and sslkey are in fact libpq options, but we repeat them
 		 * here to allow them to appear in both foreign server context (when
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 456b267f70b..096cd4d0fdd 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -22,6 +22,8 @@
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "executor/execAsync.h"
+#include "executor/spi.h"
+#include "fmgr.h"
 #include "foreign/fdwapi.h"
 #include "funcapi.h"
 #include "miscadmin.h"
@@ -402,6 +404,7 @@ static void postgresExecForeignTruncate(List *rels,
 static bool postgresAnalyzeForeignTable(Relation relation,
 										AcquireSampleRowsFunc *func,
 										BlockNumber *totalpages);
+static FdwImportStatsResult postgresImportStatistics(Relation relation);
 static List *postgresImportForeignSchema(ImportForeignSchemaStmt *stmt,
 										 Oid serverOid);
 static void postgresGetForeignJoinPaths(PlannerInfo *root,
@@ -546,6 +549,114 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
 
+/*
+ * Static queries for querying remote statistics.
+ */
+
+
+/* relallfrozen introduced in v18 */
+static const char *relstats_query_18 =
+	"SELECT c.relpages, c.reltuples, c.relallvisible, c.relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* trust reltuples = 0 as of v14 */
+static const char *relstats_query_14 =
+	"SELECT c.relpages, c.reltuples, c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/*
+ * Before v14, a reltuples value of 0 was ambiguous: it could either mean
+ * the relation is empty, or it could mean that it hadn't yet been
+ * vacuumed or analyzed.  (Newer versions use -1 for the latter case.)
+ * This ambiguity allegedly can cause the planner to choose inefficient
+ * plans after restoring to v18 or newer.  To deal with this, let's just
+ * set reltuples to -1 in that case.
+ */
+static const char *relstats_query_default =
+	"SELECT c.relpages, "
+	"CASE c.reltuples WHEN 0 THEN -1 ELSE c.reltuples END AS reltuples, "
+	"c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* All static relstats queries have the same column order */
+enum RelStatsColumns {
+	RELSTATS_RELPAGES = 0,
+	RELSTATS_RELTUPLES,
+	RELSTATS_RELALLVISIBLE,
+	RELSTATS_RELALLFROZEN,
+	RELSTATS_NUM_FIELDS
+};
+
+/* range stats introduced in v17 */
+static const char *attstats_query_17 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"s.range_length_histogram, s.range_empty_frac, s.range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* elements stats introduced in 9.2 */
+static const char *attstats_query_9_2 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* inherited introduced in 9.0 */
+static const char *attstats_query_9_0 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+static const char *attstats_query_default =
+	"SELECT s.attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname";
+
+/* All static attstats queries have the same column order */
+enum AttStatsColumns {
+	ATTSTATS_ATTNAME = 0,
+	ATTSTATS_NULL_FRAC,
+	ATTSTATS_AVG_WIDTH,
+	ATTSTATS_N_DISTINCT,
+	ATTSTATS_MOST_COMMON_VALS,
+	ATTSTATS_MOST_COMMON_FREQS,
+	ATTSTATS_HISTOGRAM_BOUNDS,
+	ATTSTATS_CORRELATION,
+	ATTSTATS_MOST_COMMON_ELEMS,
+	ATTSTATS_MOST_COMMON_ELEM_FREQS,
+	ATTSTATS_ELEM_COUNT_HISTOGRAM,
+	ATTSTATS_RANGE_LENGTH_HISTOGRAM,
+	ATTSTATS_RANGE_EMPTY_FRAC,
+	ATTSTATS_RANGE_BOUNDS_HISTOGRAM,
+	ATTSTATS_NUM_FIELDS
+};
 
 /*
  * Foreign-data wrapper handler function: return a struct with pointers
@@ -595,6 +706,7 @@ postgres_fdw_handler(PG_FUNCTION_ARGS)
 
 	/* Support functions for ANALYZE */
 	routine->AnalyzeForeignTable = postgresAnalyzeForeignTable;
+	routine->ImportStatistics = postgresImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	routine->ImportForeignSchema = postgresImportForeignSchema;
@@ -4935,6 +5047,440 @@ postgresAnalyzeForeignTable(Relation relation,
 	return true;
 }
 
+/*
+ * Process optional argument.
+ *
+ * Cannot be the first argument in the SQL function call.
+ *
+ * It is safe to presume that argname and argtype are quote-safe.
+ *
+ * Argument values can potentially be quite large, so free the quoted string
+ * after use.
+ */
+static void
+append_optional(StringInfo str, PGresult *res, int row, int field,
+				const char *argname, const char *argtype)
+{
+	if (!PQgetisnull(res, row, field))
+	{
+		/* Argument values can be quite large, so free after use */
+		char *argval_l = quote_literal_cstr(PQgetvalue(res, row, field));
+
+		appendStringInfo(str, ",\n\t'%s', %s::%s", argname, argval_l, argtype);
+
+		pfree(argval_l);
+	}
+}
+
+
+/*
+ * Generate a pg_restore_relation_stats command.
+ */
+static char *
+restore_relation_stats_sql(PGresult *res, const char *schemaname_l,
+						   const char *relname_l,
+						   const int server_version_num)
+{
+	StringInfoData	sql;
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_relation_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s",
+					 server_version_num, schemaname_l, relname_l);
+
+	append_optional(&sql, res, 0, RELSTATS_RELPAGES, "relpages", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELTUPLES, "reltuples", "real");
+	append_optional(&sql, res, 0, RELSTATS_RELALLVISIBLE, "relallvisible", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELALLFROZEN, "relallfrozen", "integer");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * Generate a pg_restore_attribute_stats command.
+ */
+static char *
+restore_attribute_stats_sql(PGresult *res, int row, const char *schemaname_l,
+							const char *relname_l, const AttrNumber attnum,
+							const int server_version_num)
+{
+	StringInfoData	sql;
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_attribute_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s,\n"
+					 "\t'attnum', %d::smallint,\n"
+					 "\t'inherited', false::boolean",
+					 server_version_num, schemaname_l, relname_l, attnum);
+
+	append_optional(&sql, res, row, ATTSTATS_NULL_FRAC, "null_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_AVG_WIDTH, "avg_width", "integer");
+	append_optional(&sql, res, row, ATTSTATS_N_DISTINCT, "n_distinct", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_VALS, "most_common_vals", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_FREQS, "most_common_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_HISTOGRAM_BOUNDS, "histogram_bounds", "text");
+	append_optional(&sql, res, row, ATTSTATS_CORRELATION, "correlation", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEMS, "most_common_elems", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEM_FREQS, "most_common_elem_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_ELEM_COUNT_HISTOGRAM, "elem_count_histogram", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_LENGTH_HISTOGRAM, "range_length_histogram", "text");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_EMPTY_FRAC, "range_empty_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_BOUNDS_HISTOGRAM, "range_bounds_histogram", "text");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * postgresImportStatistics
+ * 		Attempt to fetch remote statistics and apply those instead of analyzing.
+ */
+static FdwImportStatsResult
+postgresImportStatistics(Relation relation)
+{
+
+	ForeignTable   *table;
+	ForeignServer  *server;
+	UserMapping	   *user;
+	PGconn		   *conn;
+	ListCell	   *lc;
+	bool			fetch_stats = true;
+	bool			remote_analyze = false;
+	int				server_version_num;
+
+	const char	   *relation_sql;
+	const char	   *attribute_sql;
+	const char	   *schemaname;
+	const char	   *relname;
+	const char	   *remote_schemaname = NULL;
+	const char	   *remote_relname = NULL;
+
+	const char	   *schemaname_l;
+	const char	   *relname_l;
+
+	char   *relimport_sql;
+
+	PGresult   *res;
+	TupleDesc	tupdesc;
+	const char *sql_params[2];
+	int			sql_param_formats[2] = {0, 0};
+
+	table = GetForeignTable(RelationGetRelid(relation));
+	server = GetForeignServer(table->serverid);
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+			fetch_stats = defGetBoolean(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+			fetch_stats = defGetBoolean(def);
+		else if (strcmp(def->defname, "schema_name") == 0)
+			remote_schemaname = defGetString(def);
+		else if (strcmp(def->defname, "table_name") == 0)
+			remote_relname = defGetString(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	if (fetch_stats == false)
+		return FDW_IMPORT_STATS_DISABLED;
+
+	user = GetUserMapping(GetUserId(), table->serverid);
+	conn = GetConnection(user, false, NULL);
+	server_version_num = PQserverVersion(conn);
+
+	schemaname = get_namespace_name(RelationGetNamespace(relation));
+
+	relname = RelationGetRelationName(relation);
+
+	schemaname_l = quote_literal_cstr(schemaname);
+	relname_l = quote_literal_cstr(relname);
+
+	if (remote_schemaname == NULL)
+		remote_schemaname = schemaname;
+	if (remote_relname == NULL)
+		remote_relname = relname;
+	sql_params[0] = remote_schemaname;
+	sql_params[1] = remote_relname;
+
+	if (server_version_num >= 180000)
+		relation_sql = relstats_query_18;
+	else if (server_version_num >= 140000)
+		relation_sql = relstats_query_14;
+	else
+	 	relation_sql = relstats_query_default;
+
+	if (server_version_num >= 170000)
+		attribute_sql = attstats_query_17;
+	else if (server_version_num >= 90200)
+		attribute_sql = attstats_query_9_2;
+	else if (server_version_num >= 90000)
+		attribute_sql = attstats_query_9_0;
+	else
+		attribute_sql = attstats_query_default;
+
+	/*
+	 * pg_restore_attribute_stats
+	 *
+	 * We do this before relation stats because we may retry it if no stats were
+	 * found.
+	 */
+	if (!PQsendQueryParams(conn, attribute_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, attribute_sql);
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	res = pgfdw_get_result(conn);
+	if (PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQnfields(res) != ATTSTATS_NUM_FIELDS)
+	{
+		pgfdw_report(INFO, res, conn, attribute_sql);
+		PQclear(res);
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * If we got a query response of the right shape, but there were no rows,
+	 * then the remote is just missing statistics
+	 */
+	if (PQntuples(res) == 0)
+	{
+		StringInfoData	buf;
+
+		PQclear(res);
+
+		/*
+		 * If remote_analyze is not enabled, any failure to find statistics are
+		 * considered temporary. This is not an error, but we should fall back
+		 * to regular local analyzis if enabled.
+		 */
+		if (!remote_analyze)
+		{
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_NOTFOUND;
+		}
+
+		/*
+		 * Analyze the remote table and try again. If it's still empty, then that's
+		 * an error indicating that we probably shouldn't do remote analysis going
+		 * forward.
+		 */
+		initStringInfo(&buf);
+
+		appendStringInfo(&buf, "ANALYZE %s.%s",
+						 quote_identifier(remote_schemaname),
+						 quote_identifier(remote_relname));
+
+		res = pgfdw_exec_query(conn, buf.data, NULL);
+
+		if (res == NULL || PQresultStatus(res) != PGRES_COMMAND_OK)
+		{
+			pgfdw_report(NOTICE, res, conn, buf.data);
+			pfree(buf.data);
+			PQclear(res);
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_FAILED;
+		}
+
+		PQclear(res);
+		pfree(buf.data);
+
+		/* retry attribute stats query */
+		if (!PQsendQueryParams(conn, attribute_sql, 2, NULL, sql_params, NULL,
+							sql_param_formats, 0))
+		{
+			pgfdw_report(INFO, NULL, conn, attribute_sql);
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_FAILED;
+		}
+		res = pgfdw_get_result(conn);
+
+		/* getting nothing on the second try is a failure */
+		if (PQresultStatus(res) != PGRES_TUPLES_OK
+			|| PQntuples(res) == 0
+			|| PQnfields(res) != ATTSTATS_NUM_FIELDS)
+		{
+			pgfdw_report(INFO, res, conn, attribute_sql);
+			PQclear(res);
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_FAILED;
+		}
+	}
+
+	SPI_connect();
+
+	/*
+	 * Walk all local table attributes looking for name matches in the result
+	 * set and perform a pg_restore_attribute_stats() on each match.
+	 *
+	 * XXX: the result set is sorted by attname, so perhaps we could do a binary
+	 * search of the result set. Alternately we could collect the local attributes
+	 * in a list and sort that by remote name, which would allow us to iterate via
+	 * a merge.
+	 *
+	 * XXX: what should be done if match_found = false?
+	 */
+	tupdesc = RelationGetDescr(relation);
+	for (int i = 0; i < tupdesc->natts; i++)
+	{
+		char	   *remote_colname;
+		List	   *fc_options;
+		ListCell   *fc_lc;
+		AttrNumber	attnum;
+		bool		match_found = false;
+
+		/* Ignore dropped columns. */
+		if (TupleDescAttr(tupdesc, i)->attisdropped)
+			continue;
+
+		/* Ignore generated columns, but maybe this should fail the import? */
+		if (TupleDescAttr(tupdesc, i)->attgenerated)
+			continue;
+
+		attnum = TupleDescAttr(tupdesc, i)->attnum;
+
+		/* default remote_colname is attname */
+		remote_colname = NameStr(TupleDescAttr(tupdesc, i)->attname);
+		fc_options = GetForeignColumnOptions(RelationGetRelid(relation), i + 1);
+
+		foreach(fc_lc, fc_options)
+		{
+			DefElem    *def = (DefElem *) lfirst(fc_lc);
+
+			if (strcmp(def->defname, "column_name") == 0)
+			{
+				remote_colname = defGetString(def);
+				break;
+			}
+		}
+
+		for (int j = 0; j < PQntuples(res); j++)
+		{
+			char   *attimport_sql;
+
+			if (PQgetisnull(res, j, ATTSTATS_ATTNAME))
+				continue;
+
+			if (strcmp(PQgetvalue(res, j, ATTSTATS_ATTNAME), remote_colname) != 0)
+				continue;
+
+			match_found = true;
+			attimport_sql = restore_attribute_stats_sql(res, j, schemaname_l, relname_l, attnum, server_version_num);
+
+			if (SPI_execute(attimport_sql, false, 1) != SPI_OK_SELECT)
+			{
+				/*
+				 * It takes a lot to make a restore command fail outright, so any actual
+				 * failure is a sign that the statistics are seriously malformed, and
+				 * we should give up on importing stats for this table.
+				 */
+				ereport(INFO,
+						(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+							errmsg("Attribute statistics import failed %s", attimport_sql)));
+				SPI_finish();
+				ReleaseConnection(conn);
+				pfree(attimport_sql);
+				return FDW_IMPORT_STATS_FAILED;
+			}
+
+			pfree(attimport_sql);
+		}
+
+		/* TODO: should this be an error? What action could we take to remediate? */
+		if (!match_found)
+			ereport(INFO,
+					(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+						errmsg("Attribute statistics found for %s.%s but no columns matched",
+							   quote_identifier(schemaname),
+							   quote_identifier(relname))));
+	}
+	PQclear(res);
+
+	/*
+	 * pg_restore_relation_stats
+	 */
+	if (!PQsendQueryParams(conn, relation_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, relation_sql);
+		SPI_finish();
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	res = pgfdw_get_result(conn);
+	if (PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQntuples(res) != 1
+		|| PQnfields(res) != RELSTATS_NUM_FIELDS)
+	{
+		/* unable to get relation stats, fall back on table sampling */
+		pgfdw_report(INFO, res, conn, attribute_sql);
+		PQclear(res);
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	relimport_sql = restore_relation_stats_sql(res, schemaname_l, relname_l, server_version_num);
+
+	if (SPI_execute(relimport_sql, false, 0) != SPI_OK_SELECT)
+	{
+		/*
+		 * It takes a lot to make a restore command fail outright, so any actual
+		 * failure is a sign that the statistics are seriously malformed, and
+		 * we should give up on importing stats for this table.
+		 */
+		ereport(INFO,
+				(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+					errmsg("Relation statistics import failed %s", relimport_sql)));
+		SPI_finish();
+		ReleaseConnection(conn);
+		pfree(relimport_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	pfree(relimport_sql);
+
+	SPI_finish();
+
+	return FDW_IMPORT_STATS_OK;
+}
+
+
 /*
  * postgresGetAnalyzeInfoForForeignTable
  *		Count tuples in foreign table (just get pg_class.reltuples).
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 3b7da128519..cddc57bacc9 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -1278,7 +1278,8 @@ REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -1287,6 +1288,8 @@ SELECT ftx.x1, ft2.c2, ftx.x8 FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8), ft2
 SELECT ftx.x1, ft2.c2, ftx FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8), ft2
   WHERE ftx.x1 = ft2.c1 AND ftx.x1 = 1; -- ERROR
 SELECT sum(c2), array_agg(c8) FROM ft1 GROUP BY c8; -- ERROR
+ANALYZE ft1; -- WARNING
+ALTER FOREIGN TABLE ft1 OPTIONS ( fetch_stats 'false' );
 ANALYZE ft1; -- ERROR
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE user_enum;
 
@@ -4376,6 +4379,38 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 
+-- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table', remote_analyze 'true');
+
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+ANALYZE remote_analyze_ftable;
+
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================

base-commit: 2d756ebbe857e3d395d18350bf232300ebd23981
-- 
2.51.0

#5Corey Huinker
corey.huinker@gmail.com
In reply to: Corey Huinker (#4)
1 attachment(s)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Fri, Sep 12, 2025 at 1:17 AM Corey Huinker <corey.huinker@gmail.com>
wrote:

Rebased.

Rebased again.

Attachments:

v3-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchtext/x-patch; charset=US-ASCII; name=v3-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchDownload
From f44b3223c1a15fd7213b2227669ff400f8c01efa Mon Sep 17 00:00:00 2001
From: Corey Huinker <corey.huinker@gmail.com>
Date: Thu, 7 Aug 2025 23:58:38 -0400
Subject: [PATCH v3] Add remote statistics fetching to postgres_fdw.

This adds the ability to fetch and import statistics from a remote
server table table rather than fetching the data or data sample from
that table.

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will first attempt to fetch statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If no statistics are found, then ANALYZE will fall back to the normal
behavior of sampling and local analysis.

This operation will only work on remote relations that can have stored
statistics: tables, partitioned tables, and materialized views. If the
remote relation is a view then remote fetching/analyzing is just wasted
effort and the user is better of setting fetch_stats to false for that
table.

The default for fetch_stats is true at both server and table level. The
default for remote_analyze is false at both the server and table level.
In both cases, setting a value at the table level will override the
corresponding server-level setting.
---
 src/include/foreign/fdwapi.h                  |  12 +
 src/backend/commands/analyze.c                |  46 +-
 doc/src/sgml/postgres-fdw.sgml                |  35 +-
 .../postgres_fdw/expected/postgres_fdw.out    |  64 +-
 contrib/postgres_fdw/option.c                 |  10 +
 contrib/postgres_fdw/postgres_fdw.c           | 546 ++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  37 +-
 7 files changed, 746 insertions(+), 4 deletions(-)

diff --git a/src/include/foreign/fdwapi.h b/src/include/foreign/fdwapi.h
index fcd7e7027f3..eb6284979fb 100644
--- a/src/include/foreign/fdwapi.h
+++ b/src/include/foreign/fdwapi.h
@@ -19,6 +19,15 @@
 /* avoid including explain_state.h here */
 typedef struct ExplainState ExplainState;
 
+/* result of ImportStatistics */
+typedef enum
+{
+	FDW_IMPORT_STATS_OK = 0,		/* was able to import statistics */
+	FDW_IMPORT_STATS_DISABLED,		/* import disabled for this table */
+	FDW_IMPORT_STATS_NOTFOUND,		/* no remote attribute stats found */
+	FDW_IMPORT_STATS_FAILED			/* remote query failure of some kind */
+} FdwImportStatsResult;
+
 
 /*
  * Callback function signatures --- see fdwhandler.sgml for more info.
@@ -157,6 +166,8 @@ typedef bool (*AnalyzeForeignTable_function) (Relation relation,
 											  AcquireSampleRowsFunc *func,
 											  BlockNumber *totalpages);
 
+typedef FdwImportStatsResult (*ImportStatistics_function) (Relation relation);
+
 typedef List *(*ImportForeignSchema_function) (ImportForeignSchemaStmt *stmt,
 											   Oid serverOid);
 
@@ -255,6 +266,7 @@ typedef struct FdwRoutine
 
 	/* Support functions for ANALYZE */
 	AnalyzeForeignTable_function AnalyzeForeignTable;
+	ImportStatistics_function ImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	ImportForeignSchema_function ImportForeignSchema;
diff --git a/src/backend/commands/analyze.c b/src/backend/commands/analyze.c
index c2e216563c6..616333d5574 100644
--- a/src/backend/commands/analyze.c
+++ b/src/backend/commands/analyze.c
@@ -196,13 +196,57 @@ analyze_rel(Oid relid, RangeVar *relation,
 	{
 		/*
 		 * For a foreign table, call the FDW's hook function to see whether it
-		 * supports analysis.
+		 * supports statistics import and/or analysis.
 		 */
 		FdwRoutine *fdwroutine;
 		bool		ok = false;
 
 		fdwroutine = GetFdwRoutineForRelation(onerel, false);
 
+		if (fdwroutine->ImportStatistics != NULL)
+		{
+			FdwImportStatsResult	res;
+
+			/*
+			 * Fetching pre-existing remote stats is not guaranteed to be a quick
+			 * operation.
+			 *
+			 * XXX: Should this be it's own fetch type? If not, then there might be
+			 * confusion when a long stats-fetch fails, followed by a regular analyze,
+			 * which would make it look like the table was analyzed twice.
+			 */
+			pgstat_progress_start_command(PROGRESS_COMMAND_ANALYZE,
+										  RelationGetRelid(onerel));
+
+			res = fdwroutine->ImportStatistics(onerel);
+
+			pgstat_progress_end_command();
+
+			/*
+			 * If we were able to import statistics, then there is no need to collect
+			 * samples for local analysis.
+			 */
+			switch(res)
+			{
+				case FDW_IMPORT_STATS_OK:
+					relation_close(onerel, NoLock);
+					return;
+					break;
+				case FDW_IMPORT_STATS_DISABLED:
+					break;
+				case FDW_IMPORT_STATS_NOTFOUND:
+					ereport(INFO,
+							(errmsg("Found no remote statistics for \"%s\"",
+									RelationGetRelationName(onerel))));
+					break;
+				case FDW_IMPORT_STATS_FAILED:
+				default:
+					ereport(INFO,
+							(errmsg("Fetching remote statistics from \"%s\" failed",
+									RelationGetRelationName(onerel))));
+			}
+		}
+
 		if (fdwroutine->AnalyzeForeignTable != NULL)
 			ok = fdwroutine->AnalyzeForeignTable(onerel,
 												 &acquirefunc,
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index 781a01067f7..c395d1061d5 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -332,7 +332,7 @@ OPTIONS (ADD password_required 'false');
    </para>
 
    <para>
-    The following option controls how such an <command>ANALYZE</command>
+    The following options control how such an <command>ANALYZE</command>
     operation behaves:
    </para>
 
@@ -364,6 +364,39 @@ OPTIONS (ADD password_required 'false');
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>fetch_stats</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines if <command>ANALYZE</command> on a foreign table
+       will first attempt to fetch and import the existing relation and
+       attribute statistics from the remote table, and only attempt regular
+       data sampling if no statistics were availble. This option is only
+       useful if the remote relation is one that can have regular statistics
+       (tables and materialized views).
+       The default is <literal>true</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><literal>remote_analyze</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines wheter an <command>ANALYZE</command> on a foreign
+       table will attempt to <command>ANALYZE</command> the remote table if
+       the first attempt to fetch remote statistics fails, and will then
+       make a second and final attempt to fetch remote statistics. This option
+       is only meaningful if the foreign table has
+       <literal>fetch_stats</literal> enabled at either the server or table
+       level.
+       The default is <literal>false</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
    </variablelist>
 
   </sect3>
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 91bbd0d8c73..6e9a0acedae 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -628,6 +628,7 @@ INSERT INTO loct_empty
   SELECT id, 'AAA' || to_char(id, 'FM000') FROM generate_series(1, 100) id;
 DELETE FROM loct_empty;
 ANALYZE ft_empty;
+INFO:  Found no remote statistics for "ft_empty"
 EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM ft_empty ORDER BY c1;
                                   QUERY PLAN                                   
 -------------------------------------------------------------------------------
@@ -4551,7 +4552,8 @@ REINDEX TABLE reind_fdw_parent; -- ok
 REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -4568,6 +4570,9 @@ CONTEXT:  whole-row reference to foreign table "ftx"
 SELECT sum(c2), array_agg(c8) FROM ft1 GROUP BY c8; -- ERROR
 ERROR:  invalid input syntax for type integer: "foo"
 CONTEXT:  processing expression at position 2 in select list
+ANALYZE ft1; -- WARNING
+WARNING:  invalid input syntax for type integer: "foo"
+ALTER FOREIGN TABLE ft1 OPTIONS ( fetch_stats 'false' );
 ANALYZE ft1; -- ERROR
 ERROR:  invalid input syntax for type integer: "foo"
 CONTEXT:  column "c8" of foreign table "ft1"
@@ -7102,6 +7107,7 @@ INSERT INTO loct2 VALUES (1002, 'bar');
 CREATE FOREIGN TABLE remt2 (c1 int, c2 text) SERVER loopback OPTIONS (table_name 'loct2');
 ANALYZE loct1;
 ANALYZE remt2;
+INFO:  Found no remote statistics for "remt2"
 SET enable_mergejoin TO false;
 SET enable_hashjoin TO false;
 SET enable_material TO false;
@@ -8784,6 +8790,7 @@ alter foreign table foo2 options (use_remote_estimate 'true');
 create index i_loct1_f1 on loct1(f1);
 create index i_foo_f1 on foo(f1);
 analyze foo;
+INFO:  Found no remote statistics for "foo2"
 analyze loct1;
 -- inner join; expressions in the clauses appear in the equivalence class list
 explain (verbose, costs off)
@@ -9013,7 +9020,9 @@ insert into remt1 values (2, 'bar');
 insert into remt2 values (1, 'foo');
 insert into remt2 values (2, 'bar');
 analyze remt1;
+INFO:  Found no remote statistics for "remt1"
 analyze remt2;
+INFO:  Found no remote statistics for "remt2"
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
                                                    QUERY PLAN                                                   
@@ -10313,6 +10322,8 @@ CREATE FOREIGN TABLE ftprt1_p1 PARTITION OF fprt1 FOR VALUES FROM (0) TO (250)
 CREATE FOREIGN TABLE ftprt1_p2 PARTITION OF fprt1 FOR VALUES FROM (250) TO (500)
 	SERVER loopback OPTIONS (TABLE_NAME 'fprt1_p2');
 ANALYZE fprt1;
+INFO:  Found no remote statistics for "ftprt1_p1"
+INFO:  Found no remote statistics for "ftprt1_p2"
 ANALYZE fprt1_p1;
 ANALYZE fprt1_p2;
 CREATE TABLE fprt2 (a int, b int, c varchar) PARTITION BY RANGE(b);
@@ -10328,6 +10339,8 @@ ALTER TABLE fprt2 ATTACH PARTITION ftprt2_p1 FOR VALUES FROM (0) TO (250);
 CREATE FOREIGN TABLE ftprt2_p2 PARTITION OF fprt2 FOR VALUES FROM (250) TO (500)
 	SERVER loopback OPTIONS (table_name 'fprt2_p2', use_remote_estimate 'true');
 ANALYZE fprt2;
+INFO:  Found no remote statistics for "ftprt2_p1"
+INFO:  Found no remote statistics for "ftprt2_p2"
 ANALYZE fprt2_p1;
 ANALYZE fprt2_p2;
 -- inner join three tables
@@ -10515,9 +10528,15 @@ CREATE FOREIGN TABLE fpagg_tab_p1 PARTITION OF pagg_tab FOR VALUES FROM (0) TO (
 CREATE FOREIGN TABLE fpagg_tab_p2 PARTITION OF pagg_tab FOR VALUES FROM (10) TO (20) SERVER loopback OPTIONS (table_name 'pagg_tab_p2');
 CREATE FOREIGN TABLE fpagg_tab_p3 PARTITION OF pagg_tab FOR VALUES FROM (20) TO (30) SERVER loopback OPTIONS (table_name 'pagg_tab_p3');
 ANALYZE pagg_tab;
+INFO:  Found no remote statistics for "fpagg_tab_p1"
+INFO:  Found no remote statistics for "fpagg_tab_p2"
+INFO:  Found no remote statistics for "fpagg_tab_p3"
 ANALYZE fpagg_tab_p1;
+INFO:  Found no remote statistics for "fpagg_tab_p1"
 ANALYZE fpagg_tab_p2;
+INFO:  Found no remote statistics for "fpagg_tab_p2"
 ANALYZE fpagg_tab_p3;
+INFO:  Found no remote statistics for "fpagg_tab_p3"
 -- When GROUP BY clause matches with PARTITION KEY.
 -- Plan with partitionwise aggregates is disabled
 SET enable_partitionwise_aggregate TO false;
@@ -11463,6 +11482,8 @@ CREATE FOREIGN TABLE async_p2 PARTITION OF async_pt FOR VALUES FROM (2000) TO (3
 INSERT INTO async_p1 SELECT 1000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 INSERT INTO async_p2 SELECT 2000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
 -- simple queries
 CREATE TABLE result_tbl (a int, b int, c text);
 EXPLAIN (VERBOSE, COSTS OFF)
@@ -11569,6 +11590,9 @@ CREATE FOREIGN TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4
   SERVER loopback2 OPTIONS (table_name 'base_tbl3');
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
+INFO:  Found no remote statistics for "async_p3"
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
                            QUERY PLAN                           
@@ -11605,6 +11629,8 @@ DROP TABLE base_tbl3;
 CREATE TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4000);
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
                            QUERY PLAN                           
@@ -12660,6 +12686,42 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 -- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table', remote_analyze 'true');
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+ tablename | num_stats 
+-----------+-----------
+(0 rows)
+
+ANALYZE remote_analyze_ftable;
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+       tablename       | num_stats 
+-----------------------+-----------
+ remote_analyze_ftable |         3
+ remote_analyze_table  |         3
+(2 rows)
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+-- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
 -- Disable debug_discard_caches in order to manage remote connections
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 04788b7e8b3..7f069373e82 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -120,6 +120,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 			strcmp(def->defname, "async_capable") == 0 ||
 			strcmp(def->defname, "parallel_commit") == 0 ||
 			strcmp(def->defname, "parallel_abort") == 0 ||
+			strcmp(def->defname, "fetch_stats") == 0 ||
+			strcmp(def->defname, "remote_analyze") == 0 ||
 			strcmp(def->defname, "keep_connections") == 0)
 		{
 			/* these accept only boolean values */
@@ -278,6 +280,14 @@ InitPgFdwOptions(void)
 		{"use_scram_passthrough", ForeignServerRelationId, false},
 		{"use_scram_passthrough", UserMappingRelationId, false},
 
+		/* fetch_size is available on both server and table */
+		{"fetch_stats", ForeignServerRelationId, false},
+		{"fetch_stats", ForeignTableRelationId, false},
+
+		/* remote_analyze is available on both server and table */
+		{"remote_analyze", ForeignServerRelationId, false},
+		{"remote_analyze", ForeignTableRelationId, false},
+
 		/*
 		 * sslcert and sslkey are in fact libpq options, but we repeat them
 		 * here to allow them to appear in both foreign server context (when
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 456b267f70b..096cd4d0fdd 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -22,6 +22,8 @@
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "executor/execAsync.h"
+#include "executor/spi.h"
+#include "fmgr.h"
 #include "foreign/fdwapi.h"
 #include "funcapi.h"
 #include "miscadmin.h"
@@ -402,6 +404,7 @@ static void postgresExecForeignTruncate(List *rels,
 static bool postgresAnalyzeForeignTable(Relation relation,
 										AcquireSampleRowsFunc *func,
 										BlockNumber *totalpages);
+static FdwImportStatsResult postgresImportStatistics(Relation relation);
 static List *postgresImportForeignSchema(ImportForeignSchemaStmt *stmt,
 										 Oid serverOid);
 static void postgresGetForeignJoinPaths(PlannerInfo *root,
@@ -546,6 +549,114 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
 
+/*
+ * Static queries for querying remote statistics.
+ */
+
+
+/* relallfrozen introduced in v18 */
+static const char *relstats_query_18 =
+	"SELECT c.relpages, c.reltuples, c.relallvisible, c.relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* trust reltuples = 0 as of v14 */
+static const char *relstats_query_14 =
+	"SELECT c.relpages, c.reltuples, c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/*
+ * Before v14, a reltuples value of 0 was ambiguous: it could either mean
+ * the relation is empty, or it could mean that it hadn't yet been
+ * vacuumed or analyzed.  (Newer versions use -1 for the latter case.)
+ * This ambiguity allegedly can cause the planner to choose inefficient
+ * plans after restoring to v18 or newer.  To deal with this, let's just
+ * set reltuples to -1 in that case.
+ */
+static const char *relstats_query_default =
+	"SELECT c.relpages, "
+	"CASE c.reltuples WHEN 0 THEN -1 ELSE c.reltuples END AS reltuples, "
+	"c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* All static relstats queries have the same column order */
+enum RelStatsColumns {
+	RELSTATS_RELPAGES = 0,
+	RELSTATS_RELTUPLES,
+	RELSTATS_RELALLVISIBLE,
+	RELSTATS_RELALLFROZEN,
+	RELSTATS_NUM_FIELDS
+};
+
+/* range stats introduced in v17 */
+static const char *attstats_query_17 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"s.range_length_histogram, s.range_empty_frac, s.range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* elements stats introduced in 9.2 */
+static const char *attstats_query_9_2 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* inherited introduced in 9.0 */
+static const char *attstats_query_9_0 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+static const char *attstats_query_default =
+	"SELECT s.attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname";
+
+/* All static attstats queries have the same column order */
+enum AttStatsColumns {
+	ATTSTATS_ATTNAME = 0,
+	ATTSTATS_NULL_FRAC,
+	ATTSTATS_AVG_WIDTH,
+	ATTSTATS_N_DISTINCT,
+	ATTSTATS_MOST_COMMON_VALS,
+	ATTSTATS_MOST_COMMON_FREQS,
+	ATTSTATS_HISTOGRAM_BOUNDS,
+	ATTSTATS_CORRELATION,
+	ATTSTATS_MOST_COMMON_ELEMS,
+	ATTSTATS_MOST_COMMON_ELEM_FREQS,
+	ATTSTATS_ELEM_COUNT_HISTOGRAM,
+	ATTSTATS_RANGE_LENGTH_HISTOGRAM,
+	ATTSTATS_RANGE_EMPTY_FRAC,
+	ATTSTATS_RANGE_BOUNDS_HISTOGRAM,
+	ATTSTATS_NUM_FIELDS
+};
 
 /*
  * Foreign-data wrapper handler function: return a struct with pointers
@@ -595,6 +706,7 @@ postgres_fdw_handler(PG_FUNCTION_ARGS)
 
 	/* Support functions for ANALYZE */
 	routine->AnalyzeForeignTable = postgresAnalyzeForeignTable;
+	routine->ImportStatistics = postgresImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	routine->ImportForeignSchema = postgresImportForeignSchema;
@@ -4935,6 +5047,440 @@ postgresAnalyzeForeignTable(Relation relation,
 	return true;
 }
 
+/*
+ * Process optional argument.
+ *
+ * Cannot be the first argument in the SQL function call.
+ *
+ * It is safe to presume that argname and argtype are quote-safe.
+ *
+ * Argument values can potentially be quite large, so free the quoted string
+ * after use.
+ */
+static void
+append_optional(StringInfo str, PGresult *res, int row, int field,
+				const char *argname, const char *argtype)
+{
+	if (!PQgetisnull(res, row, field))
+	{
+		/* Argument values can be quite large, so free after use */
+		char *argval_l = quote_literal_cstr(PQgetvalue(res, row, field));
+
+		appendStringInfo(str, ",\n\t'%s', %s::%s", argname, argval_l, argtype);
+
+		pfree(argval_l);
+	}
+}
+
+
+/*
+ * Generate a pg_restore_relation_stats command.
+ */
+static char *
+restore_relation_stats_sql(PGresult *res, const char *schemaname_l,
+						   const char *relname_l,
+						   const int server_version_num)
+{
+	StringInfoData	sql;
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_relation_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s",
+					 server_version_num, schemaname_l, relname_l);
+
+	append_optional(&sql, res, 0, RELSTATS_RELPAGES, "relpages", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELTUPLES, "reltuples", "real");
+	append_optional(&sql, res, 0, RELSTATS_RELALLVISIBLE, "relallvisible", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELALLFROZEN, "relallfrozen", "integer");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * Generate a pg_restore_attribute_stats command.
+ */
+static char *
+restore_attribute_stats_sql(PGresult *res, int row, const char *schemaname_l,
+							const char *relname_l, const AttrNumber attnum,
+							const int server_version_num)
+{
+	StringInfoData	sql;
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_attribute_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s,\n"
+					 "\t'attnum', %d::smallint,\n"
+					 "\t'inherited', false::boolean",
+					 server_version_num, schemaname_l, relname_l, attnum);
+
+	append_optional(&sql, res, row, ATTSTATS_NULL_FRAC, "null_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_AVG_WIDTH, "avg_width", "integer");
+	append_optional(&sql, res, row, ATTSTATS_N_DISTINCT, "n_distinct", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_VALS, "most_common_vals", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_FREQS, "most_common_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_HISTOGRAM_BOUNDS, "histogram_bounds", "text");
+	append_optional(&sql, res, row, ATTSTATS_CORRELATION, "correlation", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEMS, "most_common_elems", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEM_FREQS, "most_common_elem_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_ELEM_COUNT_HISTOGRAM, "elem_count_histogram", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_LENGTH_HISTOGRAM, "range_length_histogram", "text");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_EMPTY_FRAC, "range_empty_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_BOUNDS_HISTOGRAM, "range_bounds_histogram", "text");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * postgresImportStatistics
+ * 		Attempt to fetch remote statistics and apply those instead of analyzing.
+ */
+static FdwImportStatsResult
+postgresImportStatistics(Relation relation)
+{
+
+	ForeignTable   *table;
+	ForeignServer  *server;
+	UserMapping	   *user;
+	PGconn		   *conn;
+	ListCell	   *lc;
+	bool			fetch_stats = true;
+	bool			remote_analyze = false;
+	int				server_version_num;
+
+	const char	   *relation_sql;
+	const char	   *attribute_sql;
+	const char	   *schemaname;
+	const char	   *relname;
+	const char	   *remote_schemaname = NULL;
+	const char	   *remote_relname = NULL;
+
+	const char	   *schemaname_l;
+	const char	   *relname_l;
+
+	char   *relimport_sql;
+
+	PGresult   *res;
+	TupleDesc	tupdesc;
+	const char *sql_params[2];
+	int			sql_param_formats[2] = {0, 0};
+
+	table = GetForeignTable(RelationGetRelid(relation));
+	server = GetForeignServer(table->serverid);
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+			fetch_stats = defGetBoolean(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+			fetch_stats = defGetBoolean(def);
+		else if (strcmp(def->defname, "schema_name") == 0)
+			remote_schemaname = defGetString(def);
+		else if (strcmp(def->defname, "table_name") == 0)
+			remote_relname = defGetString(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	if (fetch_stats == false)
+		return FDW_IMPORT_STATS_DISABLED;
+
+	user = GetUserMapping(GetUserId(), table->serverid);
+	conn = GetConnection(user, false, NULL);
+	server_version_num = PQserverVersion(conn);
+
+	schemaname = get_namespace_name(RelationGetNamespace(relation));
+
+	relname = RelationGetRelationName(relation);
+
+	schemaname_l = quote_literal_cstr(schemaname);
+	relname_l = quote_literal_cstr(relname);
+
+	if (remote_schemaname == NULL)
+		remote_schemaname = schemaname;
+	if (remote_relname == NULL)
+		remote_relname = relname;
+	sql_params[0] = remote_schemaname;
+	sql_params[1] = remote_relname;
+
+	if (server_version_num >= 180000)
+		relation_sql = relstats_query_18;
+	else if (server_version_num >= 140000)
+		relation_sql = relstats_query_14;
+	else
+	 	relation_sql = relstats_query_default;
+
+	if (server_version_num >= 170000)
+		attribute_sql = attstats_query_17;
+	else if (server_version_num >= 90200)
+		attribute_sql = attstats_query_9_2;
+	else if (server_version_num >= 90000)
+		attribute_sql = attstats_query_9_0;
+	else
+		attribute_sql = attstats_query_default;
+
+	/*
+	 * pg_restore_attribute_stats
+	 *
+	 * We do this before relation stats because we may retry it if no stats were
+	 * found.
+	 */
+	if (!PQsendQueryParams(conn, attribute_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, attribute_sql);
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	res = pgfdw_get_result(conn);
+	if (PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQnfields(res) != ATTSTATS_NUM_FIELDS)
+	{
+		pgfdw_report(INFO, res, conn, attribute_sql);
+		PQclear(res);
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * If we got a query response of the right shape, but there were no rows,
+	 * then the remote is just missing statistics
+	 */
+	if (PQntuples(res) == 0)
+	{
+		StringInfoData	buf;
+
+		PQclear(res);
+
+		/*
+		 * If remote_analyze is not enabled, any failure to find statistics are
+		 * considered temporary. This is not an error, but we should fall back
+		 * to regular local analyzis if enabled.
+		 */
+		if (!remote_analyze)
+		{
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_NOTFOUND;
+		}
+
+		/*
+		 * Analyze the remote table and try again. If it's still empty, then that's
+		 * an error indicating that we probably shouldn't do remote analysis going
+		 * forward.
+		 */
+		initStringInfo(&buf);
+
+		appendStringInfo(&buf, "ANALYZE %s.%s",
+						 quote_identifier(remote_schemaname),
+						 quote_identifier(remote_relname));
+
+		res = pgfdw_exec_query(conn, buf.data, NULL);
+
+		if (res == NULL || PQresultStatus(res) != PGRES_COMMAND_OK)
+		{
+			pgfdw_report(NOTICE, res, conn, buf.data);
+			pfree(buf.data);
+			PQclear(res);
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_FAILED;
+		}
+
+		PQclear(res);
+		pfree(buf.data);
+
+		/* retry attribute stats query */
+		if (!PQsendQueryParams(conn, attribute_sql, 2, NULL, sql_params, NULL,
+							sql_param_formats, 0))
+		{
+			pgfdw_report(INFO, NULL, conn, attribute_sql);
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_FAILED;
+		}
+		res = pgfdw_get_result(conn);
+
+		/* getting nothing on the second try is a failure */
+		if (PQresultStatus(res) != PGRES_TUPLES_OK
+			|| PQntuples(res) == 0
+			|| PQnfields(res) != ATTSTATS_NUM_FIELDS)
+		{
+			pgfdw_report(INFO, res, conn, attribute_sql);
+			PQclear(res);
+			ReleaseConnection(conn);
+			return FDW_IMPORT_STATS_FAILED;
+		}
+	}
+
+	SPI_connect();
+
+	/*
+	 * Walk all local table attributes looking for name matches in the result
+	 * set and perform a pg_restore_attribute_stats() on each match.
+	 *
+	 * XXX: the result set is sorted by attname, so perhaps we could do a binary
+	 * search of the result set. Alternately we could collect the local attributes
+	 * in a list and sort that by remote name, which would allow us to iterate via
+	 * a merge.
+	 *
+	 * XXX: what should be done if match_found = false?
+	 */
+	tupdesc = RelationGetDescr(relation);
+	for (int i = 0; i < tupdesc->natts; i++)
+	{
+		char	   *remote_colname;
+		List	   *fc_options;
+		ListCell   *fc_lc;
+		AttrNumber	attnum;
+		bool		match_found = false;
+
+		/* Ignore dropped columns. */
+		if (TupleDescAttr(tupdesc, i)->attisdropped)
+			continue;
+
+		/* Ignore generated columns, but maybe this should fail the import? */
+		if (TupleDescAttr(tupdesc, i)->attgenerated)
+			continue;
+
+		attnum = TupleDescAttr(tupdesc, i)->attnum;
+
+		/* default remote_colname is attname */
+		remote_colname = NameStr(TupleDescAttr(tupdesc, i)->attname);
+		fc_options = GetForeignColumnOptions(RelationGetRelid(relation), i + 1);
+
+		foreach(fc_lc, fc_options)
+		{
+			DefElem    *def = (DefElem *) lfirst(fc_lc);
+
+			if (strcmp(def->defname, "column_name") == 0)
+			{
+				remote_colname = defGetString(def);
+				break;
+			}
+		}
+
+		for (int j = 0; j < PQntuples(res); j++)
+		{
+			char   *attimport_sql;
+
+			if (PQgetisnull(res, j, ATTSTATS_ATTNAME))
+				continue;
+
+			if (strcmp(PQgetvalue(res, j, ATTSTATS_ATTNAME), remote_colname) != 0)
+				continue;
+
+			match_found = true;
+			attimport_sql = restore_attribute_stats_sql(res, j, schemaname_l, relname_l, attnum, server_version_num);
+
+			if (SPI_execute(attimport_sql, false, 1) != SPI_OK_SELECT)
+			{
+				/*
+				 * It takes a lot to make a restore command fail outright, so any actual
+				 * failure is a sign that the statistics are seriously malformed, and
+				 * we should give up on importing stats for this table.
+				 */
+				ereport(INFO,
+						(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+							errmsg("Attribute statistics import failed %s", attimport_sql)));
+				SPI_finish();
+				ReleaseConnection(conn);
+				pfree(attimport_sql);
+				return FDW_IMPORT_STATS_FAILED;
+			}
+
+			pfree(attimport_sql);
+		}
+
+		/* TODO: should this be an error? What action could we take to remediate? */
+		if (!match_found)
+			ereport(INFO,
+					(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+						errmsg("Attribute statistics found for %s.%s but no columns matched",
+							   quote_identifier(schemaname),
+							   quote_identifier(relname))));
+	}
+	PQclear(res);
+
+	/*
+	 * pg_restore_relation_stats
+	 */
+	if (!PQsendQueryParams(conn, relation_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, relation_sql);
+		SPI_finish();
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	res = pgfdw_get_result(conn);
+	if (PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQntuples(res) != 1
+		|| PQnfields(res) != RELSTATS_NUM_FIELDS)
+	{
+		/* unable to get relation stats, fall back on table sampling */
+		pgfdw_report(INFO, res, conn, attribute_sql);
+		PQclear(res);
+		ReleaseConnection(conn);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	relimport_sql = restore_relation_stats_sql(res, schemaname_l, relname_l, server_version_num);
+
+	if (SPI_execute(relimport_sql, false, 0) != SPI_OK_SELECT)
+	{
+		/*
+		 * It takes a lot to make a restore command fail outright, so any actual
+		 * failure is a sign that the statistics are seriously malformed, and
+		 * we should give up on importing stats for this table.
+		 */
+		ereport(INFO,
+				(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+					errmsg("Relation statistics import failed %s", relimport_sql)));
+		SPI_finish();
+		ReleaseConnection(conn);
+		pfree(relimport_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	pfree(relimport_sql);
+
+	SPI_finish();
+
+	return FDW_IMPORT_STATS_OK;
+}
+
+
 /*
  * postgresGetAnalyzeInfoForForeignTable
  *		Count tuples in foreign table (just get pg_class.reltuples).
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 3b7da128519..cddc57bacc9 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -1278,7 +1278,8 @@ REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -1287,6 +1288,8 @@ SELECT ftx.x1, ft2.c2, ftx.x8 FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8), ft2
 SELECT ftx.x1, ft2.c2, ftx FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8), ft2
   WHERE ftx.x1 = ft2.c1 AND ftx.x1 = 1; -- ERROR
 SELECT sum(c2), array_agg(c8) FROM ft1 GROUP BY c8; -- ERROR
+ANALYZE ft1; -- WARNING
+ALTER FOREIGN TABLE ft1 OPTIONS ( fetch_stats 'false' );
 ANALYZE ft1; -- ERROR
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE user_enum;
 
@@ -4376,6 +4379,38 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 
+-- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table', remote_analyze 'true');
+
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+ANALYZE remote_analyze_ftable;
+
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================

base-commit: 615ff828e1cb8eaa2a987d4390c5c9970fc1a3e6
-- 
2.51.0

#6Michael Paquier
michael@paquier.xyz
In reply to: Corey Huinker (#5)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Sat, Oct 18, 2025 at 08:32:24PM -0400, Corey Huinker wrote:

Rebased again.

Hearing an opinion from Fujita-san would be interesting here, so I am
adding him in CC. I have been looking a little bit at this patch.

+ ImportStatistics_function ImportStatistics;

All FDW callbacks are documented in fdwhandler.sgml. This new one is
not.

I am a bit uncomfortable regarding the design you are using here,
where the ImportStatistics callback, if defined, takes priority over
the existing AnalyzeForeignTable, especially regarding the fact that
both callbacks attempt to retrieve the same data, except that the
existing callback has a different idea of the timing to use to
retrieve reltuples and relpages. The original callback
AnalyzeForeignTable retrieves immediately the total number of pages
via SQL, to feed ANALYZE. reltuples is then fed to ANALYZE from a
callback function that that's defined in AnalyzeForeignTable().

My point is: rather than trying to implement a second solution with a
new callback, shouldn't we try to rework the existing callback so as
it would fit better with what you are trying to do here: feed data
that ANALYZE would then be in charge of inserting? Relying on
pg_restore_relation_stats() for the job feels incorrect to me knowing
that ANALYZE is the code path in charge of updating the stats of a
relation. The new callback is a shortcut that bypasses what ANALYZE
does, so the problem, at least it seems to me, is that we want to
retrieve all the data in a single step, like your new callback, not in
two steps, something that only the existing callback allows. Hence,
wouldn't it be more natural to retrieve the total number of pages and
reltuples from one callback, meaning that for local relations we
should delay RelationGetNumberOfBlocks() inside the existing
"acquire_sample_rows" callback (renaming it would make sense)? This
requires some redesign of AnalyzeForeignTable and the internals of
analyze.c, but it would let FDW extensions know about the potential
efficiency gain.

There is also a performance concern to be made with the patch, but as
it's an ANALYZE path that may be acceptable: if we fail the first
callback, then we may call the second callback.

Fujita-san, what do you think?
--
Michael

#7Michael Paquier
michael@paquier.xyz
In reply to: Michael Paquier (#6)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Mon, Oct 20, 2025 at 03:45:14PM +0900, Michael Paquier wrote:

On Sat, Oct 18, 2025 at 08:32:24PM -0400, Corey Huinker wrote:

Rebased again.

Hearing an opinion from Fujita-san would be interesting here, so I am
adding him in CC. I have been looking a little bit at this patch.

By the way, as far as I can see this patch is still in the past commit
fest:
https://commitfest.postgresql.org/patch/5959/

You may want to move it if you are planning to continue working on
that.
--
Michael

#8Corey Huinker
corey.huinker@gmail.com
In reply to: Michael Paquier (#6)
Re: Import Statistics in postgres_fdw before resorting to sampling.

I am a bit uncomfortable regarding the design you are using here,
where the ImportStatistics callback, if defined, takes priority over
the existing AnalyzeForeignTable, especially regarding the fact that
both callbacks attempt to retrieve the same data, except that the
existing callback has a different idea of the timing to use to
retrieve reltuples and relpages. The original callback
AnalyzeForeignTable retrieves immediately the total number of pages
via SQL, to feed ANALYZE. reltuples is then fed to ANALYZE from a
callback function that that's defined in AnalyzeForeignTable().

They don't try to retrieve the same data. AnalyzeForeignTable tries to
retrieve a table sample which it feeds into the normal ANALYZE process. If
that sample is going to be any good, it has to be a lot of rows, that
that's a lot of network traffic.

ImportStatistics just grabs the results that ANALYZE computed for the
remote table, using a far better sample than we'd want to pull across the
wire.

My point is: rather than trying to implement a second solution with a
new callback, shouldn't we try to rework the existing callback so as
it would fit better with what you are trying to do here: feed data
that ANALYZE would then be in charge of inserting?

To do that, we would have to somehow generate fake data locally from the
pg_stats data that we did pull over the wire, just to have ANALYZE compute
it back down to the data we already had.

Relying on
pg_restore_relation_stats() for the job feels incorrect to me knowing
that ANALYZE is the code path in charge of updating the stats of a
relation.

That sounds like an argument for expanding ANALYZE to have syntax that will
digest pre-computed rows, essentially taking over what
pg_restore_relation_stats and pg_restore_attribute_stats already do, and
that idea was dismissed fairly early on in development, though I wasn't
against it at the time.

As it is, those two functions were developed to match what ANALYZE does.
pg_restore_relation_stats even briefly had inplace updates because that's
what ANALYZE did.

The new callback is a shortcut that bypasses what ANALYZE
does, so the problem, at least it seems to me, is that we want to
retrieve all the data in a single step, like your new callback, not in
two steps, something that only the existing callback allows. Hence,
wouldn't it be more natural to retrieve the total number of pages and
reltuples from one callback, meaning that for local relations we
should delay RelationGetNumberOfBlocks() inside the existing
"acquire_sample_rows" callback (renaming it would make sense)? This
requires some redesign of AnalyzeForeignTable and the internals of
analyze.c, but it would let FDW extensions know about the potential
efficiency gain.

I wanted to make minimal disruption to the existing callbacks, but that may
have been misguided.

One problem I do see, however, is that the queries for fetching relation
(pg_class) stats should never fail and always return one row, even if the
values returned are meaningless. It's the query against pg_stats that lets
us know if 1) the database on the other side is a real-enough postgres and
2) the remote table is itself analyzed. Only once we're happy that we have
good attribute stats should we bother with the relation stats. All of this
logic is specific to postgres, so it was confined to postgres_fdw. I don't
know that other FDWs would be that much different, but minimizing the
generic impact was a goal.

I'll look at this again, but I'm not sure I'm going to come up with much
different.

There is also a performance concern to be made with the patch, but as
it's an ANALYZE path that may be acceptable: if we fail the first
callback, then we may call the second callback.

I think the big win is the network traffic savings.

Fujita-san, what do you think?

Very interested to know as well.

#9Etsuro Fujita
etsuro.fujita@gmail.com
In reply to: Corey Huinker (#8)
Re: Import Statistics in postgres_fdw before resorting to sampling.

Hi Corey,

This is an important feature. Thanks for working on it.

On Mon, Nov 3, 2025 at 2:26 PM Corey Huinker <corey.huinker@gmail.com> wrote:

My point is: rather than trying to implement a second solution with a
new callback, shouldn't we try to rework the existing callback so as
it would fit better with what you are trying to do here: feed data
that ANALYZE would then be in charge of inserting?

To do that, we would have to somehow generate fake data locally from the pg_stats data that we did pull over the wire, just to have ANALYZE compute it back down to the data we already had.

Relying on
pg_restore_relation_stats() for the job feels incorrect to me knowing
that ANALYZE is the code path in charge of updating the stats of a
relation.

That sounds like an argument for expanding ANALYZE to have syntax that will digest pre-computed rows, essentially taking over what pg_restore_relation_stats and pg_restore_attribute_stats already do, and that idea was dismissed fairly early on in development, though I wasn't against it at the time.

As it is, those two functions were developed to match what ANALYZE does. pg_restore_relation_stats even briefly had inplace updates because that's what ANALYZE did.

To me it seems like a good idea to rely on pg_restore_relation_stats
and pg_restore_attribute_stats, rather than doing some rework on
analyze.c; IMO I don't think it's a good idea to do such a thing for
something rather special like this.

The new callback is a shortcut that bypasses what ANALYZE
does, so the problem, at least it seems to me, is that we want to
retrieve all the data in a single step, like your new callback, not in
two steps, something that only the existing callback allows. Hence,
wouldn't it be more natural to retrieve the total number of pages and
reltuples from one callback, meaning that for local relations we
should delay RelationGetNumberOfBlocks() inside the existing
"acquire_sample_rows" callback (renaming it would make sense)? This
requires some redesign of AnalyzeForeignTable and the internals of
analyze.c, but it would let FDW extensions know about the potential
efficiency gain.

I wanted to make minimal disruption to the existing callbacks, but that may have been misguided.

+1 for the minimal disruption.

Other initial comments:

The commit message says:

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will first attempt to fetch statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If no statistics are found, then ANALYZE will fall back to the normal
behavior of sampling and local analysis.

I think the first step assumes that the remote stats are up-to-date;
if they aren't, it would cause a regression. (If the remote relation
is a plain table, they are likely to be up-to-date, but for example,
if it is a foreign table, it's possible that they are stale.) So how
about making it the user's responsibility to make them up-to-date? If
doing so, we wouldn't need to do the second and third steps anymore,
making the patch simple.

On the other hand:

This operation will only work on remote relations that can have stored
statistics: tables, partitioned tables, and materialized views. If the
remote relation is a view then remote fetching/analyzing is just wasted
effort and the user is better of setting fetch_stats to false for that
table.

I'm not sure the waste effort is acceptable; IMO, if the remote table
is a view, I think that the system should detect that in some way, and
then just do the normal ANALYZE processing.

That's it for now.

My apologies for the delayed response.

Best regards,
Etsuro Fujita

#10Corey Huinker
corey.huinker@gmail.com
In reply to: Etsuro Fujita (#9)
Re: Import Statistics in postgres_fdw before resorting to sampling.

Other initial comments:

The commit message says:

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will first attempt to fetch
statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If no statistics are found, then ANALYZE will fall back to the normal
behavior of sampling and local analysis.

I think the first step assumes that the remote stats are up-to-date;
if they aren't, it would cause a regression. (If the remote relation
is a plain table, they are likely to be up-to-date, but for example,
if it is a foreign table, it's possible that they are stale.) So how
about making it the user's responsibility to make them up-to-date? If
doing so, we wouldn't need to do the second and third steps anymore,
making the patch simple.

Obviously there is no way to know the quality/freshness of remote stats if
they are found.

The analyze option was borne of feedback from other postgres hackers while
brainstorming on what this option might look like. I don't think we *need*
this extra option for the feature to be a success, but it's relative
simplicity did make me want to put it out there to see who else liked it.

On the other hand:

This operation will only work on remote relations that can have stored
statistics: tables, partitioned tables, and materialized views. If the
remote relation is a view then remote fetching/analyzing is just wasted
effort and the user is better of setting fetch_stats to false for that
table.

I'm not sure the waste effort is acceptable; IMO, if the remote table
is a view, I think that the system should detect that in some way, and
then just do the normal ANALYZE processing.

The stats fetch query is pretty light, but I can see fetching the relkind
along with the relstats, and making decisions on whether to continue from
there, only applying the relstats after attrstats have been successfully
applied.

That's it for now.

I'll see what I can do to make that work.

My apologies for the delayed response.

Valuable responses are worth waiting for.

#11Corey Huinker
corey.huinker@gmail.com
In reply to: Corey Huinker (#10)
1 attachment(s)
Re: Import Statistics in postgres_fdw before resorting to sampling.

My apologies for the delayed response.

Valuable responses are worth waiting for.

I've reorganized some things a bit, mostly to make resource cleanup
simpler.

Attachments:

v4-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchtext/x-patch; charset=US-ASCII; name=v4-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchDownload
From c387c379f4a8ba83073fd3e8d697368fe3250aa4 Mon Sep 17 00:00:00 2001
From: Corey Huinker <corey.huinker@gmail.com>
Date: Thu, 7 Aug 2025 23:58:38 -0400
Subject: [PATCH v4] Add remote statistics fetching to postgres_fdw.

This adds the ability to fetch and import statistics from a remote
server table table rather than fetching the data or data sample from
that table.

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will first attempt to fetch statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If no statistics are found, then ANALYZE will fall back to the normal
behavior of sampling and local analysis.

This operation will only work on remote relations that can have stored
statistics: tables, partitioned tables, and materialized views. If the
remote relation is a view then remote fetching/analyzing is just wasted
effort and the user is better of setting fetch_stats to false for that
table.

The default for fetch_stats is true at both server and table level. The
default for remote_analyze is false at both the server and table level.
In both cases, setting a value at the table level will override the
corresponding server-level setting.
---
 src/include/foreign/fdwapi.h                  |  12 +
 src/backend/commands/analyze.c                |  45 +-
 doc/src/sgml/postgres-fdw.sgml                |  35 +-
 .../postgres_fdw/expected/postgres_fdw.out    |  64 +-
 contrib/postgres_fdw/option.c                 |  10 +
 contrib/postgres_fdw/postgres_fdw.c           | 665 ++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  37 +-
 7 files changed, 864 insertions(+), 4 deletions(-)

diff --git a/src/include/foreign/fdwapi.h b/src/include/foreign/fdwapi.h
index fcd7e7027f3..eb6284979fb 100644
--- a/src/include/foreign/fdwapi.h
+++ b/src/include/foreign/fdwapi.h
@@ -19,6 +19,15 @@
 /* avoid including explain_state.h here */
 typedef struct ExplainState ExplainState;
 
+/* result of ImportStatistics */
+typedef enum
+{
+	FDW_IMPORT_STATS_OK = 0,		/* was able to import statistics */
+	FDW_IMPORT_STATS_DISABLED,		/* import disabled for this table */
+	FDW_IMPORT_STATS_NOTFOUND,		/* no remote attribute stats found */
+	FDW_IMPORT_STATS_FAILED			/* remote query failure of some kind */
+} FdwImportStatsResult;
+
 
 /*
  * Callback function signatures --- see fdwhandler.sgml for more info.
@@ -157,6 +166,8 @@ typedef bool (*AnalyzeForeignTable_function) (Relation relation,
 											  AcquireSampleRowsFunc *func,
 											  BlockNumber *totalpages);
 
+typedef FdwImportStatsResult (*ImportStatistics_function) (Relation relation);
+
 typedef List *(*ImportForeignSchema_function) (ImportForeignSchemaStmt *stmt,
 											   Oid serverOid);
 
@@ -255,6 +266,7 @@ typedef struct FdwRoutine
 
 	/* Support functions for ANALYZE */
 	AnalyzeForeignTable_function AnalyzeForeignTable;
+	ImportStatistics_function ImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	ImportForeignSchema_function ImportForeignSchema;
diff --git a/src/backend/commands/analyze.c b/src/backend/commands/analyze.c
index 25089fae3e0..464a3e4b3b0 100644
--- a/src/backend/commands/analyze.c
+++ b/src/backend/commands/analyze.c
@@ -196,13 +196,56 @@ analyze_rel(Oid relid, RangeVar *relation,
 	{
 		/*
 		 * For a foreign table, call the FDW's hook function to see whether it
-		 * supports analysis.
+		 * supports statistics import and/or analysis.
 		 */
 		FdwRoutine *fdwroutine;
 		bool		ok = false;
 
 		fdwroutine = GetFdwRoutineForRelation(onerel, false);
 
+		if (fdwroutine->ImportStatistics != NULL)
+		{
+			FdwImportStatsResult	res;
+
+			/*
+			 * Fetching pre-existing remote stats is not guaranteed to be a quick
+			 * operation.
+			 *
+			 * XXX: Should this be it's own fetch type? If not, then there might be
+			 * confusion when a long stats-fetch fails, followed by a regular analyze,
+			 * which would make it look like the table was analyzed twice.
+			 */
+			pgstat_progress_start_command(PROGRESS_COMMAND_ANALYZE,
+										  RelationGetRelid(onerel));
+
+			res = fdwroutine->ImportStatistics(onerel);
+
+			pgstat_progress_end_command();
+
+			/*
+			 * If we were able to import statistics, then there is no need to collect
+			 * samples for local analysis.
+			 */
+			switch(res)
+			{
+				case FDW_IMPORT_STATS_OK:
+					relation_close(onerel, ShareUpdateExclusiveLock);
+					return;
+				case FDW_IMPORT_STATS_DISABLED:
+					break;
+				case FDW_IMPORT_STATS_NOTFOUND:
+					ereport(INFO,
+							errmsg("Found no remote statistics for \"%s\"",
+								   RelationGetRelationName(onerel)));
+					break;
+				case FDW_IMPORT_STATS_FAILED:
+				default:
+					ereport(INFO,
+							errmsg("Fetching remote statistics from \"%s\" failed",
+								   RelationGetRelationName(onerel)));
+			}
+		}
+
 		if (fdwroutine->AnalyzeForeignTable != NULL)
 			ok = fdwroutine->AnalyzeForeignTable(onerel,
 												 &acquirefunc,
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index 9b032fbf675..656897d89e0 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -332,7 +332,7 @@ OPTIONS (ADD password_required 'false');
    </para>
 
    <para>
-    The following option controls how such an <command>ANALYZE</command>
+    The following options control how such an <command>ANALYZE</command>
     operation behaves:
    </para>
 
@@ -364,6 +364,39 @@ OPTIONS (ADD password_required 'false');
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>fetch_stats</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines if <command>ANALYZE</command> on a foreign table
+       will first attempt to fetch and import the existing relation and
+       attribute statistics from the remote table, and only attempt regular
+       data sampling if no statistics were availble. This option is only
+       useful if the remote relation is one that can have regular statistics
+       (tables and materialized views).
+       The default is <literal>true</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><literal>remote_analyze</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines wheter an <command>ANALYZE</command> on a foreign
+       table will attempt to <command>ANALYZE</command> the remote table if
+       the first attempt to fetch remote statistics fails, and will then
+       make a second and final attempt to fetch remote statistics. This option
+       is only meaningful if the foreign table has
+       <literal>fetch_stats</literal> enabled at either the server or table
+       level.
+       The default is <literal>false</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
    </variablelist>
 
   </sect3>
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index cd28126049d..825d4b89590 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -628,6 +628,7 @@ INSERT INTO loct_empty
   SELECT id, 'AAA' || to_char(id, 'FM000') FROM generate_series(1, 100) id;
 DELETE FROM loct_empty;
 ANALYZE ft_empty;
+INFO:  Found no remote statistics for "ft_empty"
 EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM ft_empty ORDER BY c1;
                                   QUERY PLAN                                   
 -------------------------------------------------------------------------------
@@ -4551,7 +4552,8 @@ REINDEX TABLE reind_fdw_parent; -- ok
 REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -4568,6 +4570,9 @@ CONTEXT:  whole-row reference to foreign table "ftx"
 SELECT sum(c2), array_agg(c8) FROM ft1 GROUP BY c8; -- ERROR
 ERROR:  invalid input syntax for type integer: "foo"
 CONTEXT:  processing expression at position 2 in select list
+ANALYZE ft1; -- WARNING
+WARNING:  invalid input syntax for type integer: "foo"
+ALTER FOREIGN TABLE ft1 OPTIONS ( fetch_stats 'false' );
 ANALYZE ft1; -- ERROR
 ERROR:  invalid input syntax for type integer: "foo"
 CONTEXT:  column "c8" of foreign table "ft1"
@@ -7102,6 +7107,7 @@ INSERT INTO loct2 VALUES (1002, 'bar');
 CREATE FOREIGN TABLE remt2 (c1 int, c2 text) SERVER loopback OPTIONS (table_name 'loct2');
 ANALYZE loct1;
 ANALYZE remt2;
+INFO:  Found no remote statistics for "remt2"
 SET enable_mergejoin TO false;
 SET enable_hashjoin TO false;
 SET enable_material TO false;
@@ -8784,6 +8790,7 @@ alter foreign table foo2 options (use_remote_estimate 'true');
 create index i_loct1_f1 on loct1(f1);
 create index i_foo_f1 on foo(f1);
 analyze foo;
+INFO:  Found no remote statistics for "foo2"
 analyze loct1;
 -- inner join; expressions in the clauses appear in the equivalence class list
 explain (verbose, costs off)
@@ -9013,7 +9020,9 @@ insert into remt1 values (2, 'bar');
 insert into remt2 values (1, 'foo');
 insert into remt2 values (2, 'bar');
 analyze remt1;
+INFO:  Found no remote statistics for "remt1"
 analyze remt2;
+INFO:  Found no remote statistics for "remt2"
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
                                                    QUERY PLAN                                                   
@@ -10313,6 +10322,8 @@ CREATE FOREIGN TABLE ftprt1_p1 PARTITION OF fprt1 FOR VALUES FROM (0) TO (250)
 CREATE FOREIGN TABLE ftprt1_p2 PARTITION OF fprt1 FOR VALUES FROM (250) TO (500)
 	SERVER loopback OPTIONS (TABLE_NAME 'fprt1_p2');
 ANALYZE fprt1;
+INFO:  Found no remote statistics for "ftprt1_p1"
+INFO:  Found no remote statistics for "ftprt1_p2"
 ANALYZE fprt1_p1;
 ANALYZE fprt1_p2;
 CREATE TABLE fprt2 (a int, b int, c varchar) PARTITION BY RANGE(b);
@@ -10328,6 +10339,8 @@ ALTER TABLE fprt2 ATTACH PARTITION ftprt2_p1 FOR VALUES FROM (0) TO (250);
 CREATE FOREIGN TABLE ftprt2_p2 PARTITION OF fprt2 FOR VALUES FROM (250) TO (500)
 	SERVER loopback OPTIONS (table_name 'fprt2_p2', use_remote_estimate 'true');
 ANALYZE fprt2;
+INFO:  Found no remote statistics for "ftprt2_p1"
+INFO:  Found no remote statistics for "ftprt2_p2"
 ANALYZE fprt2_p1;
 ANALYZE fprt2_p2;
 -- inner join three tables
@@ -10515,9 +10528,15 @@ CREATE FOREIGN TABLE fpagg_tab_p1 PARTITION OF pagg_tab FOR VALUES FROM (0) TO (
 CREATE FOREIGN TABLE fpagg_tab_p2 PARTITION OF pagg_tab FOR VALUES FROM (10) TO (20) SERVER loopback OPTIONS (table_name 'pagg_tab_p2');
 CREATE FOREIGN TABLE fpagg_tab_p3 PARTITION OF pagg_tab FOR VALUES FROM (20) TO (30) SERVER loopback OPTIONS (table_name 'pagg_tab_p3');
 ANALYZE pagg_tab;
+INFO:  Found no remote statistics for "fpagg_tab_p1"
+INFO:  Found no remote statistics for "fpagg_tab_p2"
+INFO:  Found no remote statistics for "fpagg_tab_p3"
 ANALYZE fpagg_tab_p1;
+INFO:  Found no remote statistics for "fpagg_tab_p1"
 ANALYZE fpagg_tab_p2;
+INFO:  Found no remote statistics for "fpagg_tab_p2"
 ANALYZE fpagg_tab_p3;
+INFO:  Found no remote statistics for "fpagg_tab_p3"
 -- When GROUP BY clause matches with PARTITION KEY.
 -- Plan with partitionwise aggregates is disabled
 SET enable_partitionwise_aggregate TO false;
@@ -11463,6 +11482,8 @@ CREATE FOREIGN TABLE async_p2 PARTITION OF async_pt FOR VALUES FROM (2000) TO (3
 INSERT INTO async_p1 SELECT 1000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 INSERT INTO async_p2 SELECT 2000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
 -- simple queries
 CREATE TABLE result_tbl (a int, b int, c text);
 EXPLAIN (VERBOSE, COSTS OFF)
@@ -11569,6 +11590,9 @@ CREATE FOREIGN TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4
   SERVER loopback2 OPTIONS (table_name 'base_tbl3');
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
+INFO:  Found no remote statistics for "async_p3"
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
                            QUERY PLAN                           
@@ -11610,6 +11634,8 @@ DROP TABLE base_tbl3;
 CREATE TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4000);
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
                            QUERY PLAN                           
@@ -12665,6 +12691,42 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 -- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table', remote_analyze 'true');
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+ tablename | num_stats 
+-----------+-----------
+(0 rows)
+
+ANALYZE remote_analyze_ftable;
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+       tablename       | num_stats 
+-----------------------+-----------
+ remote_analyze_ftable |         3
+ remote_analyze_table  |         3
+(2 rows)
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+-- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
 -- Disable debug_discard_caches in order to manage remote connections
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 04788b7e8b3..7f069373e82 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -120,6 +120,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 			strcmp(def->defname, "async_capable") == 0 ||
 			strcmp(def->defname, "parallel_commit") == 0 ||
 			strcmp(def->defname, "parallel_abort") == 0 ||
+			strcmp(def->defname, "fetch_stats") == 0 ||
+			strcmp(def->defname, "remote_analyze") == 0 ||
 			strcmp(def->defname, "keep_connections") == 0)
 		{
 			/* these accept only boolean values */
@@ -278,6 +280,14 @@ InitPgFdwOptions(void)
 		{"use_scram_passthrough", ForeignServerRelationId, false},
 		{"use_scram_passthrough", UserMappingRelationId, false},
 
+		/* fetch_size is available on both server and table */
+		{"fetch_stats", ForeignServerRelationId, false},
+		{"fetch_stats", ForeignTableRelationId, false},
+
+		/* remote_analyze is available on both server and table */
+		{"remote_analyze", ForeignServerRelationId, false},
+		{"remote_analyze", ForeignTableRelationId, false},
+
 		/*
 		 * sslcert and sslkey are in fact libpq options, but we repeat them
 		 * here to allow them to appear in both foreign server context (when
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 06b52c65300..6f732d1e588 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -22,6 +22,8 @@
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "executor/execAsync.h"
+#include "executor/spi.h"
+#include "fmgr.h"
 #include "foreign/fdwapi.h"
 #include "funcapi.h"
 #include "miscadmin.h"
@@ -317,6 +319,13 @@ typedef struct
 	List	   *already_used;	/* expressions already dealt with */
 } ec_member_foreign_arg;
 
+/* Result sets that are returned from a foreign statistics scan */
+typedef struct
+{
+	PGresult	*rel;
+	PGresult	*att;
+} RemoteStatsResults;
+
 /*
  * SQL functions
  */
@@ -402,6 +411,7 @@ static void postgresExecForeignTruncate(List *rels,
 static bool postgresAnalyzeForeignTable(Relation relation,
 										AcquireSampleRowsFunc *func,
 										BlockNumber *totalpages);
+static FdwImportStatsResult postgresImportStatistics(Relation relation);
 static List *postgresImportForeignSchema(ImportForeignSchemaStmt *stmt,
 										 Oid serverOid);
 static void postgresGetForeignJoinPaths(PlannerInfo *root,
@@ -546,6 +556,115 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
 
+/*
+ * Static queries for querying remote statistics.
+ */
+
+
+/* relallfrozen introduced in v18 */
+static const char *relstats_query_18 =
+	"SELECT c.relkind, c.relpages, c.reltuples, c.relallvisible, c.relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* trust reltuples = 0 as of v14 */
+static const char *relstats_query_14 =
+	"SELECT c.relkind, c.relpages, c.reltuples, c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/*
+ * Before v14, a reltuples value of 0 was ambiguous: it could either mean
+ * the relation is empty, or it could mean that it hadn't yet been
+ * vacuumed or analyzed.  (Newer versions use -1 for the latter case.)
+ * This ambiguity allegedly can cause the planner to choose inefficient
+ * plans after restoring to v18 or newer.  To deal with this, let's just
+ * set reltuples to -1 in that case.
+ */
+static const char *relstats_query_default =
+	"SELECT c.relkind, c.relpages, "
+	"CASE c.reltuples WHEN 0 THEN -1 ELSE c.reltuples END AS reltuples, "
+	"c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* All static relstats queries have the same column order */
+enum RelStatsColumns {
+	RELSTATS_RELKIND = 0,
+	RELSTATS_RELPAGES,
+	RELSTATS_RELTUPLES,
+	RELSTATS_RELALLVISIBLE,
+	RELSTATS_RELALLFROZEN,
+	RELSTATS_NUM_FIELDS
+};
+
+/* range stats introduced in v17 */
+static const char *attstats_query_17 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"s.range_length_histogram, s.range_empty_frac, s.range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* elements stats introduced in 9.2 */
+static const char *attstats_query_9_2 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* inherited introduced in 9.0 */
+static const char *attstats_query_9_0 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+static const char *attstats_query_default =
+	"SELECT s.attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname";
+
+/* All static attstats queries have the same column order */
+enum AttStatsColumns {
+	ATTSTATS_ATTNAME = 0,
+	ATTSTATS_NULL_FRAC,
+	ATTSTATS_AVG_WIDTH,
+	ATTSTATS_N_DISTINCT,
+	ATTSTATS_MOST_COMMON_VALS,
+	ATTSTATS_MOST_COMMON_FREQS,
+	ATTSTATS_HISTOGRAM_BOUNDS,
+	ATTSTATS_CORRELATION,
+	ATTSTATS_MOST_COMMON_ELEMS,
+	ATTSTATS_MOST_COMMON_ELEM_FREQS,
+	ATTSTATS_ELEM_COUNT_HISTOGRAM,
+	ATTSTATS_RANGE_LENGTH_HISTOGRAM,
+	ATTSTATS_RANGE_EMPTY_FRAC,
+	ATTSTATS_RANGE_BOUNDS_HISTOGRAM,
+	ATTSTATS_NUM_FIELDS
+};
 
 /*
  * Foreign-data wrapper handler function: return a struct with pointers
@@ -595,6 +714,7 @@ postgres_fdw_handler(PG_FUNCTION_ARGS)
 
 	/* Support functions for ANALYZE */
 	routine->AnalyzeForeignTable = postgresAnalyzeForeignTable;
+	routine->ImportStatistics = postgresImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	routine->ImportForeignSchema = postgresImportForeignSchema;
@@ -4935,6 +5055,551 @@ postgresAnalyzeForeignTable(Relation relation,
 	return true;
 }
 
+/*
+ * Process optional argument.
+ *
+ * Cannot be the first argument in the SQL function call.
+ *
+ * It is safe to presume that argname and argtype are quote-safe.
+ *
+ * Argument values can potentially be quite large, so free the quoted string
+ * after use.
+ */
+static void
+append_optional(StringInfo str, PGresult *res, int row, int field,
+				const char *argname, const char *argtype)
+{
+	if (!PQgetisnull(res, row, field))
+	{
+		/* Argument values can be quite large, so free after use */
+		char *argval_l = quote_literal_cstr(PQgetvalue(res, row, field));
+
+		appendStringInfo(str, ",\n\t'%s', %s::%s", argname, argval_l, argtype);
+
+		pfree(argval_l);
+	}
+}
+
+
+/*
+ * Generate a pg_restore_relation_stats command.
+ */
+static char *
+restore_relation_stats_sql(PGresult *res, const char *schemaname,
+						   const char *relname, const int server_version_num)
+{
+	StringInfoData	sql;
+
+	char	   *schemaname_l = quote_literal_cstr(schemaname);
+	char	   *relname_l = quote_literal_cstr(relname);
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_relation_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s",
+					 server_version_num, schemaname_l, relname_l);
+
+	pfree(schemaname_l);
+	pfree(relname_l);
+
+	append_optional(&sql, res, 0, RELSTATS_RELPAGES, "relpages", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELTUPLES, "reltuples", "real");
+	append_optional(&sql, res, 0, RELSTATS_RELALLVISIBLE, "relallvisible", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELALLFROZEN, "relallfrozen", "integer");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * Generate a pg_restore_attribute_stats command.
+ */
+static char *
+restore_attribute_stats_sql(PGresult *res, int row,
+							const char *schemaname, const char *relname,
+							const AttrNumber attnum, const int server_version_num)
+{
+	StringInfoData	sql;
+
+	char	   *schemaname_l = quote_literal_cstr(schemaname);
+	char	   *relname_l = quote_literal_cstr(relname);
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_attribute_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s,\n"
+					 "\t'attnum', %d::smallint,\n"
+					 "\t'inherited', false::boolean",
+					 server_version_num, schemaname_l, relname_l, attnum);
+
+	pfree(schemaname_l);
+	pfree(relname_l);
+
+	append_optional(&sql, res, row, ATTSTATS_NULL_FRAC, "null_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_AVG_WIDTH, "avg_width", "integer");
+	append_optional(&sql, res, row, ATTSTATS_N_DISTINCT, "n_distinct", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_VALS, "most_common_vals", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_FREQS, "most_common_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_HISTOGRAM_BOUNDS, "histogram_bounds", "text");
+	append_optional(&sql, res, row, ATTSTATS_CORRELATION, "correlation", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEMS, "most_common_elems", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEM_FREQS, "most_common_elem_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_ELEM_COUNT_HISTOGRAM, "elem_count_histogram", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_LENGTH_HISTOGRAM, "range_length_histogram", "text");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_EMPTY_FRAC, "range_empty_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_BOUNDS_HISTOGRAM, "range_bounds_histogram", "text");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+static FdwImportStatsResult
+import_fetched_statistics(Relation relation, int server_version_num,
+						  const char *schemaname, const char *relname,
+						  RemoteStatsResults *remstats)
+{
+	TupleDesc	tupdesc = RelationGetDescr(relation);
+	int			spirc;
+	char	   *relimport_sql;
+
+	/*
+	 * Walk all local table attributes looking for name matches in the result
+	 * set and perform a pg_restore_attribute_stats() on each match.
+	 *
+	 * XXX: the result set is sorted by attname, so perhaps we could do a binary
+	 * search of the result set. Alternately we could collect the local attributes
+	 * in a list and sort that by remote name, which would allow us to iterate via
+	 * a merge.
+	 *
+	 * XXX: what should be done if match_found = false?
+	 */
+	for (int i = 0; i < tupdesc->natts; i++)
+	{
+		char	   *remote_colname;
+		List	   *fc_options;
+		ListCell   *fc_lc;
+		AttrNumber	attnum;
+		bool		match_found = false;
+
+		/* Ignore dropped columns. */
+		if (TupleDescAttr(tupdesc, i)->attisdropped)
+			continue;
+
+		/* Ignore generated columns, but maybe this should fail the import? */
+		if (TupleDescAttr(tupdesc, i)->attgenerated)
+			continue;
+
+		attnum = TupleDescAttr(tupdesc, i)->attnum;
+
+		/* default remote_colname is attname */
+		remote_colname = NameStr(TupleDescAttr(tupdesc, i)->attname);
+		fc_options = GetForeignColumnOptions(RelationGetRelid(relation), i + 1);
+
+		foreach(fc_lc, fc_options)
+		{
+			DefElem    *def = (DefElem *) lfirst(fc_lc);
+
+			if (strcmp(def->defname, "column_name") == 0)
+			{
+				remote_colname = defGetString(def);
+				break;
+			}
+		}
+
+		for (int j = 0; j < PQntuples(remstats->att); j++)
+		{
+			char	   *attimport_sql;
+			PGresult   *res = remstats->att;
+
+			/* Skip results where we don't have no attribute name to compare */
+			if (PQgetisnull(res, j, ATTSTATS_ATTNAME))
+				continue;
+
+			/* Keep skipping name non-matches */
+			if (strcmp(PQgetvalue(res, j, ATTSTATS_ATTNAME), remote_colname) != 0)
+				continue;
+
+			match_found = true;
+			attimport_sql = restore_attribute_stats_sql(res, j, schemaname,
+														relname, attnum, server_version_num);
+
+			spirc = SPI_execute(attimport_sql, false, 1);
+			pfree(attimport_sql);
+
+			if (spirc != SPI_OK_SELECT)
+			{
+				/*
+				 * It takes a lot to make a restore command fail outright, so any actual
+				 * failure is a sign that the statistics are seriously malformed, and
+				 * we should give up on importing stats for this table.
+				 */
+				ereport(INFO,
+						(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+							errmsg("Attribute statistics import failed %s", attimport_sql)));
+				return FDW_IMPORT_STATS_FAILED;
+			}
+		}
+
+		/* TODO: should this be an error? What action could we take to remediate? */
+		if (!match_found)
+			ereport(INFO,
+					(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+						errmsg("Attribute statistics found for %s.%s but no columns matched",
+							   quote_identifier(schemaname),
+							   quote_identifier(relname))));
+	}
+
+	relimport_sql = restore_relation_stats_sql(remstats->rel, schemaname, relname,
+											   server_version_num);
+
+	spirc = SPI_execute(relimport_sql, false, 1);
+
+	if (spirc != SPI_OK_SELECT)
+	{
+		/*
+		 * It takes a lot to make a restore command fail outright, so any actual
+		 * failure is a sign that the statistics are seriously malformed, and
+		 * we should give up on importing stats for this table.
+		 */
+		ereport(INFO,
+				(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+					errmsg("Relation statistics import failed %s", relimport_sql)));
+		return FDW_IMPORT_STATS_FAILED;
+	}
+	pfree(relimport_sql);
+
+	return FDW_IMPORT_STATS_OK;
+}
+
+/*
+ * Analyze a remote table.
+ */
+static bool
+analyze_remote_table(PGconn *conn, const char *remote_schemaname,
+					 const char *remote_relname)
+{
+	StringInfoData	buf;
+	PGresult	   *res;
+	bool			success = false;
+
+	initStringInfo(&buf);
+
+	appendStringInfo(&buf, "ANALYZE %s.%s",
+					 quote_identifier(remote_schemaname),
+					 quote_identifier(remote_relname));
+
+	res = pgfdw_exec_query(conn, buf.data, NULL);
+
+	if (res != NULL)
+	{
+		if (PQresultStatus(res) == PGRES_COMMAND_OK)
+			success = true;
+		else
+			pgfdw_report(NOTICE, res, conn, buf.data);
+
+		PQclear(res);
+	}
+	else
+		ereport(INFO,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("Unable to ANALYZE remote table %s.%s.",
+					   quote_identifier(remote_schemaname),
+					   quote_identifier(remote_relname)));
+
+	pfree(buf.data);
+	return success;
+}
+
+/*
+ * Attempt to fetch statistics from a remote server.
+ */
+static FdwImportStatsResult
+fetch_remote_statistics(PGconn *conn, int server_version_num,
+						const char *remote_schemaname, const char *remote_relname,
+						bool remote_analyze, RemoteStatsResults *remstats)
+{
+	const char *sql_params[2] = { remote_schemaname, remote_relname };
+	int			sql_param_formats[2] = {0, 0};
+
+	const char	   *relation_sql;
+	const char	   *attribute_sql;
+
+	char	relkind;
+
+	if (server_version_num >= 180000)
+		relation_sql = relstats_query_18;
+	else if (server_version_num >= 140000)
+		relation_sql = relstats_query_14;
+	else
+		relation_sql = relstats_query_default;
+
+	if (server_version_num >= 170000)
+		attribute_sql = attstats_query_17;
+	else if (server_version_num >= 90200)
+		attribute_sql = attstats_query_9_2;
+	else if (server_version_num >= 90000)
+		attribute_sql = attstats_query_9_0;
+	else
+		attribute_sql = attstats_query_default;
+
+	/*
+	 * Fetch basic relation stats.
+	 */
+	if (!PQsendQueryParams(conn, relation_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, relation_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	remstats->rel = pgfdw_get_result(conn);
+
+	if (remstats->rel == NULL
+		|| PQresultStatus(remstats->rel) != PGRES_TUPLES_OK
+		|| PQntuples(remstats->rel) != 1
+		|| PQnfields(remstats->rel) != RELSTATS_NUM_FIELDS
+		|| PQgetisnull(remstats->rel, 0, RELSTATS_RELKIND))
+	{
+		/* unable to get relation stats, fall back on table sampling */
+		pgfdw_report(INFO, remstats->rel, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Verify that the remote table is the sort that can have meaningful stats
+	 * in pg_stats.
+	 *
+	 * Note that while relations of kinds RELKIND_INDEX and
+	 * RELKIND_PARTITIONED_INDEX can have rows in pg_stats, they obviously can't
+	 * support a foreign table.
+	 */
+	relkind = *PQgetvalue(remstats->rel, 0, RELSTATS_RELKIND);
+
+	switch(relkind)
+	{
+		case RELKIND_RELATION:
+		case RELKIND_PARTITIONED_TABLE:
+		case RELKIND_FOREIGN_TABLE:
+		case RELKIND_MATVIEW:
+			break;
+		default:
+			ereport(INFO,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("Remote table %s.%s does not support statistics.",
+						   quote_identifier(remote_schemaname),
+						   quote_identifier(remote_relname)),
+					errdetail("Remote relation if of relkind \"%c\"", relkind));
+			return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/* See if it actually has any attribute stats. */
+	if (!PQsendQueryParams(conn, attribute_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	remstats->att = pgfdw_get_result(conn);
+	if (remstats->att == NULL
+		|| PQresultStatus(remstats->att) != PGRES_TUPLES_OK
+		|| PQnfields(remstats->att) != ATTSTATS_NUM_FIELDS)
+	{
+		pgfdw_report(INFO, remstats->att, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * If we got attribute statistics results, then we are done with fetching.
+	 */
+	if (PQntuples(remstats->att) > 0)
+		return FDW_IMPORT_STATS_OK;
+
+	/*
+	 * If remote_analyze is not enabled, then a lack of fetched stats is viewed
+	 * as a temporary problem that may be solved later when the remote server
+	 * analyzes the table.
+	 */
+	if (!remote_analyze)
+		return FDW_IMPORT_STATS_NOTFOUND;
+
+	/*
+	 * Clear off any existing fetched statistics.
+	 */
+	PQclear(remstats->att);
+	PQclear(remstats->rel);
+	remstats->att = NULL;
+	remstats->rel = NULL;
+
+	/*
+	 * Analyze the remote table.
+	 *
+	 * If it's still empty afterward, then that's an error indicating that
+	 * remote_analyze should probably be disabled.
+	 */
+	if (!analyze_remote_table(conn, remote_schemaname, remote_relname))
+		return FDW_IMPORT_STATS_FAILED;
+
+	/*
+	 * Re-fetch attribute stats query. If there still are no attribute stats then
+	 * it's an error.
+	 */
+	if (!PQsendQueryParams(conn, attribute_sql, 2, NULL, sql_params, NULL,
+						sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	remstats->att = pgfdw_get_result(conn);
+
+	/* Getting nothing on the second try is a failure */
+	if (remstats->att == NULL
+		|| PQresultStatus(remstats->att) != PGRES_TUPLES_OK
+		|| PQntuples(remstats->att) == 0
+		|| PQnfields(remstats->att) != ATTSTATS_NUM_FIELDS)
+	{
+		pgfdw_report(INFO, remstats->att, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Re-fetch basic relation stats, as they have been updated.
+	 */
+	if (!PQsendQueryParams(conn, relation_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, relation_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	remstats->rel = pgfdw_get_result(conn);
+
+	if (remstats->rel == NULL
+		|| PQresultStatus(remstats->rel) != PGRES_TUPLES_OK
+		|| PQntuples(remstats->rel) != 1
+		|| PQnfields(remstats->rel) != RELSTATS_NUM_FIELDS)
+	{
+		pgfdw_report(INFO, remstats->rel, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	return FDW_IMPORT_STATS_OK;
+}
+
+/*
+ * postgresImportStatistics
+ * 		Attempt to fetch remote statistics and apply those instead of analyzing.
+ */
+static FdwImportStatsResult
+postgresImportStatistics(Relation relation)
+{
+
+	ForeignTable   *table;
+	ForeignServer  *server;
+	UserMapping	   *user;
+	PGconn		   *conn;
+	ListCell	   *lc;
+	bool			fetch_stats = true;
+	bool			remote_analyze = false;
+	int				server_version_num = 0;
+	const char	   *schemaname = NULL;
+	const char	   *relname = NULL;
+	const char	   *remote_schemaname = NULL;
+	const char	   *remote_relname = NULL;
+
+	FdwImportStatsResult	fisr = FDW_IMPORT_STATS_OK;
+
+	RemoteStatsResults	remstats = { .rel = NULL, .att = NULL };
+
+	table = GetForeignTable(RelationGetRelid(relation));
+	server = GetForeignServer(table->serverid);
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+			fetch_stats = defGetBoolean(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+			fetch_stats = defGetBoolean(def);
+		else if (strcmp(def->defname, "schema_name") == 0)
+			remote_schemaname = defGetString(def);
+		else if (strcmp(def->defname, "table_name") == 0)
+			remote_relname = defGetString(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	if (!fetch_stats)
+		return FDW_IMPORT_STATS_DISABLED;
+
+	user = GetUserMapping(GetUserId(), table->serverid);
+	conn = GetConnection(user, false, NULL);
+	server_version_num = PQserverVersion(conn);
+
+	schemaname = get_namespace_name(RelationGetNamespace(relation));
+
+	relname = RelationGetRelationName(relation);
+
+	if (remote_schemaname == NULL)
+		remote_schemaname = schemaname;
+	if (remote_relname == NULL)
+		remote_relname = relname;
+
+	fisr = fetch_remote_statistics(conn, server_version_num,
+								   remote_schemaname, remote_relname,
+								   remote_analyze, &remstats);
+
+	ReleaseConnection(conn);
+
+	/* If we have successfully fetched stats, then try to import them */
+	if (fisr == FDW_IMPORT_STATS_OK)
+	{
+		SPI_connect();
+		fisr = import_fetched_statistics(relation, server_version_num,
+										 schemaname, relname, &remstats);
+		SPI_finish();
+	}
+
+	PQclear(remstats.att);
+	PQclear(remstats.rel);
+
+	return fisr;
+}
+
+
 /*
  * postgresGetAnalyzeInfoForForeignTable
  *		Count tuples in foreign table (just get pg_class.reltuples).
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 9a8f9e28135..09a98c860f1 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -1278,7 +1278,8 @@ REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -1287,6 +1288,8 @@ SELECT ftx.x1, ft2.c2, ftx.x8 FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8), ft2
 SELECT ftx.x1, ft2.c2, ftx FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8), ft2
   WHERE ftx.x1 = ft2.c1 AND ftx.x1 = 1; -- ERROR
 SELECT sum(c2), array_agg(c8) FROM ft1 GROUP BY c8; -- ERROR
+ANALYZE ft1; -- WARNING
+ALTER FOREIGN TABLE ft1 OPTIONS ( fetch_stats 'false' );
 ANALYZE ft1; -- ERROR
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE user_enum;
 
@@ -4379,6 +4382,38 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 
+-- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table', remote_analyze 'true');
+
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+ANALYZE remote_analyze_ftable;
+
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================

base-commit: 7d9043aee803bf9bf3307ce5f45f3464ea288cb1
-- 
2.51.1

#12Chao Li
li.evan.chao@gmail.com
In reply to: Corey Huinker (#11)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Nov 23, 2025, at 15:27, Corey Huinker <corey.huinker@gmail.com> wrote:

My apologies for the delayed response.

Valuable responses are worth waiting for.

I've reorganized some things a bit, mostly to make resource cleanup simpler.
<v4-0001-Add-remote-statistics-fetching-to-postgres_fdw.patch>

Few comments:

1 - commit message
```
effort and the user is better of setting fetch_stats to false for that
```

I guess “of” should be “off”

2 - postgres-fdw.sgml
```
+ server, determines wheter an <command>ANALYZE</command> on a foreign
```

Typo: wheter => whether

3 - postgres-fdw.sgml
```
+ data sampling if no statistics were availble. This option is only
```

Typo: availble => available

4 - option.c
```
+		/* fetch_size is available on both server and table */
+		{"fetch_stats", ForeignServerRelationId, false},
+		{"fetch_stats", ForeignTableRelationId, false},
```

In the comment, I guess fetch_size should be fetch_stats.

5 - analyze.c
```
+ * XXX: Should this be it's own fetch type? If not, then there might be
```

Typo: “it’s own” => “its own”

6 - analyze.c
```
+				case FDW_IMPORT_STATS_NOTFOUND:
+					ereport(INFO,
+							errmsg("Found no remote statistics for \"%s\"",
+								   RelationGetRelationName(onerel)));
```

`Found no remote statistics for \"%s\””` could be rephrased as `""No remote statistics found for foreign table \"%s\””`, sounds better wording in server log.

Also, I wonder if this message at INFO level is too noisy?

7 - postgres_fdw.c
```
+		default:
+			ereport(INFO,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("Remote table %s.%s does not support statistics.",
+						   quote_identifier(remote_schemaname),
+						   quote_identifier(remote_relname)),
+					errdetail("Remote relation if of relkind \"%c\"", relkind));
```

I think “if of” should be “is of”.

8 - postgres_fdw.c
```
+						errmsg("Attribute statistics found for %s.%s but no columns matched",
+							   quote_identifier(schemaname),
+							   quote_identifier(relname))));
```

Instead of using "%s.%s” and calling quote_identifier() twice, there is a simple function to use: quote_qualified_identifier(schemaname, relname).

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

#13Etsuro Fujita
etsuro.fujita@gmail.com
In reply to: Corey Huinker (#10)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Sat, Nov 22, 2025 at 6:31 AM Corey Huinker <corey.huinker@gmail.com> wrote:

Other initial comments:

The commit message says:

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will first attempt to fetch statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If no statistics are found, then ANALYZE will fall back to the normal
behavior of sampling and local analysis.

I think the first step assumes that the remote stats are up-to-date;
if they aren't, it would cause a regression. (If the remote relation
is a plain table, they are likely to be up-to-date, but for example,
if it is a foreign table, it's possible that they are stale.) So how
about making it the user's responsibility to make them up-to-date? If
doing so, we wouldn't need to do the second and third steps anymore,
making the patch simple.

Obviously there is no way to know the quality/freshness of remote stats if they are found.

The analyze option was borne of feedback from other postgres hackers while brainstorming on what this option might look like. I don't think we *need* this extra option for the feature to be a success, but it's relative simplicity did make me want to put it out there to see who else liked it.

Actually, I have some concerns about the ANALYZE and fall-back
options. As for the former, if the remote user didn't have the
MAINTAIN privilege on the remote table, remote ANALYZE would be just a
waste effort. As for the latter, imagine the situation where a user
ANALYZEs a foreign table whose remote table is significantly large.
When the previous attempts fail, the user might want to re-try to
import remote stats after ANALYZEing the remote table in the remote
side in some way, rather than postgres_fdw automatically falling back
to the normal lengthy processing. I think just throwing an error if
the first attempt fails would make the system not only simple but
reliable while providing some flexibility to users.

On the other hand:

This operation will only work on remote relations that can have stored
statistics: tables, partitioned tables, and materialized views. If the
remote relation is a view then remote fetching/analyzing is just wasted
effort and the user is better of setting fetch_stats to false for that
table.

I'm not sure the waste effort is acceptable; IMO, if the remote table
is a view, I think that the system should detect that in some way, and
then just do the normal ANALYZE processing.

The stats fetch query is pretty light, but I can see fetching the relkind along with the relstats, and making decisions on whether to continue from there, only applying the relstats after attrstats have been successfully applied.

Good idea! I would vote for throwing an error if the relkind is view,
making the user set fetch_stats to false for the foreign table.

Best regards,
Etsuro Fujita

#14Etsuro Fujita
etsuro.fujita@gmail.com
In reply to: Corey Huinker (#11)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Sun, Nov 23, 2025 at 4:28 PM Corey Huinker <corey.huinker@gmail.com> wrote:

I've reorganized some things a bit, mostly to make resource cleanup simpler.

Thanks for updating the patch! I will look into it.

Best regards,
Etsuro Fujita

#15Corey Huinker
corey.huinker@gmail.com
In reply to: Etsuro Fujita (#14)
1 attachment(s)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Thu, Nov 27, 2025 at 7:48 AM Etsuro Fujita <etsuro.fujita@gmail.com>
wrote:

On Sun, Nov 23, 2025 at 4:28 PM Corey Huinker <corey.huinker@gmail.com>
wrote:

I've reorganized some things a bit, mostly to make resource cleanup

simpler.

Thanks for updating the patch! I will look into it.

Best regards,
Etsuro Fujita

Rebase, no changes.

Attachments:

v5-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchtext/x-patch; charset=US-ASCII; name=v5-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchDownload
From f500a5af60e3ae842f1ec485a7341218acb648fe Mon Sep 17 00:00:00 2001
From: Corey Huinker <corey.huinker@gmail.com>
Date: Thu, 7 Aug 2025 23:58:38 -0400
Subject: [PATCH v5] Add remote statistics fetching to postgres_fdw.

This adds the ability to fetch and import statistics from a remote
server table table rather than fetching the data or data sample from
that table.

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will first attempt to fetch statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If no statistics are found, then ANALYZE will fall back to the normal
behavior of sampling and local analysis.

This operation will only work on remote relations that can have stored
statistics: tables, partitioned tables, and materialized views. If the
remote relation is a view then remote fetching/analyzing is just wasted
effort and the user is better of setting fetch_stats to false for that
table.

The default for fetch_stats is true at both server and table level. The
default for remote_analyze is false at both the server and table level.
In both cases, setting a value at the table level will override the
corresponding server-level setting.
---
 src/include/foreign/fdwapi.h                  |  12 +
 src/backend/commands/analyze.c                |  45 +-
 doc/src/sgml/postgres-fdw.sgml                |  35 +-
 .../postgres_fdw/expected/postgres_fdw.out    |  64 +-
 contrib/postgres_fdw/option.c                 |  10 +
 contrib/postgres_fdw/postgres_fdw.c           | 665 ++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  37 +-
 7 files changed, 864 insertions(+), 4 deletions(-)

diff --git a/src/include/foreign/fdwapi.h b/src/include/foreign/fdwapi.h
index fcd7e7027f3..eb6284979fb 100644
--- a/src/include/foreign/fdwapi.h
+++ b/src/include/foreign/fdwapi.h
@@ -19,6 +19,15 @@
 /* avoid including explain_state.h here */
 typedef struct ExplainState ExplainState;
 
+/* result of ImportStatistics */
+typedef enum
+{
+	FDW_IMPORT_STATS_OK = 0,		/* was able to import statistics */
+	FDW_IMPORT_STATS_DISABLED,		/* import disabled for this table */
+	FDW_IMPORT_STATS_NOTFOUND,		/* no remote attribute stats found */
+	FDW_IMPORT_STATS_FAILED			/* remote query failure of some kind */
+} FdwImportStatsResult;
+
 
 /*
  * Callback function signatures --- see fdwhandler.sgml for more info.
@@ -157,6 +166,8 @@ typedef bool (*AnalyzeForeignTable_function) (Relation relation,
 											  AcquireSampleRowsFunc *func,
 											  BlockNumber *totalpages);
 
+typedef FdwImportStatsResult (*ImportStatistics_function) (Relation relation);
+
 typedef List *(*ImportForeignSchema_function) (ImportForeignSchemaStmt *stmt,
 											   Oid serverOid);
 
@@ -255,6 +266,7 @@ typedef struct FdwRoutine
 
 	/* Support functions for ANALYZE */
 	AnalyzeForeignTable_function AnalyzeForeignTable;
+	ImportStatistics_function ImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	ImportForeignSchema_function ImportForeignSchema;
diff --git a/src/backend/commands/analyze.c b/src/backend/commands/analyze.c
index 5e2a7a8234e..bbaebc9d710 100644
--- a/src/backend/commands/analyze.c
+++ b/src/backend/commands/analyze.c
@@ -196,13 +196,56 @@ analyze_rel(Oid relid, RangeVar *relation,
 	{
 		/*
 		 * For a foreign table, call the FDW's hook function to see whether it
-		 * supports analysis.
+		 * supports statistics import and/or analysis.
 		 */
 		FdwRoutine *fdwroutine;
 		bool		ok = false;
 
 		fdwroutine = GetFdwRoutineForRelation(onerel, false);
 
+		if (fdwroutine->ImportStatistics != NULL)
+		{
+			FdwImportStatsResult	res;
+
+			/*
+			 * Fetching pre-existing remote stats is not guaranteed to be a quick
+			 * operation.
+			 *
+			 * XXX: Should this be it's own fetch type? If not, then there might be
+			 * confusion when a long stats-fetch fails, followed by a regular analyze,
+			 * which would make it look like the table was analyzed twice.
+			 */
+			pgstat_progress_start_command(PROGRESS_COMMAND_ANALYZE,
+										  RelationGetRelid(onerel));
+
+			res = fdwroutine->ImportStatistics(onerel);
+
+			pgstat_progress_end_command();
+
+			/*
+			 * If we were able to import statistics, then there is no need to collect
+			 * samples for local analysis.
+			 */
+			switch(res)
+			{
+				case FDW_IMPORT_STATS_OK:
+					relation_close(onerel, ShareUpdateExclusiveLock);
+					return;
+				case FDW_IMPORT_STATS_DISABLED:
+					break;
+				case FDW_IMPORT_STATS_NOTFOUND:
+					ereport(INFO,
+							errmsg("Found no remote statistics for \"%s\"",
+								   RelationGetRelationName(onerel)));
+					break;
+				case FDW_IMPORT_STATS_FAILED:
+				default:
+					ereport(INFO,
+							errmsg("Fetching remote statistics from \"%s\" failed",
+								   RelationGetRelationName(onerel)));
+			}
+		}
+
 		if (fdwroutine->AnalyzeForeignTable != NULL)
 			ok = fdwroutine->AnalyzeForeignTable(onerel,
 												 &acquirefunc,
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index 9b032fbf675..656897d89e0 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -332,7 +332,7 @@ OPTIONS (ADD password_required 'false');
    </para>
 
    <para>
-    The following option controls how such an <command>ANALYZE</command>
+    The following options control how such an <command>ANALYZE</command>
     operation behaves:
    </para>
 
@@ -364,6 +364,39 @@ OPTIONS (ADD password_required 'false');
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>fetch_stats</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines if <command>ANALYZE</command> on a foreign table
+       will first attempt to fetch and import the existing relation and
+       attribute statistics from the remote table, and only attempt regular
+       data sampling if no statistics were availble. This option is only
+       useful if the remote relation is one that can have regular statistics
+       (tables and materialized views).
+       The default is <literal>true</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><literal>remote_analyze</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines wheter an <command>ANALYZE</command> on a foreign
+       table will attempt to <command>ANALYZE</command> the remote table if
+       the first attempt to fetch remote statistics fails, and will then
+       make a second and final attempt to fetch remote statistics. This option
+       is only meaningful if the foreign table has
+       <literal>fetch_stats</literal> enabled at either the server or table
+       level.
+       The default is <literal>false</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
    </variablelist>
 
   </sect3>
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 48e3185b227..642ec27f2ac 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -628,6 +628,7 @@ INSERT INTO loct_empty
   SELECT id, 'AAA' || to_char(id, 'FM000') FROM generate_series(1, 100) id;
 DELETE FROM loct_empty;
 ANALYZE ft_empty;
+INFO:  Found no remote statistics for "ft_empty"
 EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM ft_empty ORDER BY c1;
                                   QUERY PLAN                                   
 -------------------------------------------------------------------------------
@@ -4551,7 +4552,8 @@ REINDEX TABLE reind_fdw_parent; -- ok
 REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -4568,6 +4570,9 @@ CONTEXT:  whole-row reference to foreign table "ftx"
 SELECT sum(c2), array_agg(c8) FROM ft1 GROUP BY c8; -- ERROR
 ERROR:  invalid input syntax for type integer: "foo"
 CONTEXT:  processing expression at position 2 in select list
+ANALYZE ft1; -- WARNING
+WARNING:  invalid input syntax for type integer: "foo"
+ALTER FOREIGN TABLE ft1 OPTIONS ( fetch_stats 'false' );
 ANALYZE ft1; -- ERROR
 ERROR:  invalid input syntax for type integer: "foo"
 CONTEXT:  column "c8" of foreign table "ft1"
@@ -7102,6 +7107,7 @@ INSERT INTO loct2 VALUES (1002, 'bar');
 CREATE FOREIGN TABLE remt2 (c1 int, c2 text) SERVER loopback OPTIONS (table_name 'loct2');
 ANALYZE loct1;
 ANALYZE remt2;
+INFO:  Found no remote statistics for "remt2"
 SET enable_mergejoin TO false;
 SET enable_hashjoin TO false;
 SET enable_material TO false;
@@ -8784,6 +8790,7 @@ alter foreign table foo2 options (use_remote_estimate 'true');
 create index i_loct1_f1 on loct1(f1);
 create index i_foo_f1 on foo(f1);
 analyze foo;
+INFO:  Found no remote statistics for "foo2"
 analyze loct1;
 -- inner join; expressions in the clauses appear in the equivalence class list
 explain (verbose, costs off)
@@ -9013,7 +9020,9 @@ insert into remt1 values (2, 'bar');
 insert into remt2 values (1, 'foo');
 insert into remt2 values (2, 'bar');
 analyze remt1;
+INFO:  Found no remote statistics for "remt1"
 analyze remt2;
+INFO:  Found no remote statistics for "remt2"
 explain (verbose, costs off)
 update parent set b = parent.b || remt2.b from remt2 where parent.a = remt2.a returning *;
                                                    QUERY PLAN                                                   
@@ -10313,6 +10322,8 @@ CREATE FOREIGN TABLE ftprt1_p1 PARTITION OF fprt1 FOR VALUES FROM (0) TO (250)
 CREATE FOREIGN TABLE ftprt1_p2 PARTITION OF fprt1 FOR VALUES FROM (250) TO (500)
 	SERVER loopback OPTIONS (TABLE_NAME 'fprt1_p2');
 ANALYZE fprt1;
+INFO:  Found no remote statistics for "ftprt1_p1"
+INFO:  Found no remote statistics for "ftprt1_p2"
 ANALYZE fprt1_p1;
 ANALYZE fprt1_p2;
 CREATE TABLE fprt2 (a int, b int, c varchar) PARTITION BY RANGE(b);
@@ -10328,6 +10339,8 @@ ALTER TABLE fprt2 ATTACH PARTITION ftprt2_p1 FOR VALUES FROM (0) TO (250);
 CREATE FOREIGN TABLE ftprt2_p2 PARTITION OF fprt2 FOR VALUES FROM (250) TO (500)
 	SERVER loopback OPTIONS (table_name 'fprt2_p2', use_remote_estimate 'true');
 ANALYZE fprt2;
+INFO:  Found no remote statistics for "ftprt2_p1"
+INFO:  Found no remote statistics for "ftprt2_p2"
 ANALYZE fprt2_p1;
 ANALYZE fprt2_p2;
 -- inner join three tables
@@ -10515,9 +10528,15 @@ CREATE FOREIGN TABLE fpagg_tab_p1 PARTITION OF pagg_tab FOR VALUES FROM (0) TO (
 CREATE FOREIGN TABLE fpagg_tab_p2 PARTITION OF pagg_tab FOR VALUES FROM (10) TO (20) SERVER loopback OPTIONS (table_name 'pagg_tab_p2');
 CREATE FOREIGN TABLE fpagg_tab_p3 PARTITION OF pagg_tab FOR VALUES FROM (20) TO (30) SERVER loopback OPTIONS (table_name 'pagg_tab_p3');
 ANALYZE pagg_tab;
+INFO:  Found no remote statistics for "fpagg_tab_p1"
+INFO:  Found no remote statistics for "fpagg_tab_p2"
+INFO:  Found no remote statistics for "fpagg_tab_p3"
 ANALYZE fpagg_tab_p1;
+INFO:  Found no remote statistics for "fpagg_tab_p1"
 ANALYZE fpagg_tab_p2;
+INFO:  Found no remote statistics for "fpagg_tab_p2"
 ANALYZE fpagg_tab_p3;
+INFO:  Found no remote statistics for "fpagg_tab_p3"
 -- When GROUP BY clause matches with PARTITION KEY.
 -- Plan with partitionwise aggregates is disabled
 SET enable_partitionwise_aggregate TO false;
@@ -11463,6 +11482,8 @@ CREATE FOREIGN TABLE async_p2 PARTITION OF async_pt FOR VALUES FROM (2000) TO (3
 INSERT INTO async_p1 SELECT 1000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 INSERT INTO async_p2 SELECT 2000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
 -- simple queries
 CREATE TABLE result_tbl (a int, b int, c text);
 EXPLAIN (VERBOSE, COSTS OFF)
@@ -11569,6 +11590,9 @@ CREATE FOREIGN TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4
   SERVER loopback2 OPTIONS (table_name 'base_tbl3');
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
+INFO:  Found no remote statistics for "async_p3"
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
                            QUERY PLAN                           
@@ -11610,6 +11634,8 @@ DROP TABLE base_tbl3;
 CREATE TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4000);
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 ANALYZE async_pt;
+INFO:  Found no remote statistics for "async_p1"
+INFO:  Found no remote statistics for "async_p2"
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
                            QUERY PLAN                           
@@ -12665,6 +12691,42 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 -- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table', remote_analyze 'true');
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+ tablename | num_stats 
+-----------+-----------
+(0 rows)
+
+ANALYZE remote_analyze_ftable;
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+       tablename       | num_stats 
+-----------------------+-----------
+ remote_analyze_ftable |         3
+ remote_analyze_table  |         3
+(2 rows)
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+-- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
 -- Disable debug_discard_caches in order to manage remote connections
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 04788b7e8b3..7f069373e82 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -120,6 +120,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 			strcmp(def->defname, "async_capable") == 0 ||
 			strcmp(def->defname, "parallel_commit") == 0 ||
 			strcmp(def->defname, "parallel_abort") == 0 ||
+			strcmp(def->defname, "fetch_stats") == 0 ||
+			strcmp(def->defname, "remote_analyze") == 0 ||
 			strcmp(def->defname, "keep_connections") == 0)
 		{
 			/* these accept only boolean values */
@@ -278,6 +280,14 @@ InitPgFdwOptions(void)
 		{"use_scram_passthrough", ForeignServerRelationId, false},
 		{"use_scram_passthrough", UserMappingRelationId, false},
 
+		/* fetch_size is available on both server and table */
+		{"fetch_stats", ForeignServerRelationId, false},
+		{"fetch_stats", ForeignTableRelationId, false},
+
+		/* remote_analyze is available on both server and table */
+		{"remote_analyze", ForeignServerRelationId, false},
+		{"remote_analyze", ForeignTableRelationId, false},
+
 		/*
 		 * sslcert and sslkey are in fact libpq options, but we repeat them
 		 * here to allow them to appear in both foreign server context (when
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 5e178c21b39..334e4bf0f4e 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -22,6 +22,8 @@
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "executor/execAsync.h"
+#include "executor/spi.h"
+#include "fmgr.h"
 #include "foreign/fdwapi.h"
 #include "funcapi.h"
 #include "miscadmin.h"
@@ -317,6 +319,13 @@ typedef struct
 	List	   *already_used;	/* expressions already dealt with */
 } ec_member_foreign_arg;
 
+/* Result sets that are returned from a foreign statistics scan */
+typedef struct
+{
+	PGresult	*rel;
+	PGresult	*att;
+} RemoteStatsResults;
+
 /*
  * SQL functions
  */
@@ -402,6 +411,7 @@ static void postgresExecForeignTruncate(List *rels,
 static bool postgresAnalyzeForeignTable(Relation relation,
 										AcquireSampleRowsFunc *func,
 										BlockNumber *totalpages);
+static FdwImportStatsResult postgresImportStatistics(Relation relation);
 static List *postgresImportForeignSchema(ImportForeignSchemaStmt *stmt,
 										 Oid serverOid);
 static void postgresGetForeignJoinPaths(PlannerInfo *root,
@@ -546,6 +556,115 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
 
+/*
+ * Static queries for querying remote statistics.
+ */
+
+
+/* relallfrozen introduced in v18 */
+static const char *relstats_query_18 =
+	"SELECT c.relkind, c.relpages, c.reltuples, c.relallvisible, c.relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* trust reltuples = 0 as of v14 */
+static const char *relstats_query_14 =
+	"SELECT c.relkind, c.relpages, c.reltuples, c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/*
+ * Before v14, a reltuples value of 0 was ambiguous: it could either mean
+ * the relation is empty, or it could mean that it hadn't yet been
+ * vacuumed or analyzed.  (Newer versions use -1 for the latter case.)
+ * This ambiguity allegedly can cause the planner to choose inefficient
+ * plans after restoring to v18 or newer.  To deal with this, let's just
+ * set reltuples to -1 in that case.
+ */
+static const char *relstats_query_default =
+	"SELECT c.relkind, c.relpages, "
+	"CASE c.reltuples WHEN 0 THEN -1 ELSE c.reltuples END AS reltuples, "
+	"c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* All static relstats queries have the same column order */
+enum RelStatsColumns {
+	RELSTATS_RELKIND = 0,
+	RELSTATS_RELPAGES,
+	RELSTATS_RELTUPLES,
+	RELSTATS_RELALLVISIBLE,
+	RELSTATS_RELALLFROZEN,
+	RELSTATS_NUM_FIELDS
+};
+
+/* range stats introduced in v17 */
+static const char *attstats_query_17 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"s.range_length_histogram, s.range_empty_frac, s.range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* elements stats introduced in 9.2 */
+static const char *attstats_query_9_2 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* inherited introduced in 9.0 */
+static const char *attstats_query_9_0 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+static const char *attstats_query_default =
+	"SELECT s.attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname";
+
+/* All static attstats queries have the same column order */
+enum AttStatsColumns {
+	ATTSTATS_ATTNAME = 0,
+	ATTSTATS_NULL_FRAC,
+	ATTSTATS_AVG_WIDTH,
+	ATTSTATS_N_DISTINCT,
+	ATTSTATS_MOST_COMMON_VALS,
+	ATTSTATS_MOST_COMMON_FREQS,
+	ATTSTATS_HISTOGRAM_BOUNDS,
+	ATTSTATS_CORRELATION,
+	ATTSTATS_MOST_COMMON_ELEMS,
+	ATTSTATS_MOST_COMMON_ELEM_FREQS,
+	ATTSTATS_ELEM_COUNT_HISTOGRAM,
+	ATTSTATS_RANGE_LENGTH_HISTOGRAM,
+	ATTSTATS_RANGE_EMPTY_FRAC,
+	ATTSTATS_RANGE_BOUNDS_HISTOGRAM,
+	ATTSTATS_NUM_FIELDS
+};
 
 /*
  * Foreign-data wrapper handler function: return a struct with pointers
@@ -595,6 +714,7 @@ postgres_fdw_handler(PG_FUNCTION_ARGS)
 
 	/* Support functions for ANALYZE */
 	routine->AnalyzeForeignTable = postgresAnalyzeForeignTable;
+	routine->ImportStatistics = postgresImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	routine->ImportForeignSchema = postgresImportForeignSchema;
@@ -4935,6 +5055,551 @@ postgresAnalyzeForeignTable(Relation relation,
 	return true;
 }
 
+/*
+ * Process optional argument.
+ *
+ * Cannot be the first argument in the SQL function call.
+ *
+ * It is safe to presume that argname and argtype are quote-safe.
+ *
+ * Argument values can potentially be quite large, so free the quoted string
+ * after use.
+ */
+static void
+append_optional(StringInfo str, PGresult *res, int row, int field,
+				const char *argname, const char *argtype)
+{
+	if (!PQgetisnull(res, row, field))
+	{
+		/* Argument values can be quite large, so free after use */
+		char *argval_l = quote_literal_cstr(PQgetvalue(res, row, field));
+
+		appendStringInfo(str, ",\n\t'%s', %s::%s", argname, argval_l, argtype);
+
+		pfree(argval_l);
+	}
+}
+
+
+/*
+ * Generate a pg_restore_relation_stats command.
+ */
+static char *
+restore_relation_stats_sql(PGresult *res, const char *schemaname,
+						   const char *relname, const int server_version_num)
+{
+	StringInfoData	sql;
+
+	char	   *schemaname_l = quote_literal_cstr(schemaname);
+	char	   *relname_l = quote_literal_cstr(relname);
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_relation_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s",
+					 server_version_num, schemaname_l, relname_l);
+
+	pfree(schemaname_l);
+	pfree(relname_l);
+
+	append_optional(&sql, res, 0, RELSTATS_RELPAGES, "relpages", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELTUPLES, "reltuples", "real");
+	append_optional(&sql, res, 0, RELSTATS_RELALLVISIBLE, "relallvisible", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELALLFROZEN, "relallfrozen", "integer");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * Generate a pg_restore_attribute_stats command.
+ */
+static char *
+restore_attribute_stats_sql(PGresult *res, int row,
+							const char *schemaname, const char *relname,
+							const AttrNumber attnum, const int server_version_num)
+{
+	StringInfoData	sql;
+
+	char	   *schemaname_l = quote_literal_cstr(schemaname);
+	char	   *relname_l = quote_literal_cstr(relname);
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_attribute_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s,\n"
+					 "\t'attnum', %d::smallint,\n"
+					 "\t'inherited', false::boolean",
+					 server_version_num, schemaname_l, relname_l, attnum);
+
+	pfree(schemaname_l);
+	pfree(relname_l);
+
+	append_optional(&sql, res, row, ATTSTATS_NULL_FRAC, "null_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_AVG_WIDTH, "avg_width", "integer");
+	append_optional(&sql, res, row, ATTSTATS_N_DISTINCT, "n_distinct", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_VALS, "most_common_vals", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_FREQS, "most_common_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_HISTOGRAM_BOUNDS, "histogram_bounds", "text");
+	append_optional(&sql, res, row, ATTSTATS_CORRELATION, "correlation", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEMS, "most_common_elems", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEM_FREQS, "most_common_elem_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_ELEM_COUNT_HISTOGRAM, "elem_count_histogram", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_LENGTH_HISTOGRAM, "range_length_histogram", "text");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_EMPTY_FRAC, "range_empty_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_BOUNDS_HISTOGRAM, "range_bounds_histogram", "text");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+static FdwImportStatsResult
+import_fetched_statistics(Relation relation, int server_version_num,
+						  const char *schemaname, const char *relname,
+						  RemoteStatsResults *remstats)
+{
+	TupleDesc	tupdesc = RelationGetDescr(relation);
+	int			spirc;
+	char	   *relimport_sql;
+
+	/*
+	 * Walk all local table attributes looking for name matches in the result
+	 * set and perform a pg_restore_attribute_stats() on each match.
+	 *
+	 * XXX: the result set is sorted by attname, so perhaps we could do a binary
+	 * search of the result set. Alternately we could collect the local attributes
+	 * in a list and sort that by remote name, which would allow us to iterate via
+	 * a merge.
+	 *
+	 * XXX: what should be done if match_found = false?
+	 */
+	for (int i = 0; i < tupdesc->natts; i++)
+	{
+		char	   *remote_colname;
+		List	   *fc_options;
+		ListCell   *fc_lc;
+		AttrNumber	attnum;
+		bool		match_found = false;
+
+		/* Ignore dropped columns. */
+		if (TupleDescAttr(tupdesc, i)->attisdropped)
+			continue;
+
+		/* Ignore generated columns, but maybe this should fail the import? */
+		if (TupleDescAttr(tupdesc, i)->attgenerated)
+			continue;
+
+		attnum = TupleDescAttr(tupdesc, i)->attnum;
+
+		/* default remote_colname is attname */
+		remote_colname = NameStr(TupleDescAttr(tupdesc, i)->attname);
+		fc_options = GetForeignColumnOptions(RelationGetRelid(relation), i + 1);
+
+		foreach(fc_lc, fc_options)
+		{
+			DefElem    *def = (DefElem *) lfirst(fc_lc);
+
+			if (strcmp(def->defname, "column_name") == 0)
+			{
+				remote_colname = defGetString(def);
+				break;
+			}
+		}
+
+		for (int j = 0; j < PQntuples(remstats->att); j++)
+		{
+			char	   *attimport_sql;
+			PGresult   *res = remstats->att;
+
+			/* Skip results where we don't have no attribute name to compare */
+			if (PQgetisnull(res, j, ATTSTATS_ATTNAME))
+				continue;
+
+			/* Keep skipping name non-matches */
+			if (strcmp(PQgetvalue(res, j, ATTSTATS_ATTNAME), remote_colname) != 0)
+				continue;
+
+			match_found = true;
+			attimport_sql = restore_attribute_stats_sql(res, j, schemaname,
+														relname, attnum, server_version_num);
+
+			spirc = SPI_execute(attimport_sql, false, 1);
+			pfree(attimport_sql);
+
+			if (spirc != SPI_OK_SELECT)
+			{
+				/*
+				 * It takes a lot to make a restore command fail outright, so any actual
+				 * failure is a sign that the statistics are seriously malformed, and
+				 * we should give up on importing stats for this table.
+				 */
+				ereport(INFO,
+						(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+							errmsg("Attribute statistics import failed %s", attimport_sql)));
+				return FDW_IMPORT_STATS_FAILED;
+			}
+		}
+
+		/* TODO: should this be an error? What action could we take to remediate? */
+		if (!match_found)
+			ereport(INFO,
+					(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+						errmsg("Attribute statistics found for %s.%s but no columns matched",
+							   quote_identifier(schemaname),
+							   quote_identifier(relname))));
+	}
+
+	relimport_sql = restore_relation_stats_sql(remstats->rel, schemaname, relname,
+											   server_version_num);
+
+	spirc = SPI_execute(relimport_sql, false, 1);
+
+	if (spirc != SPI_OK_SELECT)
+	{
+		/*
+		 * It takes a lot to make a restore command fail outright, so any actual
+		 * failure is a sign that the statistics are seriously malformed, and
+		 * we should give up on importing stats for this table.
+		 */
+		ereport(INFO,
+				(errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+					errmsg("Relation statistics import failed %s", relimport_sql)));
+		return FDW_IMPORT_STATS_FAILED;
+	}
+	pfree(relimport_sql);
+
+	return FDW_IMPORT_STATS_OK;
+}
+
+/*
+ * Analyze a remote table.
+ */
+static bool
+analyze_remote_table(PGconn *conn, const char *remote_schemaname,
+					 const char *remote_relname)
+{
+	StringInfoData	buf;
+	PGresult	   *res;
+	bool			success = false;
+
+	initStringInfo(&buf);
+
+	appendStringInfo(&buf, "ANALYZE %s.%s",
+					 quote_identifier(remote_schemaname),
+					 quote_identifier(remote_relname));
+
+	res = pgfdw_exec_query(conn, buf.data, NULL);
+
+	if (res != NULL)
+	{
+		if (PQresultStatus(res) == PGRES_COMMAND_OK)
+			success = true;
+		else
+			pgfdw_report(NOTICE, res, conn, buf.data);
+
+		PQclear(res);
+	}
+	else
+		ereport(INFO,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("Unable to ANALYZE remote table %s.%s.",
+					   quote_identifier(remote_schemaname),
+					   quote_identifier(remote_relname)));
+
+	pfree(buf.data);
+	return success;
+}
+
+/*
+ * Attempt to fetch statistics from a remote server.
+ */
+static FdwImportStatsResult
+fetch_remote_statistics(PGconn *conn, int server_version_num,
+						const char *remote_schemaname, const char *remote_relname,
+						bool remote_analyze, RemoteStatsResults *remstats)
+{
+	const char *sql_params[2] = { remote_schemaname, remote_relname };
+	int			sql_param_formats[2] = {0, 0};
+
+	const char	   *relation_sql;
+	const char	   *attribute_sql;
+
+	char	relkind;
+
+	if (server_version_num >= 180000)
+		relation_sql = relstats_query_18;
+	else if (server_version_num >= 140000)
+		relation_sql = relstats_query_14;
+	else
+		relation_sql = relstats_query_default;
+
+	if (server_version_num >= 170000)
+		attribute_sql = attstats_query_17;
+	else if (server_version_num >= 90200)
+		attribute_sql = attstats_query_9_2;
+	else if (server_version_num >= 90000)
+		attribute_sql = attstats_query_9_0;
+	else
+		attribute_sql = attstats_query_default;
+
+	/*
+	 * Fetch basic relation stats.
+	 */
+	if (!PQsendQueryParams(conn, relation_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, relation_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	remstats->rel = pgfdw_get_result(conn);
+
+	if (remstats->rel == NULL
+		|| PQresultStatus(remstats->rel) != PGRES_TUPLES_OK
+		|| PQntuples(remstats->rel) != 1
+		|| PQnfields(remstats->rel) != RELSTATS_NUM_FIELDS
+		|| PQgetisnull(remstats->rel, 0, RELSTATS_RELKIND))
+	{
+		/* unable to get relation stats, fall back on table sampling */
+		pgfdw_report(INFO, remstats->rel, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Verify that the remote table is the sort that can have meaningful stats
+	 * in pg_stats.
+	 *
+	 * Note that while relations of kinds RELKIND_INDEX and
+	 * RELKIND_PARTITIONED_INDEX can have rows in pg_stats, they obviously can't
+	 * support a foreign table.
+	 */
+	relkind = *PQgetvalue(remstats->rel, 0, RELSTATS_RELKIND);
+
+	switch(relkind)
+	{
+		case RELKIND_RELATION:
+		case RELKIND_PARTITIONED_TABLE:
+		case RELKIND_FOREIGN_TABLE:
+		case RELKIND_MATVIEW:
+			break;
+		default:
+			ereport(INFO,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("Remote table %s.%s does not support statistics.",
+						   quote_identifier(remote_schemaname),
+						   quote_identifier(remote_relname)),
+					errdetail("Remote relation if of relkind \"%c\"", relkind));
+			return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/* See if it actually has any attribute stats. */
+	if (!PQsendQueryParams(conn, attribute_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	remstats->att = pgfdw_get_result(conn);
+	if (remstats->att == NULL
+		|| PQresultStatus(remstats->att) != PGRES_TUPLES_OK
+		|| PQnfields(remstats->att) != ATTSTATS_NUM_FIELDS)
+	{
+		pgfdw_report(INFO, remstats->att, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * If we got attribute statistics results, then we are done with fetching.
+	 */
+	if (PQntuples(remstats->att) > 0)
+		return FDW_IMPORT_STATS_OK;
+
+	/*
+	 * If remote_analyze is not enabled, then a lack of fetched stats is viewed
+	 * as a temporary problem that may be solved later when the remote server
+	 * analyzes the table.
+	 */
+	if (!remote_analyze)
+		return FDW_IMPORT_STATS_NOTFOUND;
+
+	/*
+	 * Clear off any existing fetched statistics.
+	 */
+	PQclear(remstats->att);
+	PQclear(remstats->rel);
+	remstats->att = NULL;
+	remstats->rel = NULL;
+
+	/*
+	 * Analyze the remote table.
+	 *
+	 * If it's still empty afterward, then that's an error indicating that
+	 * remote_analyze should probably be disabled.
+	 */
+	if (!analyze_remote_table(conn, remote_schemaname, remote_relname))
+		return FDW_IMPORT_STATS_FAILED;
+
+	/*
+	 * Re-fetch attribute stats query. If there still are no attribute stats then
+	 * it's an error.
+	 */
+	if (!PQsendQueryParams(conn, attribute_sql, 2, NULL, sql_params, NULL,
+						sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	remstats->att = pgfdw_get_result(conn);
+
+	/* Getting nothing on the second try is a failure */
+	if (remstats->att == NULL
+		|| PQresultStatus(remstats->att) != PGRES_TUPLES_OK
+		|| PQntuples(remstats->att) == 0
+		|| PQnfields(remstats->att) != ATTSTATS_NUM_FIELDS)
+	{
+		pgfdw_report(INFO, remstats->att, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Re-fetch basic relation stats, as they have been updated.
+	 */
+	if (!PQsendQueryParams(conn, relation_sql, 2, NULL, sql_params, NULL,
+						   sql_param_formats, 0))
+	{
+		pgfdw_report(INFO, NULL, conn, relation_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	/*
+	 * Get the result, and check for success.
+	 * If the query failed or the result set is of the wrong shape, then
+	 * fail the import and fall back to local analysis.
+	 */
+	remstats->rel = pgfdw_get_result(conn);
+
+	if (remstats->rel == NULL
+		|| PQresultStatus(remstats->rel) != PGRES_TUPLES_OK
+		|| PQntuples(remstats->rel) != 1
+		|| PQnfields(remstats->rel) != RELSTATS_NUM_FIELDS)
+	{
+		pgfdw_report(INFO, remstats->rel, conn, attribute_sql);
+		return FDW_IMPORT_STATS_FAILED;
+	}
+
+	return FDW_IMPORT_STATS_OK;
+}
+
+/*
+ * postgresImportStatistics
+ * 		Attempt to fetch remote statistics and apply those instead of analyzing.
+ */
+static FdwImportStatsResult
+postgresImportStatistics(Relation relation)
+{
+
+	ForeignTable   *table;
+	ForeignServer  *server;
+	UserMapping	   *user;
+	PGconn		   *conn;
+	ListCell	   *lc;
+	bool			fetch_stats = true;
+	bool			remote_analyze = false;
+	int				server_version_num = 0;
+	const char	   *schemaname = NULL;
+	const char	   *relname = NULL;
+	const char	   *remote_schemaname = NULL;
+	const char	   *remote_relname = NULL;
+
+	FdwImportStatsResult	fisr = FDW_IMPORT_STATS_OK;
+
+	RemoteStatsResults	remstats = { .rel = NULL, .att = NULL };
+
+	table = GetForeignTable(RelationGetRelid(relation));
+	server = GetForeignServer(table->serverid);
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+			fetch_stats = defGetBoolean(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+			fetch_stats = defGetBoolean(def);
+		else if (strcmp(def->defname, "schema_name") == 0)
+			remote_schemaname = defGetString(def);
+		else if (strcmp(def->defname, "table_name") == 0)
+			remote_relname = defGetString(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	if (!fetch_stats)
+		return FDW_IMPORT_STATS_DISABLED;
+
+	user = GetUserMapping(GetUserId(), table->serverid);
+	conn = GetConnection(user, false, NULL);
+	server_version_num = PQserverVersion(conn);
+
+	schemaname = get_namespace_name(RelationGetNamespace(relation));
+
+	relname = RelationGetRelationName(relation);
+
+	if (remote_schemaname == NULL)
+		remote_schemaname = schemaname;
+	if (remote_relname == NULL)
+		remote_relname = relname;
+
+	fisr = fetch_remote_statistics(conn, server_version_num,
+								   remote_schemaname, remote_relname,
+								   remote_analyze, &remstats);
+
+	ReleaseConnection(conn);
+
+	/* If we have successfully fetched stats, then try to import them */
+	if (fisr == FDW_IMPORT_STATS_OK)
+	{
+		SPI_connect();
+		fisr = import_fetched_statistics(relation, server_version_num,
+										 schemaname, relname, &remstats);
+		SPI_finish();
+	}
+
+	PQclear(remstats.att);
+	PQclear(remstats.rel);
+
+	return fisr;
+}
+
+
 /*
  * postgresGetAnalyzeInfoForForeignTable
  *		Count tuples in foreign table (just get pg_class.reltuples).
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 9a8f9e28135..09a98c860f1 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -1278,7 +1278,8 @@ REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -1287,6 +1288,8 @@ SELECT ftx.x1, ft2.c2, ftx.x8 FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8), ft2
 SELECT ftx.x1, ft2.c2, ftx FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8), ft2
   WHERE ftx.x1 = ft2.c1 AND ftx.x1 = 1; -- ERROR
 SELECT sum(c2), array_agg(c8) FROM ft1 GROUP BY c8; -- ERROR
+ANALYZE ft1; -- WARNING
+ALTER FOREIGN TABLE ft1 OPTIONS ( fetch_stats 'false' );
 ANALYZE ft1; -- ERROR
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE user_enum;
 
@@ -4379,6 +4382,38 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 
+-- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table', remote_analyze 'true');
+
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+ANALYZE remote_analyze_ftable;
+
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================

base-commit: b65f1ad9b12767dbd45d9588ce8ed2e593dbddbf
-- 
2.52.0

#16Chao Li
li.evan.chao@gmail.com
In reply to: Corey Huinker (#15)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Dec 12, 2025, at 05:59, Corey Huinker <corey.huinker@gmail.com> wrote:

On Thu, Nov 27, 2025 at 7:48 AM Etsuro Fujita <etsuro.fujita@gmail.com> wrote:
On Sun, Nov 23, 2025 at 4:28 PM Corey Huinker <corey.huinker@gmail.com> wrote:

I've reorganized some things a bit, mostly to make resource cleanup simpler.

Thanks for updating the patch! I will look into it.

Best regards,
Etsuro Fujita

Rebase, no changes.
<v5-0001-Add-remote-statistics-fetching-to-postgres_fdw.patch>

A kind reminder, I don’t see my comments are addressed:

/messages/by-id/F9C73EF2-F977-46E4-9F61-B6CF72BF1A69@gmail.com

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

#17Etsuro Fujita
etsuro.fujita@gmail.com
In reply to: Etsuro Fujita (#13)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Thu, Nov 27, 2025 at 9:46 PM Etsuro Fujita <etsuro.fujita@gmail.com> wrote:

On Sat, Nov 22, 2025 at 6:31 AM Corey Huinker <corey.huinker@gmail.com> wrote:

Other initial comments:

The commit message says:

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will first attempt to fetch statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If no statistics are found, then ANALYZE will fall back to the normal
behavior of sampling and local analysis.

I think the first step assumes that the remote stats are up-to-date;
if they aren't, it would cause a regression. (If the remote relation
is a plain table, they are likely to be up-to-date, but for example,
if it is a foreign table, it's possible that they are stale.) So how
about making it the user's responsibility to make them up-to-date? If
doing so, we wouldn't need to do the second and third steps anymore,
making the patch simple.

Obviously there is no way to know the quality/freshness of remote stats if they are found.

The analyze option was borne of feedback from other postgres hackers while brainstorming on what this option might look like. I don't think we *need* this extra option for the feature to be a success, but it's relative simplicity did make me want to put it out there to see who else liked it.

Actually, I have some concerns about the ANALYZE and fall-back
options. As for the former, if the remote user didn't have the
MAINTAIN privilege on the remote table, remote ANALYZE would be just a
waste effort. As for the latter, imagine the situation where a user
ANALYZEs a foreign table whose remote table is significantly large.
When the previous attempts fail, the user might want to re-try to
import remote stats after ANALYZEing the remote table in the remote
side in some way, rather than postgres_fdw automatically falling back
to the normal lengthy processing. I think just throwing an error if
the first attempt fails would make the system not only simple but
reliable while providing some flexibility to users.

I think I was narrow-minded here; as for the ANALYZE option, if the
remote user has the privilege, it would work well, so +1 for it, but I
don't think it's a must-have option, so I'd vote for making it a
separate patch. As for the fall-back behavior, however, sorry, I
still think it reduces flexibility.

Best regards,
Etsuro Fujita

#18Etsuro Fujita
etsuro.fujita@gmail.com
In reply to: Corey Huinker (#15)
1 attachment(s)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Fri, Dec 12, 2025 at 6:59 AM Corey Huinker <corey.huinker@gmail.com> wrote:

Rebase, no changes.

Thanks for rebasing! I reviewed the changes made to the core:

@@ -196,13 +196,56 @@ analyze_rel(Oid relid, RangeVar *relation,
    {
        /*
         * For a foreign table, call the FDW's hook function to see whether it
-        * supports analysis.
+        * supports statistics import and/or analysis.
         */
        FdwRoutine *fdwroutine;
        bool        ok = false;

fdwroutine = GetFdwRoutineForRelation(onerel, false);

+       if (fdwroutine->ImportStatistics != NULL)
+       {
+           FdwImportStatsResult    res;
+
+           /*
+            * Fetching pre-existing remote stats is not guaranteed to
be a quick
+            * operation.
+            *
+            * XXX: Should this be it's own fetch type? If not, then
there might be
+            * confusion when a long stats-fetch fails, followed by a
regular analyze,
+            * which would make it look like the table was analyzed twice.
+            */
+           pgstat_progress_start_command(PROGRESS_COMMAND_ANALYZE,
+                                         RelationGetRelid(onerel));
+
+           res = fdwroutine->ImportStatistics(onerel);
+
+           pgstat_progress_end_command();
+
+           /*
+            * If we were able to import statistics, then there is no
need to collect
+            * samples for local analysis.
+            */
+           switch(res)
+           {
+               case FDW_IMPORT_STATS_OK:
+                   relation_close(onerel, ShareUpdateExclusiveLock);
+                   return;
+               case FDW_IMPORT_STATS_DISABLED:
+                   break;
+               case FDW_IMPORT_STATS_NOTFOUND:
+                   ereport(INFO,
+                           errmsg("Found no remote statistics for \"%s\"",
+                                  RelationGetRelationName(onerel)));
+                   break;
+               case FDW_IMPORT_STATS_FAILED:
+               default:
+                   ereport(INFO,
+                           errmsg("Fetching remote statistics from
\"%s\" failed",
+                                  RelationGetRelationName(onerel)));
+           }
+       }

Returning in the FDW_IMPORT_STATS_OK case isn't 100% correct; if the
foreign table is an inheritance parent, we would fail to do inherited
stats.

IIUC, the FDW needs to do two things within the ImportStatistics
callback function: check the importability, and if ok, do the work. I
think that would make the API complicated. To avoid that, how about
1) splitting the callback function into the two functions shown below
and then 2) rewriting the above to something like the attached? The
attached addresses the returning issue mentioned above as well.

bool
StatisticsAreImportable(Relation relation)

Checks the importability, and if ok, returns true, in which case the
following callback function is called. In the postgres_fdw case, we
could implement this to check if the fetch_stats flag for the foreign
table is set to true, and if so, return true.

void
ImportStatistics(Relation relation, List *va_cols, int elevel)

Imports the stats for the foreign table from the foreign server. As
mentioned in previous emails, I don't think it's a good idea to fall
back to the normal processing when the attempt to import the stats
fails, in which case I think we should just throw an error (or
warning) so that the user can re-try to import the stats after fixing
the foreign side in some way. So I re-defined this as a void
function. Note that this re-definition removes the concern mentioned
in the comment starting with "XXX:". In the postgres_fdw case, as
mentioned in a previous email, I think it would be good to implement
this so that it checks whether the remote table is a view or not when
importing the relation stats from the remote server, and if so, just
throws an error (or warning), making the user reset the fetch_stats
flag.

I added two arguments to the callback function: va_cols, for
supporting the column list in the ANALYZE command, and elevel, for
proper logging. I'm not sure if VaccumParams should be added as well.

That's it for now. I'll continue to review the patch.

Best regards,
Etsuro Fujita

Attachments:

FDW-API-for-importing-stats.patchapplication/octet-stream; name=FDW-API-for-importing-stats.patchDownload
diff --git a/src/backend/commands/analyze.c b/src/backend/commands/analyze.c
index 5e2a7a8234e..ec0ccf15362 100644
--- a/src/backend/commands/analyze.c
+++ b/src/backend/commands/analyze.c
@@ -113,6 +113,8 @@ analyze_rel(Oid relid, RangeVar *relation,
 	int			elevel;
 	AcquireSampleRowsFunc acquirefunc = NULL;
 	BlockNumber relpages = 0;
+	FdwRoutine *fdwroutine = NULL;
+	bool		import_stats = false;
 
 	/* Select logging level */
 	if (params.options & VACOPT_VERBOSE)
@@ -195,26 +197,32 @@ analyze_rel(Oid relid, RangeVar *relation,
 	else if (onerel->rd_rel->relkind == RELKIND_FOREIGN_TABLE)
 	{
 		/*
-		 * For a foreign table, call the FDW's hook function to see whether it
-		 * supports analysis.
+		 * For a foreign table, call the FDW's hook functions to see whether
+		 * it supports statistics import or analysis.
 		 */
-		FdwRoutine *fdwroutine;
-		bool		ok = false;
-
 		fdwroutine = GetFdwRoutineForRelation(onerel, false);
 
-		if (fdwroutine->AnalyzeForeignTable != NULL)
-			ok = fdwroutine->AnalyzeForeignTable(onerel,
-												 &acquirefunc,
-												 &relpages);
-
-		if (!ok)
+		if (fdwroutine->ImportStatistics != NULL &&
+			fdwroutine->StatisticsAreImportable != NULL &&
+			fdwroutine->StatisticsAreImportable(onerel))
+			import_stats = true;
+		else
 		{
-			ereport(WARNING,
-					(errmsg("skipping \"%s\" --- cannot analyze this foreign table",
-							RelationGetRelationName(onerel))));
-			relation_close(onerel, ShareUpdateExclusiveLock);
-			return;
+			bool		ok = false;
+
+			if (fdwroutine->AnalyzeForeignTable != NULL)
+				ok = fdwroutine->AnalyzeForeignTable(onerel,
+													 &acquirefunc,
+													 &relpages);
+
+			if (!ok)
+			{
+				ereport(WARNING,
+						(errmsg("skipping \"%s\" --- cannot analyze this foreign table",
+								RelationGetRelationName(onerel))));
+				relation_close(onerel, ShareUpdateExclusiveLock);
+				return;
+			}
 		}
 	}
 	else if (onerel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
@@ -248,9 +256,18 @@ analyze_rel(Oid relid, RangeVar *relation,
 
 	/*
 	 * Do the normal non-recursive ANALYZE.  We can skip this for partitioned
-	 * tables, which don't contain any rows.
+	 * tables, which don't contain any rows.  For foreign tables, if they
+	 * support importing statistics, do that instead of the non-recursive
+	 * ANALYZE.
 	 */
-	if (onerel->rd_rel->relkind != RELKIND_PARTITIONED_TABLE)
+	if (import_stats)
+	{
+		Assert(onerel->rd_rel->relkind == RELKIND_FOREIGN_TABLE);
+		Assert(fdwroutine != NULL);
+		Assert(fdwroutine->ImportStatistics != NULL);
+		fdwroutine->ImportStatistics(onerel, va_cols, elevel);
+	}
+	else if (onerel->rd_rel->relkind != RELKIND_PARTITIONED_TABLE)
 		do_analyze_rel(onerel, params, va_cols, acquirefunc,
 					   relpages, false, in_outer_xact, elevel);
 
diff --git a/src/include/foreign/fdwapi.h b/src/include/foreign/fdwapi.h
index fcd7e7027f3..c4453f306fa 100644
--- a/src/include/foreign/fdwapi.h
+++ b/src/include/foreign/fdwapi.h
@@ -157,6 +157,12 @@ typedef bool (*AnalyzeForeignTable_function) (Relation relation,
 											  AcquireSampleRowsFunc *func,
 											  BlockNumber *totalpages);
 
+typedef bool (*StatisticsAreImportable_function) (Relation relation);
+
+typedef void (*ImportStatistics_function) (Relation relation,
+										   List *va_cols,
+										   int elevel);
+
 typedef List *(*ImportForeignSchema_function) (ImportForeignSchemaStmt *stmt,
 											   Oid serverOid);
 
@@ -255,6 +261,8 @@ typedef struct FdwRoutine
 
 	/* Support functions for ANALYZE */
 	AnalyzeForeignTable_function AnalyzeForeignTable;
+	StatisticsAreImportable_function StatisticsAreImportable;
+	ImportStatistics_function ImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	ImportForeignSchema_function ImportForeignSchema;
#19Corey Huinker
corey.huinker@gmail.com
In reply to: Chao Li (#16)
Re: Import Statistics in postgres_fdw before resorting to sampling.

A kind reminder, I don’t see my comments are addressed:

My apologies. Will get into the next rev.

#20Corey Huinker
corey.huinker@gmail.com
In reply to: Etsuro Fujita (#18)
Re: Import Statistics in postgres_fdw before resorting to sampling.

CCing Jonathan Katz and Nathan Bossart as they had been sounding boards for
me when I started designing this feature.

Returning in the FDW_IMPORT_STATS_OK case isn't 100% correct; if the
foreign table is an inheritance parent, we would fail to do inherited
stats.

Perhaps I'm not understanding completely, but I believe what we're doing
now should be ok.

The local table of type 'f' can be a member of a partition, but can't be a
partition itself, so whatever stats we get for it, we're storing them as
inh = false.

On the remote side, the table could be an inheritance parent, in which case
we ONLY want the inh=true stats, but we will still store them locally as
inh = false.

The DISTINCT ON(a.attname)....ORDER BY a.attname, s.inherited DESC part of
the query ensures that we get inh=true stats if they're there in preference
to the inh=false steps.

I will grant you that in an old-style inheritance (i.e. not formal
partitions) situation we'd probably want some weighted mix of the inherited
and non-inherited stats, but 1) very few people use old-style inheritance
anymore, 2) few of those export tables via a FDW, and 3) there's no way to
do that weighting so we should fall back to sampling anyway.

None of this takes away from your suggestions down below.

IIUC, the FDW needs to do two things within the ImportStatistics
callback function: check the importability, and if ok, do the work. I
think that would make the API complicated. To avoid that, how about
1) splitting the callback function into the two functions shown below
and then 2) rewriting the above to something like the attached? The
attached addresses the returning issue mentioned above as well.

bool
StatisticsAreImportable(Relation relation)

Checks the importability, and if ok, returns true, in which case the
following callback function is called. In the postgres_fdw case, we
could implement this to check if the fetch_stats flag for the foreign
table is set to true, and if so, return true.

+1

void
ImportStatistics(Relation relation, List *va_cols, int elevel)

Imports the stats for the foreign table from the foreign server. As
mentioned in previous emails, I don't think it's a good idea to fall
back to the normal processing when the attempt to import the stats
fails, in which case I think we should just throw an error (or
warning) so that the user can re-try to import the stats after fixing
the foreign side in some way. So I re-defined this as a void
function. Note that this re-definition removes the concern mentioned
in the comment starting with "XXX:". In the postgres_fdw case, as
mentioned in a previous email, I think it would be good to implement
this so that it checks whether the remote table is a view or not when
importing the relation stats from the remote server, and if so, just
throws an error (or warning), making the user reset the fetch_stats
flag.

I think I'm ok with this design as the decision, as it still leaves open
the fdw-specific options of how to handle initially finding no remote stats.

I can still see a situation where a local table expects the remote table to
eventually have proper statistics on it, but until that happens it will
fall back to table samples. This design decision means that either the user
lives without any statistics for a while, or alters the foreign table
options and hopefully remembers to set them back. While I understand the
desire to first implement something very simple, I think that adding the
durability that fallback allows for will be harder to implement if we don't
build it in from the start. I'm interested to hear with Nathan and/or
Jonathan have to say about that.

I added two arguments to the callback function: va_cols, for
supporting the column list in the ANALYZE command, and elevel, for
proper logging. I'm not sure if VaccumParams should be added as well.

Good catch, I forgot about that one.

Going to think some more on this before I work incorporating your

#21Corey Huinker
corey.huinker@gmail.com
In reply to: Corey Huinker (#20)
1 attachment(s)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Fri, Dec 12, 2025 at 2:45 PM Corey Huinker <corey.huinker@gmail.com>
wrote:

CCing Jonathan Katz and Nathan Bossart as they had been sounding boards
for me when I started designing this feature.

Returning in the FDW_IMPORT_STATS_OK case isn't 100% correct; if the
foreign table is an inheritance parent, we would fail to do inherited
stats.

Perhaps I'm not understanding completely, but I believe what we're doing
now should be ok.

The local table of type 'f' can be a member of a partition, but can't be a
partition itself, so whatever stats we get for it, we're storing them as
inh = false.

On the remote side, the table could be an inheritance parent, in which
case we ONLY want the inh=true stats, but we will still store them locally
as inh = false.

The DISTINCT ON(a.attname)....ORDER BY a.attname, s.inherited DESC part of
the query ensures that we get inh=true stats if they're there in preference
to the inh=false steps.

I will grant you that in an old-style inheritance (i.e. not formal
partitions) situation we'd probably want some weighted mix of the inherited
and non-inherited stats, but 1) very few people use old-style inheritance
anymore, 2) few of those export tables via a FDW, and 3) there's no way to
do that weighting so we should fall back to sampling anyway.

None of this takes away from your suggestions down below.

IIUC, the FDW needs to do two things within the ImportStatistics
callback function: check the importability, and if ok, do the work. I
think that would make the API complicated. To avoid that, how about
1) splitting the callback function into the two functions shown below
and then 2) rewriting the above to something like the attached? The
attached addresses the returning issue mentioned above as well.

bool
StatisticsAreImportable(Relation relation)

Checks the importability, and if ok, returns true, in which case the
following callback function is called. In the postgres_fdw case, we
could implement this to check if the fetch_stats flag for the foreign
table is set to true, and if so, return true.

+1

void
ImportStatistics(Relation relation, List *va_cols, int elevel)

Imports the stats for the foreign table from the foreign server. As
mentioned in previous emails, I don't think it's a good idea to fall
back to the normal processing when the attempt to import the stats
fails, in which case I think we should just throw an error (or
warning) so that the user can re-try to import the stats after fixing
the foreign side in some way. So I re-defined this as a void
function. Note that this re-definition removes the concern mentioned
in the comment starting with "XXX:". In the postgres_fdw case, as
mentioned in a previous email, I think it would be good to implement
this so that it checks whether the remote table is a view or not when
importing the relation stats from the remote server, and if so, just
throws an error (or warning), making the user reset the fetch_stats
flag.

I think I'm ok with this design as the decision, as it still leaves open
the fdw-specific options of how to handle initially finding no remote stats.

I can still see a situation where a local table expects the remote table
to eventually have proper statistics on it, but until that happens it will
fall back to table samples. This design decision means that either the user
lives without any statistics for a while, or alters the foreign table
options and hopefully remembers to set them back. While I understand the
desire to first implement something very simple, I think that adding the
durability that fallback allows for will be harder to implement if we don't
build it in from the start. I'm interested to hear with Nathan and/or
Jonathan have to say about that.

I added two arguments to the callback function: va_cols, for
supporting the column list in the ANALYZE command, and elevel, for
proper logging. I'm not sure if VaccumParams should be added as well.

Good catch, I forgot about that one.

Going to think some more on this before I work incorporating your

Heh, the word "changes" got cut off.

Anyway, here's v6 incorporating both threads of feedback.

Attachments:

v6-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchtext/x-patch; charset=US-ASCII; name=v6-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchDownload
From 571d6cb899fe0bb480c4848ea62707f357b94848 Mon Sep 17 00:00:00 2001
From: Corey Huinker <corey.huinker@gmail.com>
Date: Thu, 7 Aug 2025 23:58:38 -0400
Subject: [PATCH v6] Add remote statistics fetching to postgres_fdw.

This adds the ability to fetch and import statistics from a remote
server table table rather than fetching the data or data sample from
that table.

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will first attempt to fetch statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If the attempts to fetch statistics result in no rows returned or none
of the rows returned matching the columns in the local table, then the
ANALYZE will raise an error.

This operation will only work on remote relations that can have stored
statistics: tables, partitioned tables, and materialized views. If the
remote relation is a view then remote fetching/analyzing is just wasted
effort and the user is better off setting fetch_stats to false for that
table.

The default for fetch_stats is true at both server and table level. The
default for remote_analyze is false at both the server and table level.
In both cases, setting a value at the table level will override the
corresponding server-level setting.
---
 src/include/foreign/fdwapi.h                  |   9 +-
 src/backend/commands/analyze.c                |  44 +-
 doc/src/sgml/postgres-fdw.sgml                |  35 +-
 .../postgres_fdw/expected/postgres_fdw.out    |  52 +-
 contrib/postgres_fdw/option.c                 |  10 +
 contrib/postgres_fdw/postgres_fdw.c           | 671 ++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  46 +-
 7 files changed, 857 insertions(+), 10 deletions(-)

diff --git a/src/include/foreign/fdwapi.h b/src/include/foreign/fdwapi.h
index fcd7e7027f3..255e18584e3 100644
--- a/src/include/foreign/fdwapi.h
+++ b/src/include/foreign/fdwapi.h
@@ -19,7 +19,6 @@
 /* avoid including explain_state.h here */
 typedef struct ExplainState ExplainState;
 
-
 /*
  * Callback function signatures --- see fdwhandler.sgml for more info.
  */
@@ -157,6 +156,12 @@ typedef bool (*AnalyzeForeignTable_function) (Relation relation,
 											  AcquireSampleRowsFunc *func,
 											  BlockNumber *totalpages);
 
+typedef bool (*StatisticsAreImportable_function)  (Relation relation);
+
+typedef void (*ImportStatistics_function) (Relation relation,
+										   List *va_cols,
+										   int elevel);
+
 typedef List *(*ImportForeignSchema_function) (ImportForeignSchemaStmt *stmt,
 											   Oid serverOid);
 
@@ -255,6 +260,8 @@ typedef struct FdwRoutine
 
 	/* Support functions for ANALYZE */
 	AnalyzeForeignTable_function AnalyzeForeignTable;
+	StatisticsAreImportable_function StatisticsAreImportable;
+	ImportStatistics_function ImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	ImportForeignSchema_function ImportForeignSchema;
diff --git a/src/backend/commands/analyze.c b/src/backend/commands/analyze.c
index 5e2a7a8234e..a59e7805da9 100644
--- a/src/backend/commands/analyze.c
+++ b/src/backend/commands/analyze.c
@@ -113,6 +113,8 @@ analyze_rel(Oid relid, RangeVar *relation,
 	int			elevel;
 	AcquireSampleRowsFunc acquirefunc = NULL;
 	BlockNumber relpages = 0;
+	FdwRoutine *fdwroutine = NULL;
+	bool		import_stats = false;
 
 	/* Select logging level */
 	if (params.options & VACOPT_VERBOSE)
@@ -194,15 +196,36 @@ analyze_rel(Oid relid, RangeVar *relation,
 	}
 	else if (onerel->rd_rel->relkind == RELKIND_FOREIGN_TABLE)
 	{
+		bool	ok = false;
+
 		/*
-		 * For a foreign table, call the FDW's hook function to see whether it
-		 * supports analysis.
+		 * For a foreign table, call the FDW's hook functions to see whether
+		 * it supports statistics import or analysis.
 		 */
-		FdwRoutine *fdwroutine;
-		bool		ok = false;
 
 		fdwroutine = GetFdwRoutineForRelation(onerel, false);
 
+		if (fdwroutine->ImportStatistics != NULL &&
+			fdwroutine->StatisticsAreImportable != NULL &&
+			fdwroutine->StatisticsAreImportable(onerel))
+			import_stats = true;
+		else
+		{
+			if (fdwroutine->AnalyzeForeignTable != NULL)
+				ok = fdwroutine->AnalyzeForeignTable(onerel,
+													 &acquirefunc,
+													 &relpages);
+
+			if (!ok)
+			{
+				ereport(WARNING,
+						errmsg("skipping \"%s\" -- cannot analyze this foreign table.",
+							   RelationGetRelationName(onerel)));
+				relation_close(onerel, ShareUpdateExclusiveLock);
+				return;
+			}
+		}
+
 		if (fdwroutine->AnalyzeForeignTable != NULL)
 			ok = fdwroutine->AnalyzeForeignTable(onerel,
 												 &acquirefunc,
@@ -248,9 +271,18 @@ analyze_rel(Oid relid, RangeVar *relation,
 
 	/*
 	 * Do the normal non-recursive ANALYZE.  We can skip this for partitioned
-	 * tables, which don't contain any rows.
+	 * tables, which don't contain any rows.  For foreign tables, if they
+	 * support importing statistics, do that instead of the non-recursive
+	 * ANALYZE.
 	 */
-	if (onerel->rd_rel->relkind != RELKIND_PARTITIONED_TABLE)
+	if (import_stats)
+	{
+		Assert(onerel->rd_rel->relkind == RELKIND_FOREIGN_TABLE);
+		Assert(fdwroutine != NULL);
+		Assert(fdwroutine->ImportStatistics != NULL);
+		fdwroutine->ImportStatistics(onerel, va_cols, elevel);
+	}
+	else if (onerel->rd_rel->relkind != RELKIND_PARTITIONED_TABLE)
 		do_analyze_rel(onerel, params, va_cols, acquirefunc,
 					   relpages, false, in_outer_xact, elevel);
 
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index 9b032fbf675..5439ce9eb09 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -332,7 +332,7 @@ OPTIONS (ADD password_required 'false');
    </para>
 
    <para>
-    The following option controls how such an <command>ANALYZE</command>
+    The following options control how such an <command>ANALYZE</command>
     operation behaves:
    </para>
 
@@ -364,6 +364,39 @@ OPTIONS (ADD password_required 'false');
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>fetch_stats</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines if <command>ANALYZE</command> on a foreign table
+       will first attempt to fetch and import the existing relation and
+       attribute statistics from the remote table, and only attempt regular
+       data sampling if no statistics were available. This option is only
+       useful if the remote relation is one that can have regular statistics
+       (tables and materialized views).
+       The default is <literal>true</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><literal>remote_analyze</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines whether an <command>ANALYZE</command> on a foreign
+       table will attempt to <command>ANALYZE</command> the remote table if
+       the first attempt to fetch remote statistics fails, and will then
+       make a second and final attempt to fetch remote statistics. This option
+       is only meaningful if the foreign table has
+       <literal>fetch_stats</literal> enabled at either the server or table
+       level.
+       The default is <literal>false</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
    </variablelist>
 
   </sect3>
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 48e3185b227..cac1a73adf0 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -252,6 +252,7 @@ SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should work again
 -- Now we should be able to run ANALYZE.
 -- To exercise multiple code paths, we use local stats on ft1
 -- and remote-estimate mode on ft2.
+ALTER SERVER loopback OPTIONS (ADD fetch_stats 'false');
 ANALYZE ft1;
 ALTER FOREIGN TABLE ft2 OPTIONS (use_remote_estimate 'true');
 -- ===================================================================
@@ -4551,7 +4552,8 @@ REINDEX TABLE reind_fdw_parent; -- ok
 REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -11462,6 +11464,11 @@ CREATE FOREIGN TABLE async_p2 PARTITION OF async_pt FOR VALUES FROM (2000) TO (3
   SERVER loopback2 OPTIONS (table_name 'base_tbl2');
 INSERT INTO async_p1 SELECT 1000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 INSERT INTO async_p2 SELECT 2000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p2
+ANALYZE async_pt;
+ERROR:  Failed to import statistics from remote table public.base_tbl2, no statistics found.
+-- Turn off fetch_stats at the table level.
+ALTER FOREIGN TABLE async_p2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 -- simple queries
 CREATE TABLE result_tbl (a int, b int, c text);
@@ -11568,6 +11575,11 @@ CREATE TABLE base_tbl3 (a int, b int, c text);
 CREATE FOREIGN TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4000)
   SERVER loopback2 OPTIONS (table_name 'base_tbl3');
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p3/loopback2
+ANALYZE async_pt;
+ERROR:  Failed to import statistics from remote table public.base_tbl3, no statistics found.
+-- Turn off fetch_stats at the server level.
+ALTER SERVER loopback2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
@@ -12665,6 +12677,44 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 -- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table',
+                fetch_stats 'true',
+                remote_analyze 'true');
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+ tablename | num_stats 
+-----------+-----------
+(0 rows)
+
+ANALYZE remote_analyze_ftable;
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+       tablename       | num_stats 
+-----------------------+-----------
+ remote_analyze_ftable |         3
+ remote_analyze_table  |         3
+(2 rows)
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+-- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
 -- Disable debug_discard_caches in order to manage remote connections
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index 04788b7e8b3..5030279a8a7 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -120,6 +120,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 			strcmp(def->defname, "async_capable") == 0 ||
 			strcmp(def->defname, "parallel_commit") == 0 ||
 			strcmp(def->defname, "parallel_abort") == 0 ||
+			strcmp(def->defname, "fetch_stats") == 0 ||
+			strcmp(def->defname, "remote_analyze") == 0 ||
 			strcmp(def->defname, "keep_connections") == 0)
 		{
 			/* these accept only boolean values */
@@ -278,6 +280,14 @@ InitPgFdwOptions(void)
 		{"use_scram_passthrough", ForeignServerRelationId, false},
 		{"use_scram_passthrough", UserMappingRelationId, false},
 
+		/* fetch_stats is available on both server and table */
+		{"fetch_stats", ForeignServerRelationId, false},
+		{"fetch_stats", ForeignTableRelationId, false},
+
+		/* remote_analyze is available on both server and table */
+		{"remote_analyze", ForeignServerRelationId, false},
+		{"remote_analyze", ForeignTableRelationId, false},
+
 		/*
 		 * sslcert and sslkey are in fact libpq options, but we repeat them
 		 * here to allow them to appear in both foreign server context (when
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 5e178c21b39..ede811a212a 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -22,6 +22,8 @@
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "executor/execAsync.h"
+#include "executor/spi.h"
+#include "fmgr.h"
 #include "foreign/fdwapi.h"
 #include "funcapi.h"
 #include "miscadmin.h"
@@ -317,6 +319,13 @@ typedef struct
 	List	   *already_used;	/* expressions already dealt with */
 } ec_member_foreign_arg;
 
+/* Result sets that are returned from a foreign statistics scan */
+typedef struct
+{
+	PGresult	*rel;
+	PGresult	*att;
+} RemoteStatsResults;
+
 /*
  * SQL functions
  */
@@ -402,6 +411,10 @@ static void postgresExecForeignTruncate(List *rels,
 static bool postgresAnalyzeForeignTable(Relation relation,
 										AcquireSampleRowsFunc *func,
 										BlockNumber *totalpages);
+static bool postgresStatisticsAreImportable(Relation relation);
+static void postgresImportStatistics(Relation relation,
+									 List *va_cols,
+									 int elevel);
 static List *postgresImportForeignSchema(ImportForeignSchemaStmt *stmt,
 										 Oid serverOid);
 static void postgresGetForeignJoinPaths(PlannerInfo *root,
@@ -546,6 +559,115 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
 
+/*
+ * Static queries for querying remote statistics.
+ */
+
+
+/* relallfrozen introduced in v18 */
+static const char *relstats_query_18 =
+	"SELECT c.relkind, c.relpages, c.reltuples, c.relallvisible, c.relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* trust reltuples = 0 as of v14 */
+static const char *relstats_query_14 =
+	"SELECT c.relkind, c.relpages, c.reltuples, c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/*
+ * Before v14, a reltuples value of 0 was ambiguous: it could either mean
+ * the relation is empty, or it could mean that it hadn't yet been
+ * vacuumed or analyzed.  (Newer versions use -1 for the latter case.)
+ * This ambiguity allegedly can cause the planner to choose inefficient
+ * plans after restoring to v18 or newer.  To deal with this, let's just
+ * set reltuples to -1 in that case.
+ */
+static const char *relstats_query_default =
+	"SELECT c.relkind, c.relpages, "
+	"CASE c.reltuples WHEN 0 THEN -1 ELSE c.reltuples END AS reltuples, "
+	"c.relallvisible, NULL AS relallfrozen "
+	"FROM pg_catalog.pg_class AS c "
+	"JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+	"WHERE n.nspname = $1 AND c.relname = $2";
+
+/* All static relstats queries have the same column order */
+enum RelStatsColumns {
+	RELSTATS_RELKIND = 0,
+	RELSTATS_RELPAGES,
+	RELSTATS_RELTUPLES,
+	RELSTATS_RELALLVISIBLE,
+	RELSTATS_RELALLFROZEN,
+	RELSTATS_NUM_FIELDS
+};
+
+/* range stats introduced in v17 */
+static const char *attstats_query_17 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"s.range_length_histogram, s.range_empty_frac, s.range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* elements stats introduced in 9.2 */
+static const char *attstats_query_9_2 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* inherited introduced in 9.0 */
+static const char *attstats_query_9_0 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname, s.inherited DESC";
+
+static const char *attstats_query_default =
+	"SELECT s.attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"ORDER BY s.attname";
+
+/* All static attstats queries have the same column order */
+enum AttStatsColumns {
+	ATTSTATS_ATTNAME = 0,
+	ATTSTATS_NULL_FRAC,
+	ATTSTATS_AVG_WIDTH,
+	ATTSTATS_N_DISTINCT,
+	ATTSTATS_MOST_COMMON_VALS,
+	ATTSTATS_MOST_COMMON_FREQS,
+	ATTSTATS_HISTOGRAM_BOUNDS,
+	ATTSTATS_CORRELATION,
+	ATTSTATS_MOST_COMMON_ELEMS,
+	ATTSTATS_MOST_COMMON_ELEM_FREQS,
+	ATTSTATS_ELEM_COUNT_HISTOGRAM,
+	ATTSTATS_RANGE_LENGTH_HISTOGRAM,
+	ATTSTATS_RANGE_EMPTY_FRAC,
+	ATTSTATS_RANGE_BOUNDS_HISTOGRAM,
+	ATTSTATS_NUM_FIELDS
+};
 
 /*
  * Foreign-data wrapper handler function: return a struct with pointers
@@ -595,6 +717,8 @@ postgres_fdw_handler(PG_FUNCTION_ARGS)
 
 	/* Support functions for ANALYZE */
 	routine->AnalyzeForeignTable = postgresAnalyzeForeignTable;
+	routine->StatisticsAreImportable = postgresStatisticsAreImportable;
+	routine->ImportStatistics = postgresImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	routine->ImportForeignSchema = postgresImportForeignSchema;
@@ -4935,6 +5059,553 @@ postgresAnalyzeForeignTable(Relation relation,
 	return true;
 }
 
+/*
+ * Process optional argument.
+ *
+ * Cannot be the first argument in the SQL function call.
+ *
+ * It is safe to presume that argname and argtype are quote-safe.
+ *
+ * Argument values can potentially be quite large, so free the quoted string
+ * after use.
+ */
+static void
+append_optional(StringInfo str, PGresult *res, int row, int field,
+				const char *argname, const char *argtype)
+{
+	if (!PQgetisnull(res, row, field))
+	{
+		/* Argument values can be quite large, so free after use */
+		char *argval_l = quote_literal_cstr(PQgetvalue(res, row, field));
+
+		appendStringInfo(str, ",\n\t'%s', %s::%s", argname, argval_l, argtype);
+
+		pfree(argval_l);
+	}
+}
+
+
+/*
+ * Generate a pg_restore_relation_stats command.
+ */
+static char *
+restore_relation_stats_sql(PGresult *res, const char *schemaname,
+						   const char *relname, const int server_version_num)
+{
+	StringInfoData	sql;
+
+	char	   *schemaname_l = quote_literal_cstr(schemaname);
+	char	   *relname_l = quote_literal_cstr(relname);
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_relation_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s",
+					 server_version_num, schemaname_l, relname_l);
+
+	pfree(schemaname_l);
+	pfree(relname_l);
+
+	append_optional(&sql, res, 0, RELSTATS_RELPAGES, "relpages", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELTUPLES, "reltuples", "real");
+	append_optional(&sql, res, 0, RELSTATS_RELALLVISIBLE, "relallvisible", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELALLFROZEN, "relallfrozen", "integer");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * Generate a pg_restore_attribute_stats command.
+ */
+static char *
+restore_attribute_stats_sql(PGresult *res, int row,
+							const char *schemaname, const char *relname,
+							const AttrNumber attnum, const int server_version_num)
+{
+	StringInfoData	sql;
+
+	char	   *schemaname_l = quote_literal_cstr(schemaname);
+	char	   *relname_l = quote_literal_cstr(relname);
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_attribute_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s,\n"
+					 "\t'attnum', %d::smallint,\n"
+					 "\t'inherited', false::boolean",
+					 server_version_num, schemaname_l, relname_l, attnum);
+
+	pfree(schemaname_l);
+	pfree(relname_l);
+
+	append_optional(&sql, res, row, ATTSTATS_NULL_FRAC, "null_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_AVG_WIDTH, "avg_width", "integer");
+	append_optional(&sql, res, row, ATTSTATS_N_DISTINCT, "n_distinct", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_VALS, "most_common_vals", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_FREQS, "most_common_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_HISTOGRAM_BOUNDS, "histogram_bounds", "text");
+	append_optional(&sql, res, row, ATTSTATS_CORRELATION, "correlation", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEMS, "most_common_elems", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEM_FREQS, "most_common_elem_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_ELEM_COUNT_HISTOGRAM, "elem_count_histogram", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_LENGTH_HISTOGRAM, "range_length_histogram", "text");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_EMPTY_FRAC, "range_empty_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_BOUNDS_HISTOGRAM, "range_bounds_histogram", "text");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * Test if an attribute name is in the list.
+ *
+ * An empty list means that all attribute names are in the list.
+ */
+static bool
+attname_in_list(const char *attname, List *va_cols)
+{
+	ListCell   *le;
+
+	if (va_cols == NIL)
+		return true;
+
+	foreach(le, va_cols)
+	{
+		char	   *col = strVal(lfirst(le));
+
+		if (strcmp(attname, col) == 0)
+			return true;
+	}
+	return false;
+}
+
+static void
+import_fetched_statistics(Relation relation, int server_version_num,
+						  const char *schemaname, const char *relname,
+						  List *va_cols, RemoteStatsResults *remstats)
+{
+	TupleDesc	tupdesc = RelationGetDescr(relation);
+	int			spirc;
+	char	   *relimport_sql;
+
+	SPI_connect();
+
+	/*
+	 * Walk all local table attributes looking for name matches in the result
+	 * set and perform a pg_restore_attribute_stats() on each match.
+	 *
+	 * XXX: the result set is sorted by attname, so perhaps we could do a binary
+	 * search of the result set. Alternately we could collect the local attributes
+	 * in a list and sort that by remote name, which would allow us to iterate via
+	 * a merge.
+	 */
+	for (int i = 0; i < tupdesc->natts; i++)
+	{
+		char	   *attname;
+		char	   *remote_colname;
+		List	   *fc_options;
+		ListCell   *fc_lc;
+		AttrNumber	attnum;
+		bool		match_found = false;
+
+		/* Ignore dropped columns. */
+		if (TupleDescAttr(tupdesc, i)->attisdropped)
+			continue;
+
+		/* Ignore generated columns. */
+		if (TupleDescAttr(tupdesc, i)->attgenerated)
+			continue;
+
+		attname = NameStr(TupleDescAttr(tupdesc, i)->attname);
+
+		/* If a list is specified, exclude any attnames not in it. */
+		if (!attname_in_list(attname, va_cols))
+			continue;
+
+		attnum = TupleDescAttr(tupdesc, i)->attnum;
+
+		/* If column_name is not specified, go with attname. */
+		remote_colname = attname;
+		fc_options = GetForeignColumnOptions(RelationGetRelid(relation), i + 1);
+
+		foreach(fc_lc, fc_options)
+		{
+			DefElem    *def = (DefElem *) lfirst(fc_lc);
+
+			if (strcmp(def->defname, "column_name") == 0)
+			{
+				remote_colname = defGetString(def);
+				break;
+			}
+		}
+
+		for (int j = 0; j < PQntuples(remstats->att); j++)
+		{
+			char	   *attimport_sql;
+			PGresult   *res = remstats->att;
+
+			/* Skip results where we don't have no attribute name to compare */
+			if (PQgetisnull(res, j, ATTSTATS_ATTNAME))
+				continue;
+
+			/* Keep skipping name non-matches */
+			if (strcmp(PQgetvalue(res, j, ATTSTATS_ATTNAME), remote_colname) != 0)
+				continue;
+
+			match_found = true;
+			attimport_sql = restore_attribute_stats_sql(res, j, schemaname,
+														relname, attnum, server_version_num);
+
+			spirc = SPI_execute(attimport_sql, false, 1);
+			pfree(attimport_sql);
+
+			/*
+			 * It takes a lot to make a restore command fail outright, so any actual
+			 * failure is a sign that the statistics are seriously malformed, and
+			 * we should give up on importing stats for this table.
+			 */
+			if (spirc != SPI_OK_SELECT)
+				ereport(ERROR,
+						errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+						errmsg("Attribute statistics import failed %s", attimport_sql));
+		}
+
+		if (!match_found)
+			ereport(ERROR,
+					errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+					errmsg("Attribute statistics were found for %s but no attriubte names matched.",
+						   quote_qualified_identifier(schemaname, relname)));
+	}
+
+	relimport_sql = restore_relation_stats_sql(remstats->rel, schemaname, relname,
+											   server_version_num);
+
+	spirc = SPI_execute(relimport_sql, false, 1);
+
+	/*
+	 * It takes a lot to make a restore command fail outright, so any actual
+	 * failure is a sign that the statistics are seriously malformed, and
+	 * we should give up on importing stats for this table.
+	 */
+	if (spirc != SPI_OK_SELECT)
+		ereport(ERROR,
+				errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+				errmsg("Relation statistics import failed: %s.", relimport_sql));
+
+	pfree(relimport_sql);
+	SPI_finish();
+}
+
+/*
+ * Analyze a remote table.
+ */
+static void
+analyze_remote_table(PGconn *conn, const char *remote_schemaname,
+					 const char *remote_relname)
+{
+	StringInfoData	buf;
+	PGresult	   *res;
+
+	initStringInfo(&buf);
+
+	appendStringInfo(&buf, "ANALYZE %s",
+					 quote_qualified_identifier(remote_schemaname, remote_relname));
+
+	res = pgfdw_exec_query(conn, buf.data, NULL);
+
+	if (res == NULL ||
+		PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, conn, buf.data);
+
+	PQclear(res);
+	pfree(buf.data);
+}
+
+/*
+ * Attempt to fetch remote stats.
+ *
+ * Attribute and relation queries have the same parameters.
+ */
+static PGresult *
+fetch_remote_stats(PGconn *conn, const char *sql, const char **params,
+				   const int *formats)
+{
+	if (!PQsendQueryParams(conn, sql, 2, NULL, params, NULL,
+						   formats, 0))
+		pgfdw_report_error(NULL, conn, sql);
+
+	return pgfdw_get_result(conn);
+}
+
+/*
+ * Attempt to fetch remote relations stats.
+ * Verify that the result is of the proper shape.
+ */
+static PGresult *
+fetch_relstats(PGconn *conn, const char *sql, const char **params,
+			   const int *formats)
+{
+	PGresult   *res = fetch_remote_stats(conn, sql, params, formats);
+
+	if (res == NULL
+		|| PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQntuples(res) != 1
+		|| PQnfields(res) != RELSTATS_NUM_FIELDS
+		|| PQgetisnull(res, 0, RELSTATS_RELKIND))
+		pgfdw_report_error(res, conn, sql);
+
+	return res;
+}
+
+/*
+ * Attempt to fetch remote attribute stats.
+ * Verify that the result is of the proper shape.
+ * Note that we do not verify the row count.
+ */
+static PGresult *
+fetch_attstats(PGconn *conn, const char *sql, const char **params,
+			   const int *formats)
+{
+	PGresult   *res = fetch_remote_stats(conn, sql, params, formats);
+
+	if (res == NULL
+		|| PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQnfields(res) != ATTSTATS_NUM_FIELDS)
+		pgfdw_report_error(res, conn, sql);
+
+	return res;
+}
+
+/*
+ * Attempt to fetch statistics from a remote server.
+ */
+static void
+fetch_remote_statistics(PGconn *conn, int server_version_num,
+						const char *remote_schemaname, const char *remote_relname,
+						bool remote_analyze, RemoteStatsResults *remstats)
+{
+	const char *sql_params[2] = { remote_schemaname, remote_relname };
+	int			sql_param_formats[2] = {0, 0};
+
+	const char	   *relation_sql;
+	const char	   *attribute_sql;
+
+	char	relkind;
+
+	if (server_version_num >= 180000)
+		relation_sql = relstats_query_18;
+	else if (server_version_num >= 140000)
+		relation_sql = relstats_query_14;
+	else
+		relation_sql = relstats_query_default;
+
+	if (server_version_num >= 170000)
+		attribute_sql = attstats_query_17;
+	else if (server_version_num >= 90200)
+		attribute_sql = attstats_query_9_2;
+	else if (server_version_num >= 90000)
+		attribute_sql = attstats_query_9_0;
+	else
+		attribute_sql = attstats_query_default;
+
+	remstats->rel = fetch_relstats(conn, relation_sql,
+								   sql_params, sql_param_formats);
+
+	/*
+	 * Verify that the remote table is the sort that can have meaningful stats
+	 * in pg_stats.
+	 *
+	 * Note that while relations of kinds RELKIND_INDEX and
+	 * RELKIND_PARTITIONED_INDEX can have rows in pg_stats, they obviously can't
+	 * support a foreign table.
+	 */
+	relkind = *PQgetvalue(remstats->rel, 0, RELSTATS_RELKIND);
+
+	switch(relkind)
+	{
+		case RELKIND_RELATION:
+		case RELKIND_PARTITIONED_TABLE:
+		case RELKIND_FOREIGN_TABLE:
+		case RELKIND_MATVIEW:
+			break;
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("Remote table %s does not support statistics.",
+						   quote_qualified_identifier(remote_schemaname, remote_relname)));
+			return;
+	}
+
+	/* See if it actually has any attribute stats. */
+	remstats->att = fetch_attstats(conn, attribute_sql,
+								   sql_params, sql_param_formats);
+
+	/* If we got attribute statistics results, then we are done with fetching. */
+	if (PQntuples(remstats->att) > 0)
+		return;
+
+	/*
+	 * Clear off any existing fetched statistics, if any. If the analyze works
+	 * then they we want to fetch the new ones.
+	 */
+	PQclear(remstats->att);
+	PQclear(remstats->rel);
+	remstats->att = NULL;
+	remstats->rel = NULL;
+
+	/*
+	 * If remote_analyze is enabled, then we will try to analyze the table and
+	 * then try again.
+	 */
+	if (remote_analyze)
+		analyze_remote_table(conn, remote_schemaname, remote_relname);
+	else
+		ereport(ERROR,
+				errcode(ERRCODE_NO_DATA_FOUND),
+				errmsg("Failed to import statistics from remote table %s, "
+					   "no statistics found.",
+					   quote_qualified_identifier(remote_schemaname, remote_relname)));
+
+	/*
+	 * Remote ANALYZE complete, so re-fetch attribute stats query.
+	 */
+	remstats->att = fetch_attstats(conn, attribute_sql,
+								   sql_params, sql_param_formats);
+
+	/* Getting nothing on the second try is a failure */
+	if (PQntuples(remstats->att) == 0)
+		pgfdw_report_error(remstats->att, conn, attribute_sql);
+
+	/* Re-fetch basic relation stats, as they have been updated. */
+	remstats->rel = fetch_relstats(conn, relation_sql,
+								   sql_params, sql_param_formats);
+}
+
+static bool
+postgresStatisticsAreImportable(Relation relation)
+{
+	ForeignTable   *table;
+	ForeignServer  *server;
+	ListCell	   *lc;
+	bool			fetch_stats = true;
+
+	table = GetForeignTable(RelationGetRelid(relation));
+	server = GetForeignServer(table->serverid);
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+		{
+			fetch_stats = defGetBoolean(def);
+			break;
+		}
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+		{
+			fetch_stats = defGetBoolean(def);
+			break;
+		}
+	}
+
+	return fetch_stats;
+}
+
+/*
+ * postgresImportStatistics
+ * 		Attempt to fetch remote statistics and apply those instead of analyzing.
+ */
+static void
+postgresImportStatistics(Relation relation, List *va_cols, int elevel)
+{
+
+	ForeignTable   *table;
+	ForeignServer  *server;
+	UserMapping	   *user;
+	PGconn		   *conn;
+	ListCell	   *lc;
+	bool			remote_analyze = false;
+	int				server_version_num = 0;
+	const char	   *schemaname = NULL;
+	const char	   *relname = NULL;
+	const char	   *remote_schemaname = NULL;
+	const char	   *remote_relname = NULL;
+
+	RemoteStatsResults	remstats = { .rel = NULL, .att = NULL };
+
+	table = GetForeignTable(RelationGetRelid(relation));
+	server = GetForeignServer(table->serverid);
+	user = GetUserMapping(GetUserId(), table->serverid);
+	conn = GetConnection(user, false, NULL);
+	server_version_num = PQserverVersion(conn);
+	schemaname = get_namespace_name(RelationGetNamespace(relation));
+	relname = RelationGetRelationName(relation);
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "schema_name") == 0)
+			remote_schemaname = defGetString(def);
+		else if (strcmp(def->defname, "table_name") == 0)
+			remote_relname = defGetString(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	/*
+	 * Assume the relation/schema names are the same as the local name unless
+	 * the options tell us otherwise.
+	 */
+	if (remote_schemaname == NULL)
+		remote_schemaname = schemaname;
+	if (remote_relname == NULL)
+		remote_relname = relname;
+
+	fetch_remote_statistics(conn, server_version_num,
+							remote_schemaname, remote_relname,
+							remote_analyze, &remstats);
+
+	ReleaseConnection(conn);
+
+	Assert(remstats.rel != NULL);
+	Assert(remstats.att != NULL);
+	import_fetched_statistics(relation, server_version_num,
+							  schemaname, relname,
+							  va_cols, &remstats);
+
+	PQclear(remstats.att);
+	PQclear(remstats.rel);
+}
+
+
 /*
  * postgresGetAnalyzeInfoForForeignTable
  *		Count tuples in foreign table (just get pg_class.reltuples).
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 9a8f9e28135..7a1787d7d79 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -241,6 +241,7 @@ SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should work again
 -- Now we should be able to run ANALYZE.
 -- To exercise multiple code paths, we use local stats on ft1
 -- and remote-estimate mode on ft2.
+ALTER SERVER loopback OPTIONS (ADD fetch_stats 'false');
 ANALYZE ft1;
 ALTER FOREIGN TABLE ft2 OPTIONS (use_remote_estimate 'true');
 
@@ -1278,7 +1279,8 @@ REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -3895,6 +3897,10 @@ CREATE FOREIGN TABLE async_p2 PARTITION OF async_pt FOR VALUES FROM (2000) TO (3
   SERVER loopback2 OPTIONS (table_name 'base_tbl2');
 INSERT INTO async_p1 SELECT 1000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 INSERT INTO async_p2 SELECT 2000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p2
+ANALYZE async_pt;
+-- Turn off fetch_stats at the table level.
+ALTER FOREIGN TABLE async_p2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 
 -- simple queries
@@ -3932,6 +3938,10 @@ CREATE TABLE base_tbl3 (a int, b int, c text);
 CREATE FOREIGN TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4000)
   SERVER loopback2 OPTIONS (table_name 'base_tbl3');
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p3/loopback2
+ANALYZE async_pt;
+-- Turn off fetch_stats at the server level.
+ALTER SERVER loopback2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 
 EXPLAIN (VERBOSE, COSTS OFF)
@@ -4379,6 +4389,40 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 
+-- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table',
+                fetch_stats 'true',
+                remote_analyze 'true');
+
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+ANALYZE remote_analyze_ftable;
+
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================

base-commit: 315342ffedf6b81f629c42e87bfaedbcc7211646
-- 
2.52.0

#22Etsuro Fujita
etsuro.fujita@gmail.com
In reply to: Corey Huinker (#21)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Sun, Dec 14, 2025 at 4:12 AM Corey Huinker <corey.huinker@gmail.com> wrote:

On Fri, Dec 12, 2025 at 2:45 PM Corey Huinker <corey.huinker@gmail.com> wrote:

CCing Jonathan Katz and Nathan Bossart as they had been sounding boards for me when I started designing this feature.

Returning in the FDW_IMPORT_STATS_OK case isn't 100% correct; if the
foreign table is an inheritance parent, we would fail to do inherited
stats.

Perhaps I'm not understanding completely, but I believe what we're doing now should be ok.

The local table of type 'f' can be a member of a partition, but can't be a partition itself, so whatever stats we get for it, we're storing them as inh = false.

On the remote side, the table could be an inheritance parent, in which case we ONLY want the inh=true stats, but we will still store them locally as inh = false.

The DISTINCT ON(a.attname)....ORDER BY a.attname, s.inherited DESC part of the query ensures that we get inh=true stats if they're there in preference to the inh=false steps.

I will grant you that in an old-style inheritance (i.e. not formal partitions) situation we'd probably want some weighted mix of the inherited and non-inherited stats, but 1) very few people use old-style inheritance anymore, 2) few of those export tables via a FDW, and 3) there's no way to do that weighting so we should fall back to sampling anyway.

Ah, I mean the case where the foreign table is an inheritance parent
on the *local* side. In that case, the return would cause us to skip
the recursive ANALYZE (i.e., do_analyze_rel() with inh=true), leading
to no inherited stats. I agree that the case is minor, but I don't
think that that's acceptable.

void
ImportStatistics(Relation relation, List *va_cols, int elevel)

Imports the stats for the foreign table from the foreign server. As
mentioned in previous emails, I don't think it's a good idea to fall
back to the normal processing when the attempt to import the stats
fails, in which case I think we should just throw an error (or
warning) so that the user can re-try to import the stats after fixing
the foreign side in some way. So I re-defined this as a void
function. Note that this re-definition removes the concern mentioned
in the comment starting with "XXX:". In the postgres_fdw case, as
mentioned in a previous email, I think it would be good to implement
this so that it checks whether the remote table is a view or not when
importing the relation stats from the remote server, and if so, just
throws an error (or warning), making the user reset the fetch_stats
flag.

I think I'm ok with this design as the decision, as it still leaves open the fdw-specific options of how to handle initially finding no remote stats.

I can still see a situation where a local table expects the remote table to eventually have proper statistics on it, but until that happens it will fall back to table samples. This design decision means that either the user lives without any statistics for a while, or alters the foreign table options and hopefully remembers to set them back. While I understand the desire to first implement something very simple, I think that adding the durability that fallback allows for will be harder to implement if we don't build it in from the start. I'm interested to hear with Nathan and/or Jonathan have to say about that.

My concern about the fall-back behavior is that it reduces flexibility
in some cases, as mentioned upthread. Maybe that could be addressed
by making the behavior an option, but my another (bigger) concern is
that considering that it's the user's responsibility to make remote
stats up-to-date when he/she uses this feature, the "no remote stats
found" result would be his/her fault; it might not be worth
complicating the code for something like that.

Anyway, I too would like to hear the opinions of them (or anyone else).

Anyway, here's v6 incorporating both threads of feedback.

Thanks for updating the patch! I will review the postgres_fdw changes next.

Best regards,
Etsuro Fujita

#23Corey Huinker
corey.huinker@gmail.com
In reply to: Etsuro Fujita (#22)
Re: Import Statistics in postgres_fdw before resorting to sampling.

Ah, I mean the case where the foreign table is an inheritance parent
on the *local* side. In that case, the return would cause us to skip
the recursive ANALYZE (i.e., do_analyze_rel() with inh=true), leading
to no inherited stats. I agree that the case is minor, but I don't
think that that's acceptable.

When such a corner case occurs (stats import configured to true, but table
is an inheritance parent), should we raise an error, or raise a warning and
return false on the CanImportStats() call? I guess the answer may depend on
the feedback we get.

#24Etsuro Fujita
etsuro.fujita@gmail.com
In reply to: Corey Huinker (#23)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Mon, Dec 15, 2025 at 5:01 AM Corey Huinker <corey.huinker@gmail.com> wrote:

Ah, I mean the case where the foreign table is an inheritance parent
on the *local* side. In that case, the return would cause us to skip
the recursive ANALYZE (i.e., do_analyze_rel() with inh=true), leading
to no inherited stats. I agree that the case is minor, but I don't
think that that's acceptable.

When such a corner case occurs (stats import configured to true, but table is an inheritance parent), should we raise an error, or raise a warning and return false on the CanImportStats() call? I guess the answer may depend on the feedback we get.

As mentioned upthread, the FDW API that I proposed addresses this
issue; even in such a case it allows the FDW to import stats, instead
of doing the normal non-recursive ANALYZE, and then do the recursive
ANALYZE, for the inherited stats.

Best regards,
Etsuro Fujita

#25Etsuro Fujita
etsuro.fujita@gmail.com
In reply to: Etsuro Fujita (#22)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Mon, Dec 15, 2025 at 3:40 AM Etsuro Fujita <etsuro.fujita@gmail.com> wrote:

On Sun, Dec 14, 2025 at 4:12 AM Corey Huinker <corey.huinker@gmail.com> wrote:

Anyway, here's v6 incorporating both threads of feedback.

I will review the postgres_fdw changes next.

I spent some time reviewing the changes.

+     <term><literal>fetch_stats</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines if <command>ANALYZE</command> on a foreign table
+       will first attempt to fetch and import the existing relation and
+       attribute statistics from the remote table, and only attempt regular
+       data sampling if no statistics were available. This option is only
+       useful if the remote relation is one that can have regular statistics
+       (tables and materialized views).
+       The default is <literal>true</literal>.

This version of the patch wouldn't fall back to the normal ANALYZE
processing anymore, so this documentation should be updated as such.
Also, as I think it's the user's responsibility to ensure the existing
statistics are up-to-date, as I said before, I think we should add a
note about that here. Also, as some users wouldn't be able to ensure
it, I'm wondering if the default should be false.

+     <term><literal>remote_analyze</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines whether an <command>ANALYZE</command> on a foreign
+       table will attempt to <command>ANALYZE</command> the remote table if
+       the first attempt to fetch remote statistics fails, and will then
+       make a second and final attempt to fetch remote statistics. This option
+       is only meaningful if the foreign table has
+       <literal>fetch_stats</literal> enabled at either the server or table
+       level.

If the user wasn't able to ensure the statistics are up-to-date, I
think he/she might want to do remote ANALYZE *before* fetching the
statistics, not after trying to do so. So I think we could instead
provide this option as such. What do you think about that? Anyway,
I'd vote for leaving this for another patch, as I think it's a
nice-to-have option rather than a must-have one, as I said before.

ISTM that the code is well organized overall. Here are a few comments:

+static const char *relstats_query_18 =
+   "SELECT c.relkind, c.relpages, c.reltuples, c.relallvisible,
c.relallfrozen "
+   "FROM pg_catalog.pg_class AS c "
+   "JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace "
+   "WHERE n.nspname = $1 AND c.relname = $2";

We don't use relallvisible and relallfrozen for foreign tables (note
that do_analyze_rel() calls vac_update_relstats() with relallvisible=0
and relallfrozen=0 for them). Do we really need to retrieve (and
restore) them?

+static const char *attstats_query_17 =
+   "SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+   "s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+   "s.histogram_bounds, s.correlation, s.most_common_elems, "
+   "s.most_common_elem_freqs, s.elem_count_histogram, "
+   "s.range_length_histogram, s.range_empty_frac, s.range_bounds_histogram "
+   "FROM pg_catalog.pg_stats AS s "
+   "WHERE s.schemaname = $1 AND s.tablename = $2 "
+   "ORDER BY s.attname, s.inherited DESC";

I think we should retrieve the attribute statistics for only the
referenced columns of the remote table, not all the columns of it, to
reduce the data transfer and the cost of matching local/remote
attributes in import_fetched_statistics().

In fetch_remote_statistics()

+   if (server_version_num >= 180000)
+       relation_sql = relstats_query_18;
+   else if (server_version_num >= 140000)
+       relation_sql = relstats_query_14;
+   else
+       relation_sql = relstats_query_default;

I think that having the definition for each of relstats_query_18,
relstats_query_14 and relstats_query_default makes the code redundant
and the maintenance hard. To avoid that, how about building the
relation_sql query dynamically as done for the query to fetch all
table data from the remote server in postgresImportForeignSchema().
Same for the attribute_sql query.

That's all I have for now. I will continue to review the changes.

Best regards,
Etsuro Fujita

#26Corey Huinker
corey.huinker@gmail.com
In reply to: Etsuro Fujita (#25)
Re: Import Statistics in postgres_fdw before resorting to sampling.

This version of the patch wouldn't fall back to the normal ANALYZE
processing anymore, so this documentation should be updated as such.
Also, as I think it's the user's responsibility to ensure the existing
statistics are up-to-date, as I said before, I think we should add a
note about that here. Also, as some users wouldn't be able to ensure
it, I'm wondering if the default should be false.

I agree, if there is no fallback, then the default should be false. When I
was initially brainstorming this patch, Nathan Bossart had suggested making
it the default because 1) that would be an automatic benefit to users and
2) the cost for attempting to import stats was small in comparison to a
table stample, so it was worth the attempt. I still want users to get that
automatic benefit, but if there is no fallback to sampling then the default
only makes sense as false.

If the user wasn't able to ensure the statistics are up-to-date, I
think he/she might want to do remote ANALYZE *before* fetching the
statistics, not after trying to do so. So I think we could instead
provide this option as such. What do you think about that? Anyway,
I'd vote for leaving this for another patch, as I think it's a
nice-to-have option rather than a must-have one, as I said before.

I hadn't thought of that one. I think it has some merit, but I think we'd
want the try-after case as well. So the settings would be: "off" (never
remote analyze, default), "on" (always analyze before fetching), and
"retry" (analyze if no stats were found). This feature feeds into the same
thinking that the default setting did, which was to make this feature just
do what is usually the smart thing, and do it automatically. Building it in
pieces might be easier to get committed, but it takes away the dream of
seamless automatic improvement, and establishes defaults that can't be
changed in future versions. That dream was probably unrealistic, but I
wanted to try.

ISTM that the code is well organized overall. Here are a few comments:

Thanks!

We don't use relallvisible and relallfrozen for foreign tables (note
that do_analyze_rel() calls vac_update_relstats() with relallvisible=0
and relallfrozen=0 for them). Do we really need to retrieve (and
restore) them?

No, and as you stated, we wouldn't want to. The query was lifted verbatim
from pg_dump, with a vague hope of moving the queries to a common library
that both pg_dump and postgres_fdw could draw upon. But that no longer
makes sense, so I'll fix.

+static const char *attstats_query_17 =
+   "SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+   "s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+   "s.histogram_bounds, s.correlation, s.most_common_elems, "
+   "s.most_common_elem_freqs, s.elem_count_histogram, "
+   "s.range_length_histogram, s.range_empty_frac,
s.range_bounds_histogram "
+   "FROM pg_catalog.pg_stats AS s "
+   "WHERE s.schemaname = $1 AND s.tablename = $2 "
+   "ORDER BY s.attname, s.inherited DESC";

I think we should retrieve the attribute statistics for only the
referenced columns of the remote table, not all the columns of it, to
reduce the data transfer and the cost of matching local/remote
attributes in import_fetched_statistics().

I thought about this, and decided that we wanted to 1) avoid per-column
round trips and 2) keep the remote queries simple. It's not such a big deal
to add "AND s.attname = ANY($3)" and construct a '{att1,att2,"ATt3"}'
string, as we already do in pg_dump in a few places.

In fetch_remote_statistics()

+   if (server_version_num >= 180000)
+       relation_sql = relstats_query_18;
+   else if (server_version_num >= 140000)
+       relation_sql = relstats_query_14;
+   else
+       relation_sql = relstats_query_default;

I think that having the definition for each of relstats_query_18,
relstats_query_14 and relstats_query_default makes the code redundant
and the maintenance hard. To avoid that, how about building the
relation_sql query dynamically as done for the query to fetch all
table data from the remote server in postgresImportForeignSchema().
Same for the attribute_sql query.

It may be a moot point. If we're not fetching relallfrozen, then the 14 &
18 cases are now the same, and since the pre-14 case concerns
differentiating between analyzed and unanalyzed tables, we would just map
that to 0 IF we kept those stats, but we almost never would because an
unanalyzed remote table would not have the attribute stats necessary to
qualify as a good remote fetch. So we're down to just one static query.

That's all I have for now. I will continue to review the changes.

Much appreciated.

#27Etsuro Fujita
etsuro.fujita@gmail.com
In reply to: Corey Huinker (#26)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Sun, Jan 4, 2026 at 11:56 AM Corey Huinker <corey.huinker@gmail.com> wrote:

This version of the patch wouldn't fall back to the normal ANALYZE
processing anymore, so this documentation should be updated as such.
Also, as I think it's the user's responsibility to ensure the existing
statistics are up-to-date, as I said before, I think we should add a
note about that here. Also, as some users wouldn't be able to ensure
it, I'm wondering if the default should be false.

I agree, if there is no fallback, then the default should be false. When I was initially brainstorming this patch, Nathan Bossart had suggested making it the default because 1) that would be an automatic benefit to users and 2) the cost for attempting to import stats was small in comparison to a table stample, so it was worth the attempt. I still want users to get that automatic benefit, but if there is no fallback to sampling then the default only makes sense as false.

I think that the FDW API that I proposed could actually allow us to
fall back to sampling, by modifying StatisticsAreImportable so that it
also checks if 1) there are statistics on the remote server and 2) the
data is fresh enough, and if so, returns true; otherwise, returns
false; in the latter case we could fall back to sampling. And if we
modified it as such, I think we could change the default to true.
(Checking #2 is necessary to avoid importing stale data, which would
degrade plan quality.)

If the user wasn't able to ensure the statistics are up-to-date, I
think he/she might want to do remote ANALYZE *before* fetching the
statistics, not after trying to do so. So I think we could instead
provide this option as such. What do you think about that? Anyway,
I'd vote for leaving this for another patch, as I think it's a
nice-to-have option rather than a must-have one, as I said before.

I hadn't thought of that one. I think it has some merit, but I think we'd want the try-after case as well. So the settings would be: "off" (never remote analyze, default), "on" (always analyze before fetching), and "retry" (analyze if no stats were found). This feature feeds into the same thinking that the default setting did, which was to make this feature just do what is usually the smart thing, and do it automatically. Building it in pieces might be easier to get committed, but it takes away the dream of seamless automatic improvement, and establishes defaults that can't be changed in future versions. That dream was probably unrealistic, but I wanted to try.

Remote ANALYZE would be an interesting idea, but to get the automatic
improvement, I think we should first work on the issue I mentioned
above. So I still think we should leave this for future work.

From a different perspective, even without the automatic improvement
including remote ANALYZE, I think this feature is useful for many
users.

+static const char *attstats_query_17 =
+   "SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+   "s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+   "s.histogram_bounds, s.correlation, s.most_common_elems, "
+   "s.most_common_elem_freqs, s.elem_count_histogram, "
+   "s.range_length_histogram, s.range_empty_frac, s.range_bounds_histogram "
+   "FROM pg_catalog.pg_stats AS s "
+   "WHERE s.schemaname = $1 AND s.tablename = $2 "
+   "ORDER BY s.attname, s.inherited DESC";

I think we should retrieve the attribute statistics for only the
referenced columns of the remote table, not all the columns of it, to
reduce the data transfer and the cost of matching local/remote
attributes in import_fetched_statistics().

I thought about this, and decided that we wanted to 1) avoid per-column round trips and 2) keep the remote queries simple. It's not such a big deal to add "AND s.attname = ANY($3)" and construct a '{att1,att2,"ATt3"}' string, as we already do in pg_dump in a few places.

Yeah, I was also thinking of modifying the query as you proposed.

In fetch_remote_statistics()

+   if (server_version_num >= 180000)
+       relation_sql = relstats_query_18;
+   else if (server_version_num >= 140000)
+       relation_sql = relstats_query_14;
+   else
+       relation_sql = relstats_query_default;

I think that having the definition for each of relstats_query_18,
relstats_query_14 and relstats_query_default makes the code redundant
and the maintenance hard. To avoid that, how about building the
relation_sql query dynamically as done for the query to fetch all
table data from the remote server in postgresImportForeignSchema().
Same for the attribute_sql query.

It may be a moot point. If we're not fetching relallfrozen, then the 14 & 18 cases are now the same, and since the pre-14 case concerns differentiating between analyzed and unanalyzed tables, we would just map that to 0 IF we kept those stats, but we almost never would because an unanalyzed remote table would not have the attribute stats necessary to qualify as a good remote fetch. So we're down to just one static query.

You are right. As the relation_sql query is only used in
fetch_remote_statistics(), shouldn't the query be defined within that
function?

Best regards,
Etsuro Fujita

#28Corey Huinker
corey.huinker@gmail.com
In reply to: Etsuro Fujita (#27)
1 attachment(s)
Re: Import Statistics in postgres_fdw before resorting to sampling.

I think that the FDW API that I proposed could actually allow us to
fall back to sampling, by modifying StatisticsAreImportable so that it
also checks if 1) there are statistics on the remote server and 2) the
data is fresh enough, and if so, returns true; otherwise, returns
false; in the latter case we could fall back to sampling. And if we
modified it as such, I think we could change the default to true.
(Checking #2 is necessary to avoid importing stale data, which would
degrade plan quality.)

So while I haven't checked for "freshness" of the statistics, I have added
checks that ensure that every asked-for column in the local table with
attstattarget != 0 will be send in the column filter, and we:

1. Find remote stats for all the columns that made our list
2. Do not get any stats over the wire with no matching target column.
3. Sort the list of expected remote column names, which means the list
matching is effectively a merge, so O(N) vs O(N^2). This is done with a
name+attnum structure, but it could just as easily have been done with a
local_name+remote_name, as pg_restore_attribute_stats() will take either
attnum or attname as a parameter.

Aside from a pre-emptive ANALYZE, how would you propose we check for and/or
measure "freshness" of the remote statistics?

Remote ANALYZE would be an interesting idea, but to get the automatic
improvement, I think we should first work on the issue I mentioned
above. So I still think we should leave this for future work.

From a different perspective, even without the automatic improvement
including remote ANALYZE, I think this feature is useful for many
users.

I'm still hoping to hear from Nathan on this subject.

I think we should retrieve the attribute statistics for only the
referenced columns of the remote table, not all the columns of it, to
reduce the data transfer and the cost of matching local/remote
attributes in import_fetched_statistics().

I thought about this, and decided that we wanted to 1) avoid per-column

round trips and 2) keep the remote queries simple. It's not such a big deal
to add "AND s.attname = ANY($3)" and construct a '{att1,att2,"ATt3"}'
string, as we already do in pg_dump in a few places.

Yeah, I was also thinking of modifying the query as you proposed.

That is done in the patch attached.

It may be a moot point. If we're not fetching relallfrozen, then the 14

& 18 cases are now the same, and since the pre-14 case concerns
differentiating between analyzed and unanalyzed tables, we would just map
that to 0 IF we kept those stats, but we almost never would because an
unanalyzed remote table would not have the attribute stats necessary to
qualify as a good remote fetch. So we're down to just one static query.

You are right. As the relation_sql query is only used in
fetch_remote_statistics(), shouldn't the query be defined within that
function?

Oddly enough, I already moved it inside of fetch_remote_statistics() in the
newest patch.

I wasn't planning on posting this patch until we had heard back from
Nathan, but since I'd already been working on a few of the items you
mentioned in your last email, I thought I'd show you that work in
progress. Some issues like the documentation haven't been updated, so it's
more of a work in progress, but it does pass the tests.

Summary of key points of this v7 WIP:

* Reduced columns on relstats query, query moved inside calling function.
* Per-column filters on attstats queries, filtering on destination
attstattarget != 0.
* Verification that all filtered-for columns have stats, and that all stats
in the result set have a matching target column.
* Expected column list is now sorted by remote-side attname, allowing a
merge join of the two lists.
* No changes to the documentation, but I know they are needed.

Attachments:

v7-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchtext/x-patch; charset=US-ASCII; name=v7-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchDownload
From ac5f0e95b8796947c7c2f637f1ea82a4d918e5d9 Mon Sep 17 00:00:00 2001
From: Corey Huinker <corey.huinker@gmail.com>
Date: Thu, 7 Aug 2025 23:58:38 -0400
Subject: [PATCH v7] Add remote statistics fetching to postgres_fdw.

This adds the ability to fetch and import statistics from a remote
server table table rather than fetching the data or data sample from
that table.

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will first attempt to fetch statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If the attempts to fetch statistics result in no rows returned or none
of the rows returned matching the columns in the local table, then the
ANALYZE will raise an error.

This operation will only work on remote relations that can have stored
statistics: tables, partitioned tables, and materialized views. If the
remote relation is a view then remote fetching/analyzing is just wasted
effort and the user is better off setting fetch_stats to false for that
table.

The default for fetch_stats is true at both server and table level. The
default for remote_analyze is false at both the server and table level.
In both cases, setting a value at the table level will override the
corresponding server-level setting.
---
 src/include/foreign/fdwapi.h                  |   9 +-
 src/backend/commands/analyze.c                |  44 +-
 doc/src/sgml/postgres-fdw.sgml                |  35 +-
 .../postgres_fdw/expected/postgres_fdw.out    |  52 +-
 contrib/postgres_fdw/option.c                 |  10 +
 contrib/postgres_fdw/postgres_fdw.c           | 782 ++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  46 +-
 7 files changed, 968 insertions(+), 10 deletions(-)

diff --git a/src/include/foreign/fdwapi.h b/src/include/foreign/fdwapi.h
index 96b6f692d2a..e54d441125a 100644
--- a/src/include/foreign/fdwapi.h
+++ b/src/include/foreign/fdwapi.h
@@ -19,7 +19,6 @@
 /* avoid including explain_state.h here */
 typedef struct ExplainState ExplainState;
 
-
 /*
  * Callback function signatures --- see fdwhandler.sgml for more info.
  */
@@ -157,6 +156,12 @@ typedef bool (*AnalyzeForeignTable_function) (Relation relation,
 											  AcquireSampleRowsFunc *func,
 											  BlockNumber *totalpages);
 
+typedef bool (*StatisticsAreImportable_function)  (Relation relation);
+
+typedef void (*ImportStatistics_function) (Relation relation,
+										   List *va_cols,
+										   int elevel);
+
 typedef List *(*ImportForeignSchema_function) (ImportForeignSchemaStmt *stmt,
 											   Oid serverOid);
 
@@ -255,6 +260,8 @@ typedef struct FdwRoutine
 
 	/* Support functions for ANALYZE */
 	AnalyzeForeignTable_function AnalyzeForeignTable;
+	StatisticsAreImportable_function StatisticsAreImportable;
+	ImportStatistics_function ImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	ImportForeignSchema_function ImportForeignSchema;
diff --git a/src/backend/commands/analyze.c b/src/backend/commands/analyze.c
index a483424152c..9f633774b5f 100644
--- a/src/backend/commands/analyze.c
+++ b/src/backend/commands/analyze.c
@@ -113,6 +113,8 @@ analyze_rel(Oid relid, RangeVar *relation,
 	int			elevel;
 	AcquireSampleRowsFunc acquirefunc = NULL;
 	BlockNumber relpages = 0;
+	FdwRoutine *fdwroutine = NULL;
+	bool		import_stats = false;
 
 	/* Select logging level */
 	if (params.options & VACOPT_VERBOSE)
@@ -194,15 +196,36 @@ analyze_rel(Oid relid, RangeVar *relation,
 	}
 	else if (onerel->rd_rel->relkind == RELKIND_FOREIGN_TABLE)
 	{
+		bool	ok = false;
+
 		/*
-		 * For a foreign table, call the FDW's hook function to see whether it
-		 * supports analysis.
+		 * For a foreign table, call the FDW's hook functions to see whether
+		 * it supports statistics import or analysis.
 		 */
-		FdwRoutine *fdwroutine;
-		bool		ok = false;
 
 		fdwroutine = GetFdwRoutineForRelation(onerel, false);
 
+		if (fdwroutine->ImportStatistics != NULL &&
+			fdwroutine->StatisticsAreImportable != NULL &&
+			fdwroutine->StatisticsAreImportable(onerel))
+			import_stats = true;
+		else
+		{
+			if (fdwroutine->AnalyzeForeignTable != NULL)
+				ok = fdwroutine->AnalyzeForeignTable(onerel,
+													 &acquirefunc,
+													 &relpages);
+
+			if (!ok)
+			{
+				ereport(WARNING,
+						errmsg("skipping \"%s\" -- cannot analyze this foreign table.",
+							   RelationGetRelationName(onerel)));
+				relation_close(onerel, ShareUpdateExclusiveLock);
+				return;
+			}
+		}
+
 		if (fdwroutine->AnalyzeForeignTable != NULL)
 			ok = fdwroutine->AnalyzeForeignTable(onerel,
 												 &acquirefunc,
@@ -248,9 +271,18 @@ analyze_rel(Oid relid, RangeVar *relation,
 
 	/*
 	 * Do the normal non-recursive ANALYZE.  We can skip this for partitioned
-	 * tables, which don't contain any rows.
+	 * tables, which don't contain any rows.  For foreign tables, if they
+	 * support importing statistics, do that instead of the non-recursive
+	 * ANALYZE.
 	 */
-	if (onerel->rd_rel->relkind != RELKIND_PARTITIONED_TABLE)
+	if (import_stats)
+	{
+		Assert(onerel->rd_rel->relkind == RELKIND_FOREIGN_TABLE);
+		Assert(fdwroutine != NULL);
+		Assert(fdwroutine->ImportStatistics != NULL);
+		fdwroutine->ImportStatistics(onerel, va_cols, elevel);
+	}
+	else if (onerel->rd_rel->relkind != RELKIND_PARTITIONED_TABLE)
 		do_analyze_rel(onerel, params, va_cols, acquirefunc,
 					   relpages, false, in_outer_xact, elevel);
 
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index 9b032fbf675..5439ce9eb09 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -332,7 +332,7 @@ OPTIONS (ADD password_required 'false');
    </para>
 
    <para>
-    The following option controls how such an <command>ANALYZE</command>
+    The following options control how such an <command>ANALYZE</command>
     operation behaves:
    </para>
 
@@ -364,6 +364,39 @@ OPTIONS (ADD password_required 'false');
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>fetch_stats</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines if <command>ANALYZE</command> on a foreign table
+       will first attempt to fetch and import the existing relation and
+       attribute statistics from the remote table, and only attempt regular
+       data sampling if no statistics were available. This option is only
+       useful if the remote relation is one that can have regular statistics
+       (tables and materialized views).
+       The default is <literal>true</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><literal>remote_analyze</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines whether an <command>ANALYZE</command> on a foreign
+       table will attempt to <command>ANALYZE</command> the remote table if
+       the first attempt to fetch remote statistics fails, and will then
+       make a second and final attempt to fetch remote statistics. This option
+       is only meaningful if the foreign table has
+       <literal>fetch_stats</literal> enabled at either the server or table
+       level.
+       The default is <literal>false</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
    </variablelist>
 
   </sect3>
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 6066510c7c0..1f7ebeda82c 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -252,6 +252,7 @@ SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should work again
 -- Now we should be able to run ANALYZE.
 -- To exercise multiple code paths, we use local stats on ft1
 -- and remote-estimate mode on ft2.
+ALTER SERVER loopback OPTIONS (ADD fetch_stats 'false');
 ANALYZE ft1;
 ALTER FOREIGN TABLE ft2 OPTIONS (use_remote_estimate 'true');
 -- ===================================================================
@@ -4551,7 +4552,8 @@ REINDEX TABLE reind_fdw_parent; -- ok
 REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -11462,6 +11464,11 @@ CREATE FOREIGN TABLE async_p2 PARTITION OF async_pt FOR VALUES FROM (2000) TO (3
   SERVER loopback2 OPTIONS (table_name 'base_tbl2');
 INSERT INTO async_p1 SELECT 1000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 INSERT INTO async_p2 SELECT 2000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p2
+ANALYZE async_pt;
+ERROR:  Failed to import statistics from remote table public.base_tbl2, no statistics found.
+-- Turn off fetch_stats at the table level.
+ALTER FOREIGN TABLE async_p2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 -- simple queries
 CREATE TABLE result_tbl (a int, b int, c text);
@@ -11568,6 +11575,11 @@ CREATE TABLE base_tbl3 (a int, b int, c text);
 CREATE FOREIGN TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4000)
   SERVER loopback2 OPTIONS (table_name 'base_tbl3');
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p3/loopback2
+ANALYZE async_pt;
+ERROR:  Failed to import statistics from remote table public.base_tbl3, no statistics found.
+-- Turn off fetch_stats at the server level.
+ALTER SERVER loopback2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
@@ -12665,6 +12677,44 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 -- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table',
+                fetch_stats 'true',
+                remote_analyze 'true');
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+ tablename | num_stats 
+-----------+-----------
+(0 rows)
+
+ANALYZE remote_analyze_ftable;
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+       tablename       | num_stats 
+-----------------------+-----------
+ remote_analyze_ftable |         3
+ remote_analyze_table  |         3
+(2 rows)
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+-- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
 -- Disable debug_discard_caches in order to manage remote connections
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index b0bd72d1e58..2941ecbfb87 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -120,6 +120,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 			strcmp(def->defname, "async_capable") == 0 ||
 			strcmp(def->defname, "parallel_commit") == 0 ||
 			strcmp(def->defname, "parallel_abort") == 0 ||
+			strcmp(def->defname, "fetch_stats") == 0 ||
+			strcmp(def->defname, "remote_analyze") == 0 ||
 			strcmp(def->defname, "keep_connections") == 0)
 		{
 			/* these accept only boolean values */
@@ -278,6 +280,14 @@ InitPgFdwOptions(void)
 		{"use_scram_passthrough", ForeignServerRelationId, false},
 		{"use_scram_passthrough", UserMappingRelationId, false},
 
+		/* fetch_stats is available on both server and table */
+		{"fetch_stats", ForeignServerRelationId, false},
+		{"fetch_stats", ForeignTableRelationId, false},
+
+		/* remote_analyze is available on both server and table */
+		{"remote_analyze", ForeignServerRelationId, false},
+		{"remote_analyze", ForeignTableRelationId, false},
+
 		/*
 		 * sslcert and sslkey are in fact libpq options, but we repeat them
 		 * here to allow them to appear in both foreign server context (when
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 3572689e33b..9ea6aa2ed59 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -22,6 +22,8 @@
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "executor/execAsync.h"
+#include "executor/spi.h"
+#include "fmgr.h"
 #include "foreign/fdwapi.h"
 #include "funcapi.h"
 #include "miscadmin.h"
@@ -48,6 +50,7 @@
 #include "utils/rel.h"
 #include "utils/sampling.h"
 #include "utils/selfuncs.h"
+#include "utils/syscache.h"
 
 PG_MODULE_MAGIC_EXT(
 					.name = "postgres_fdw",
@@ -317,6 +320,20 @@ typedef struct
 	List	   *already_used;	/* expressions already dealt with */
 } ec_member_foreign_arg;
 
+/* Result sets that are returned from a foreign statistics scan */
+typedef struct
+{
+	PGresult	*rel;
+	PGresult	*att;
+} RemoteStatsResults;
+
+/* Pairs of remote columns with local attnums */
+typedef struct
+{
+	char		remote_attname[NAMEDATALEN];
+	AttrNumber	local_attnum;
+} RemoteAttributeMapping;
+
 /*
  * SQL functions
  */
@@ -402,6 +419,10 @@ static void postgresExecForeignTruncate(List *rels,
 static bool postgresAnalyzeForeignTable(Relation relation,
 										AcquireSampleRowsFunc *func,
 										BlockNumber *totalpages);
+static bool postgresStatisticsAreImportable(Relation relation);
+static void postgresImportStatistics(Relation relation,
+									 List *va_cols,
+									 int elevel);
 static List *postgresImportForeignSchema(ImportForeignSchemaStmt *stmt,
 										 Oid serverOid);
 static void postgresGetForeignJoinPaths(PlannerInfo *root,
@@ -546,6 +567,86 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
 
+/*
+ * Static queries for querying remote statistics.
+ */
+
+/* All static relstats queries have the same column order */
+enum RelStatsColumns {
+	RELSTATS_RELKIND = 0,
+	RELSTATS_RELPAGES,
+	RELSTATS_RELTUPLES,
+	RELSTATS_NUM_FIELDS
+};
+
+/* range stats introduced in v17 */
+static const char *attstats_query_17 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"s.range_length_histogram, s.range_empty_frac, s.range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"AND s.attname = ANY($3::text[]) "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* elements stats introduced in 9.2 */
+static const char *attstats_query_9_2 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"AND s.attname = ANY($3::text[]) "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* inherited introduced in 9.0 */
+static const char *attstats_query_9_0 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"AND s.attname = ANY($3::text[]) "
+	"ORDER BY s.attname, s.inherited DESC";
+
+static const char *attstats_query_default =
+	"SELECT s.attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"AND s.attname = ANY($3::text[]) "
+	"ORDER BY s.attname";
+
+/* All static attstats queries have the same column order */
+enum AttStatsColumns {
+	ATTSTATS_ATTNAME = 0,
+	ATTSTATS_NULL_FRAC,
+	ATTSTATS_AVG_WIDTH,
+	ATTSTATS_N_DISTINCT,
+	ATTSTATS_MOST_COMMON_VALS,
+	ATTSTATS_MOST_COMMON_FREQS,
+	ATTSTATS_HISTOGRAM_BOUNDS,
+	ATTSTATS_CORRELATION,
+	ATTSTATS_MOST_COMMON_ELEMS,
+	ATTSTATS_MOST_COMMON_ELEM_FREQS,
+	ATTSTATS_ELEM_COUNT_HISTOGRAM,
+	ATTSTATS_RANGE_LENGTH_HISTOGRAM,
+	ATTSTATS_RANGE_EMPTY_FRAC,
+	ATTSTATS_RANGE_BOUNDS_HISTOGRAM,
+	ATTSTATS_NUM_FIELDS
+};
 
 /*
  * Foreign-data wrapper handler function: return a struct with pointers
@@ -595,6 +696,8 @@ postgres_fdw_handler(PG_FUNCTION_ARGS)
 
 	/* Support functions for ANALYZE */
 	routine->AnalyzeForeignTable = postgresAnalyzeForeignTable;
+	routine->StatisticsAreImportable = postgresStatisticsAreImportable;
+	routine->ImportStatistics = postgresImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	routine->ImportForeignSchema = postgresImportForeignSchema;
@@ -4935,6 +5038,685 @@ postgresAnalyzeForeignTable(Relation relation,
 	return true;
 }
 
+/*
+ * Process optional argument.
+ *
+ * Cannot be the first argument in the SQL function call.
+ *
+ * It is safe to presume that argname and argtype are quote-safe.
+ *
+ * Argument values can potentially be quite large, so free the quoted string
+ * after use.
+ */
+static void
+append_optional(StringInfo str, PGresult *res, int row, int field,
+				const char *argname, const char *argtype)
+{
+	if (!PQgetisnull(res, row, field))
+	{
+		/* Argument values can be quite large, so free after use */
+		char *argval_l = quote_literal_cstr(PQgetvalue(res, row, field));
+
+		appendStringInfo(str, ",\n\t'%s', %s::%s", argname, argval_l, argtype);
+
+		pfree(argval_l);
+	}
+}
+
+
+/*
+ * Generate a pg_restore_relation_stats command.
+ */
+static char *
+restore_relation_stats_sql(PGresult *res, const char *schemaname,
+						   const char *relname, const int server_version_num)
+{
+	StringInfoData	sql;
+
+	char	   *schemaname_l = quote_literal_cstr(schemaname);
+	char	   *relname_l = quote_literal_cstr(relname);
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_relation_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s",
+					 server_version_num, schemaname_l, relname_l);
+
+	pfree(schemaname_l);
+	pfree(relname_l);
+
+	append_optional(&sql, res, 0, RELSTATS_RELPAGES, "relpages", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELTUPLES, "reltuples", "real");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * Generate a pg_restore_attribute_stats command.
+ */
+static char *
+restore_attribute_stats_sql(PGresult *res, int row,
+							const char *schemaname, const char *relname,
+							const AttrNumber attnum, const int server_version_num)
+{
+	StringInfoData	sql;
+
+	char	   *schemaname_l = quote_literal_cstr(schemaname);
+	char	   *relname_l = quote_literal_cstr(relname);
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_attribute_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s,\n"
+					 "\t'attnum', %d::smallint,\n"
+					 "\t'inherited', false::boolean",
+					 server_version_num, schemaname_l, relname_l, attnum);
+
+	pfree(schemaname_l);
+	pfree(relname_l);
+
+	append_optional(&sql, res, row, ATTSTATS_NULL_FRAC, "null_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_AVG_WIDTH, "avg_width", "integer");
+	append_optional(&sql, res, row, ATTSTATS_N_DISTINCT, "n_distinct", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_VALS, "most_common_vals", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_FREQS, "most_common_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_HISTOGRAM_BOUNDS, "histogram_bounds", "text");
+	append_optional(&sql, res, row, ATTSTATS_CORRELATION, "correlation", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEMS, "most_common_elems", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEM_FREQS, "most_common_elem_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_ELEM_COUNT_HISTOGRAM, "elem_count_histogram", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_LENGTH_HISTOGRAM, "range_length_histogram", "text");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_EMPTY_FRAC, "range_empty_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_BOUNDS_HISTOGRAM, "range_bounds_histogram", "text");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * Test if an attribute name is in the list.
+ *
+ * An empty list means that all attribute names are in the list.
+ */
+static bool
+attname_in_list(const char *attname, List *va_cols)
+{
+	ListCell   *le;
+
+	if (va_cols == NIL)
+		return true;
+
+	foreach(le, va_cols)
+	{
+		char	   *col = strVal(lfirst(le));
+
+		if (strcmp(attname, col) == 0)
+			return true;
+	}
+	return false;
+}
+
+/*
+ * Import fetched statistics into the local statistics tables.
+ */
+static void
+import_fetched_statistics(const char *schemaname, const char *relname,
+						  int server_version_num, int natts,
+						  const RemoteAttributeMapping *remattrmap,
+						  RemoteStatsResults *remstats)
+{
+	PGresult   *res = remstats->att;
+	int			spirc;
+	char	   *relimport_sql;
+	int			mapidx = 0;
+
+	Assert(natts > 0);
+
+	SPI_connect();
+
+	/*
+	 * Match result set rows to local attnums.
+	 *
+	 * Every row of the result should be an attribute that we specificially
+	 * filtered for, so every row should have at least one match in the
+	 * RemoteAttributeMapping, which is also ordered by attname, so we only
+	 * need to walk that array once.
+	 *
+	 * We import the attribute statistics first, because those are more prone
+	 * to errors. This avoids making a modification of pg_class that will just
+	 * get rolled back by a failed attribute import.
+	 */
+	for (int i = 0; i < PQntuples(remstats->att); i++)
+	{
+		char	   *remote_attname;
+		char	   *attimport_sql;
+		bool		match_found = false;
+		int			cmp = 0;
+
+		if (PQgetisnull(res, i, ATTSTATS_ATTNAME))
+			ereport(ERROR,
+					errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+					errmsg("Remote statistics returned a row with NULL attribute name"));
+
+		remote_attname = PQgetvalue(res, i, ATTSTATS_ATTNAME);
+
+		/*
+		 * Handle a series of matching elements in the mapping.
+		 *
+		 * We exit this loop in the following ways:
+		 *
+		 * 1. Having found at least match and exhausting the list. We're done.
+		 * 2. Having found at least match and ending on a <, advance.
+		 * 3. Having found at least match and ending on a >, bad sort.
+		 * 4. No match, list exhausted.
+		 * 5. No match, <.
+		 * 6. No match, >.
+		 *
+		 */
+		for (; mapidx < natts; mapidx++)
+		{
+			cmp = strcmp(remote_attname, remattrmap[mapidx].remote_attname);
+
+			/* Stop scanning on the first non-match */
+			if (cmp != 0)
+				break;
+
+			match_found = true;
+
+			attimport_sql = restore_attribute_stats_sql(res, i,
+														schemaname, relname,
+														remattrmap[mapidx].local_attnum,
+														server_version_num);
+
+			spirc = SPI_execute(attimport_sql, false, 1);
+			pfree(attimport_sql);
+
+			/*
+			 * It takes a lot to make a restore command fail outright, so any
+			 * actual failure is a sign that the statistics are seriously
+			 * malformed, and we should give up on importing stats for this
+			 * table.
+			 */
+			if (spirc != SPI_OK_SELECT)
+				ereport(ERROR,
+						errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+						errmsg("Attribute statistics import failed %s",
+							   attimport_sql));
+		}
+
+		/* We found a match, move onto the next result. */
+		if (match_found)
+			continue;
+
+		/*
+		 * If no match and we ended on a <, then the mapidx will never find a
+		 * match, so stop now and let the cleanup report the error.
+		 */
+		if (cmp > 0)
+			break;
+
+		/*
+		 * Otherwise this result found no matching mapidx, which should only
+		 * happen if the results are out of sort or the wrong query was
+		 * executed.
+		 */
+		if (cmp < 0)
+			ereport(ERROR,
+					errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+					errmsg("No remote statistics found for %s of %s.",
+						quote_identifier(remattrmap[mapidx].remote_attname),
+						quote_qualified_identifier(schemaname, relname)));
+	}
+
+	/* Look for a mapidx with no possible matching result */
+	if (mapidx < natts)
+		ereport(ERROR,
+				errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+				errmsg("No remote statistics found for %s of %s.",
+					quote_identifier(remattrmap[mapidx].remote_attname),
+					quote_qualified_identifier(schemaname, relname)));
+
+	/*
+	 * Import relation stats.
+	 */
+	relimport_sql = restore_relation_stats_sql(remstats->rel, schemaname,
+											   relname, server_version_num);
+
+	spirc = SPI_execute(relimport_sql, false, 1);
+
+	/*
+	 * It takes a lot to make a restore command fail outright, so any actual
+	 * failure is a sign that the statistics are seriously malformed, and
+	 * we should give up on importing stats for this table.
+	 */
+	if (spirc != SPI_OK_SELECT)
+		ereport(ERROR,
+				errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+				errmsg("Relation statistics import failed: %s.",
+					   relimport_sql));
+
+	pfree(relimport_sql);
+	SPI_finish();
+}
+
+/*
+ * Analyze a remote table.
+ */
+static void
+analyze_remote_table(PGconn *conn, const char *remote_schemaname,
+					 const char *remote_relname)
+{
+	StringInfoData	buf;
+	PGresult	   *res;
+
+	initStringInfo(&buf);
+
+	appendStringInfo(&buf, "ANALYZE %s",
+					 quote_qualified_identifier(remote_schemaname, remote_relname));
+
+	res = pgfdw_exec_query(conn, buf.data, NULL);
+
+	if (res == NULL ||
+		PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, conn, buf.data);
+
+	PQclear(res);
+	pfree(buf.data);
+}
+
+/*
+ * Attempt to fetch remote relations stats.
+ * Verify that the result is of the proper shape.
+ */
+static PGresult *
+fetch_relstats(PGconn *conn,
+			   const char *remote_schemaname, const char *remote_relname)
+{
+	const char *params[2] = { remote_schemaname, remote_relname };
+
+	/*
+	 * Before v14, a reltuples value of 0 was ambiguous: it could either mean
+	 * the relation is empty, or it could mean that it hadn't yet been
+	 * vacuumed or analyzed.  (Newer versions use -1 for the latter case).
+	 *
+	 * We can ignore this change, because if the remote table wasn't analyzed,
+	 * then it would have no attribute stats, and thus we wouldn't have stats that
+	 * we would try to import. So we can take the reltuples value as-is.
+	 */
+	const char *sql = "SELECT c.relkind, c.relpages, c.reltuples "
+					  "FROM pg_catalog.pg_class AS c "
+					  "JOIN pg_catalog.pg_namespace AS n "
+					  "ON n.oid = c.relnamespace "
+					  "WHERE n.nspname = $1 AND c.relname = $2";
+
+	PGresult   *res;
+
+	if (!PQsendQueryParams(conn, sql, 2, NULL, params, NULL, NULL, 0))
+		pgfdw_report_error(NULL, conn, sql);
+
+	res = pgfdw_get_result(conn);
+
+	if (res == NULL
+		|| PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQntuples(res) != 1
+		|| PQnfields(res) != RELSTATS_NUM_FIELDS
+		|| PQgetisnull(res, 0, RELSTATS_RELKIND))
+		pgfdw_report_error(res, conn, sql);
+
+	return res;
+}
+
+/*
+ * Attempt to fetch remote attribute stats.
+ * Verify that the result is of the proper shape.
+ * Note that we do not verify the row count.
+ */
+static PGresult *
+fetch_attstats(PGconn *conn,
+			   const char *remote_schemaname, const char *remote_relname,
+			   const char *column_list, bool is_retry)
+{
+	const char *params[3] = { remote_schemaname, remote_relname, column_list };
+	int			version = PQserverVersion(conn);
+	const char *sql;
+	PGresult   *res;
+
+	if (version >= 170000)
+		sql = attstats_query_17;
+	else if (version >= 90200)
+		sql = attstats_query_9_2;
+	else if (version >= 90000)
+		sql = attstats_query_9_0;
+	else
+		sql = attstats_query_default;
+
+	if (!PQsendQueryParams(conn, sql, 3, NULL, params, NULL, NULL, 0))
+		pgfdw_report_error(NULL, conn, sql);
+
+	res = pgfdw_get_result(conn);
+
+	if (res == NULL
+		|| PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQnfields(res) != ATTSTATS_NUM_FIELDS)
+		pgfdw_report_error(res, conn, sql);
+
+	/* Getting nothing on the second try is a failure */
+	if (is_retry && PQntuples(res) == 0)
+		pgfdw_report_error(res, conn, sql);
+
+	return res;
+}
+
+/*
+ * Attempt to fetch statistics from a remote server.
+ */
+static void
+fetch_remote_statistics(PGconn *conn,
+						const char *remote_schemaname,
+						const char *remote_relname,
+						int server_version_num, int natts,
+						const RemoteAttributeMapping *remattrmap,
+						bool remote_analyze, RemoteStatsResults *remstats)
+{
+	StringInfoData	column_list;
+
+	char	relkind;
+
+	remstats->rel = fetch_relstats(conn, remote_schemaname, remote_relname);
+
+	/*
+	 * Verify that the remote table is the sort that can have meaningful stats
+	 * in pg_stats.
+	 *
+	 * Note that while relations of kinds RELKIND_INDEX and
+	 * RELKIND_PARTITIONED_INDEX can have rows in pg_stats, they obviously can't
+	 * support a foreign table.
+	 */
+	relkind = *PQgetvalue(remstats->rel, 0, RELSTATS_RELKIND);
+
+	switch(relkind)
+	{
+		case RELKIND_RELATION:
+		case RELKIND_PARTITIONED_TABLE:
+		case RELKIND_FOREIGN_TABLE:
+		case RELKIND_MATVIEW:
+			break;
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("Remote table %s does not support statistics.",
+						   quote_qualified_identifier(remote_schemaname,
+													  remote_relname)));
+			return;
+	}
+
+	initStringInfo(&column_list);
+	appendStringInfoChar(&column_list, '{');
+	for (int i = 0; i < natts; i++)
+	{
+		if (i > 0)
+			appendStringInfoChar(&column_list, ',');
+		appendStringInfoString(&column_list,
+							   quote_identifier(remattrmap[i].remote_attname));
+	}
+	appendStringInfoChar(&column_list, '}');
+
+	/* See if it actually has any attribute stats. */
+	remstats->att = fetch_attstats(conn, remote_schemaname, remote_relname,
+								   column_list.data, false);
+
+	/*
+	 * If we got attribute statistics results, then we are done with fetching.
+	 */
+	if (PQntuples(remstats->att) > 0)
+		return;
+
+	/*
+	 * Clear off any existing fetched statistics, if any. If the analyze works
+	 * then they we want to fetch the new ones.
+	 */
+	PQclear(remstats->att);
+	PQclear(remstats->rel);
+	remstats->att = NULL;
+	remstats->rel = NULL;
+
+	/*
+	 * If remote_analyze is enabled, then we will try to analyze the table and
+	 * then try again.
+	 */
+	if (remote_analyze)
+		analyze_remote_table(conn, remote_schemaname, remote_relname);
+	else
+		ereport(ERROR,
+				errcode(ERRCODE_NO_DATA_FOUND),
+				errmsg("Failed to import statistics from remote table %s, "
+					   "no statistics found.",
+					   quote_qualified_identifier(remote_schemaname,
+												  remote_relname)));
+
+	/*
+	 * Remote ANALYZE complete, so re-fetch attribute stats query.
+	 */
+	remstats->att = fetch_attstats(conn, remote_schemaname, remote_relname,
+								   column_list.data, true);
+
+	/* Re-fetch basic relation stats, as they have been updated. */
+	remstats->rel = fetch_relstats(conn, remote_schemaname, remote_relname);
+
+	pfree(column_list.data);
+}
+
+static bool
+postgresStatisticsAreImportable(Relation relation)
+{
+	ForeignTable   *table;
+	ForeignServer  *server;
+	ListCell	   *lc;
+	bool			fetch_stats = true;
+
+	table = GetForeignTable(RelationGetRelid(relation));
+	server = GetForeignServer(table->serverid);
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+		{
+			fetch_stats = defGetBoolean(def);
+			break;
+		}
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+		{
+			fetch_stats = defGetBoolean(def);
+			break;
+		}
+	}
+
+	return fetch_stats;
+}
+
+/*
+ * Compare two RemoteAttributeMappings for sorting.
+ */
+static int
+remattrmap_cmp(const void *v1, const void *v2)
+{
+	const RemoteAttributeMapping *r1 = v1;
+	const RemoteAttributeMapping *r2 = v2;
+
+	return strncmp(r1->remote_attname, r2->remote_attname, NAMEDATALEN);
+}
+
+/*
+ * postgresImportStatistics
+ * 		Attempt to fetch remote statistics and apply those instead of analyzing.
+ */
+static void
+postgresImportStatistics(Relation relation, List *va_cols, int elevel)
+{
+
+	ForeignTable   *table;
+	ForeignServer  *server;
+	UserMapping	   *user;
+	PGconn		   *conn;
+	ListCell	   *lc;
+	bool			remote_analyze = false;
+	int				server_version_num = 0;
+	const char	   *schemaname = NULL;
+	const char	   *relname = NULL;
+	const char	   *remote_schemaname = NULL;
+	const char	   *remote_relname = NULL;
+	TupleDesc		tupdesc = RelationGetDescr(relation);
+	int				natts = 0;
+
+	RemoteAttributeMapping	   *remattrmap;
+
+	RemoteStatsResults	remstats = { .rel = NULL, .att = NULL };
+
+	table = GetForeignTable(RelationGetRelid(relation));
+	server = GetForeignServer(table->serverid);
+	user = GetUserMapping(GetUserId(), table->serverid);
+	conn = GetConnection(user, false, NULL);
+	server_version_num = PQserverVersion(conn);
+	schemaname = get_namespace_name(RelationGetNamespace(relation));
+	relname = RelationGetRelationName(relation);
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "schema_name") == 0)
+			remote_schemaname = defGetString(def);
+		else if (strcmp(def->defname, "table_name") == 0)
+			remote_relname = defGetString(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	/*
+	 * Assume the relation/schema names are the same as the local name unless
+	 * the options tell us otherwise.
+	 */
+	if (remote_schemaname == NULL)
+		remote_schemaname = schemaname;
+	if (remote_relname == NULL)
+		remote_relname = relname;
+
+	/*
+	 * Build attnum/remote-attname list.
+	 *
+	 */
+	remattrmap = palloc_array(RemoteAttributeMapping, tupdesc->natts);
+	for (int i = 0; i < tupdesc->natts; i++)
+	{
+		char	   *attname;
+		char	   *remote_colname;
+		List	   *fc_options;
+		ListCell   *fc_lc;
+		AttrNumber	attnum;
+		HeapTuple	atttuple;
+		bool		isnull;
+		Datum		dat;
+		int			attstattarget;
+
+		/* Ignore dropped columns. */
+		if (TupleDescAttr(tupdesc, i)->attisdropped)
+			continue;
+
+		/* Ignore generated columns. */
+		if (TupleDescAttr(tupdesc, i)->attgenerated)
+			continue;
+
+		attname = NameStr(TupleDescAttr(tupdesc, i)->attname);
+
+		/* If a list is specified, exclude any attnames not in it. */
+		if (!attname_in_list(attname, va_cols))
+			continue;
+
+		attnum = TupleDescAttr(tupdesc, i)->attnum;
+
+		/* Ignore if attstatarget is 0 */
+		atttuple = SearchSysCache2(ATTNUM,
+								   ObjectIdGetDatum(RelationGetRelid(relation)),
+								   Int16GetDatum(attnum));
+		if (!HeapTupleIsValid(atttuple))
+			elog(ERROR, "cache lookup failed for attribute %d of relation %u",
+				 attnum, RelationGetRelid(relation));
+		dat = SysCacheGetAttr(ATTNUM, atttuple,
+							  Anum_pg_attribute_attstattarget, &isnull);
+		attstattarget = isnull ? -1 : DatumGetInt16(dat);
+		ReleaseSysCache(atttuple);
+		if (attstattarget == 0)
+			continue;
+
+		/* If column_name is not specified, go with attname. */
+		remote_colname = attname;
+		fc_options = GetForeignColumnOptions(RelationGetRelid(relation), attnum);
+
+		foreach(fc_lc, fc_options)
+		{
+			DefElem    *def = (DefElem *) lfirst(fc_lc);
+
+			if (strcmp(def->defname, "column_name") == 0)
+			{
+				remote_colname = defGetString(def);
+				break;
+			}
+		}
+
+		remattrmap[natts].local_attnum = attnum;
+		strncpy(remattrmap[natts].remote_attname, remote_colname, NAMEDATALEN);
+		natts++;
+	}
+
+	/* Sort mapping by remote attribute name */
+	qsort(remattrmap, natts, sizeof(RemoteAttributeMapping), remattrmap_cmp);
+
+	fetch_remote_statistics(conn, remote_schemaname, remote_relname,
+							server_version_num, natts, remattrmap,
+							remote_analyze, &remstats);
+
+	ReleaseConnection(conn);
+
+	Assert(remstats.rel != NULL);
+	Assert(remstats.att != NULL);
+	import_fetched_statistics(schemaname, relname, server_version_num, natts,
+							  remattrmap, &remstats);
+
+	pfree(remattrmap);
+	PQclear(remstats.att);
+	PQclear(remstats.rel);
+}
+
+
 /*
  * postgresGetAnalyzeInfoForForeignTable
  *		Count tuples in foreign table (just get pg_class.reltuples).
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 4f7ab2ed0ac..c527a1fbb15 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -241,6 +241,7 @@ SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should work again
 -- Now we should be able to run ANALYZE.
 -- To exercise multiple code paths, we use local stats on ft1
 -- and remote-estimate mode on ft2.
+ALTER SERVER loopback OPTIONS (ADD fetch_stats 'false');
 ANALYZE ft1;
 ALTER FOREIGN TABLE ft2 OPTIONS (use_remote_estimate 'true');
 
@@ -1286,7 +1287,8 @@ REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -3903,6 +3905,10 @@ CREATE FOREIGN TABLE async_p2 PARTITION OF async_pt FOR VALUES FROM (2000) TO (3
   SERVER loopback2 OPTIONS (table_name 'base_tbl2');
 INSERT INTO async_p1 SELECT 1000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 INSERT INTO async_p2 SELECT 2000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p2
+ANALYZE async_pt;
+-- Turn off fetch_stats at the table level.
+ALTER FOREIGN TABLE async_p2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 
 -- simple queries
@@ -3940,6 +3946,10 @@ CREATE TABLE base_tbl3 (a int, b int, c text);
 CREATE FOREIGN TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4000)
   SERVER loopback2 OPTIONS (table_name 'base_tbl3');
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p3/loopback2
+ANALYZE async_pt;
+-- Turn off fetch_stats at the server level.
+ALTER SERVER loopback2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 
 EXPLAIN (VERBOSE, COSTS OFF)
@@ -4387,6 +4397,40 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 
+-- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table',
+                fetch_stats 'true',
+                remote_analyze 'true');
+
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+ANALYZE remote_analyze_ftable;
+
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================

base-commit: 0547aeae0fd6f6d03dd7499c84145ad9e3aa51b9
-- 
2.52.0

#29Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Corey Huinker (#28)
Re: Import Statistics in postgres_fdw before resorting to sampling.

Hi, thanks for working on this. Generally I think that this is a good
idea to avoid fetching rows from a foreign table to compute statistics
that may already be available on the foreign server.

I started reviewing the v7 patch and here are my initial comments. I
still want to do another round of review and run some more tests.

+		if (fdwroutine->ImportStatistics != NULL &&
+			fdwroutine->StatisticsAreImportable != NULL &&
+			fdwroutine->StatisticsAreImportable(onerel))
+			import_stats = true;
+		else
+		{
+			if (fdwroutine->AnalyzeForeignTable != NULL)
+				ok = fdwroutine->AnalyzeForeignTable(onerel,
+													 &acquirefunc,
+													 &relpages);
+
+			if (!ok)
+			{
+				ereport(WARNING,
+						errmsg("skipping \"%s\" -- cannot analyze this foreign table.",
+							   RelationGetRelationName(onerel)));
+				relation_close(onerel, ShareUpdateExclusiveLock);
+				return;
+			}
+		}
+
 		if (fdwroutine->AnalyzeForeignTable != NULL)
 			ok = fdwroutine->AnalyzeForeignTable(onerel,
 												 &acquirefunc,
												 &relpages);

if (!ok)
{
ereport(WARNING,
(errmsg("skipping \"%s\" --- cannot analyze this foreign table",
RelationGetRelationName(onerel))));
relation_close(onerel, ShareUpdateExclusiveLock);
return;
}

It seems that we have the same code within the else branch after the if/else
check, is this correct?

---

+ * Every row of the result should be an attribute that we specificially

s/specificially/specifically

---

+		if (TupleDescAttr(tupdesc, i)->attisdropped)
+			continue;
+
+		/* Ignore generated columns. */
+		if (TupleDescAttr(tupdesc, i)->attgenerated)
+			continue;
+
+		attname = NameStr(TupleDescAttr(tupdesc, i)->attname);

Wouldn't be better to call TupleDescAttr a single time and save the value?
Form_pg_attribute attr = TupleDescAttr(tupdesc, i - 1);

/* Ignore dropped columns. */
if (attr->attisdropped)
continue;
...

--
Matheus Alcantara
EDB: https://www.enterprisedb.com

#30Etsuro Fujita
etsuro.fujita@gmail.com
In reply to: Matheus Alcantara (#29)
Re: Import Statistics in postgres_fdw before resorting to sampling.

Hi Matheus,

On Wed, Jan 7, 2026 at 6:38 AM Matheus Alcantara
<matheusssilv97@gmail.com> wrote:

+               if (fdwroutine->ImportStatistics != NULL &&
+                       fdwroutine->StatisticsAreImportable != NULL &&
+                       fdwroutine->StatisticsAreImportable(onerel))
+                       import_stats = true;
+               else
+               {
+                       if (fdwroutine->AnalyzeForeignTable != NULL)
+                               ok = fdwroutine->AnalyzeForeignTable(onerel,
+                                                                                                        &acquirefunc,
+                                                                                                        &relpages);
+
+                       if (!ok)
+                       {
+                               ereport(WARNING,
+                                               errmsg("skipping \"%s\" -- cannot analyze this foreign table.",
+                                                          RelationGetRelationName(onerel)));
+                               relation_close(onerel, ShareUpdateExclusiveLock);
+                               return;
+                       }
+               }
+
if (fdwroutine->AnalyzeForeignTable != NULL)
ok = fdwroutine->AnalyzeForeignTable(onerel,
&acquirefunc,
&relpages);

if (!ok)
{
ereport(WARNING,
(errmsg("skipping \"%s\" --- cannot analyze this foreign table",
RelationGetRelationName(onerel))));
relation_close(onerel, ShareUpdateExclusiveLock);
return;
}

It seems that we have the same code within the else branch after the if/else
check, is this correct?

No. This should be something like the attached in [1]/messages/by-id/CAPmGK17Dfjy_zLH1yjPqybpSueHWP7Gy_xBZXA2NpRso1qya7A@mail.gmail.com. (I didn't
look at the core changes in v6...)

Thanks!

Best regards,
Etsuro Fujita

[1]: /messages/by-id/CAPmGK17Dfjy_zLH1yjPqybpSueHWP7Gy_xBZXA2NpRso1qya7A@mail.gmail.com

#31Corey Huinker
corey.huinker@gmail.com
In reply to: Etsuro Fujita (#27)
1 attachment(s)
Re: Import Statistics in postgres_fdw before resorting to sampling.

I agree, if there is no fallback, then the default should be false. When

I was initially brainstorming this patch, Nathan Bossart had suggested
making it the default because 1) that would be an automatic benefit to
users and 2) the cost for attempting to import stats was small in
comparison to a table stample, so it was worth the attempt. I still want
users to get that automatic benefit, but if there is no fallback to
sampling then the default only makes sense as false.

I think that the FDW API that I proposed could actually allow us to
fall back to sampling, by modifying StatisticsAreImportable so that it
also checks if 1) there are statistics on the remote server and 2) the
data is fresh enough, and if so, returns true; otherwise, returns
false; in the latter case we could fall back to sampling. And if we
modified it as such, I think we could change the default to true.
(Checking #2 is necessary to avoid importing stale data, which would
degrade plan quality.)

I've given this some more thought.

First, we'd have to add the va_cols param to StatisticsAreImportable, which
isn't itself terrible.

Then, we'd have to determine that there are stats available for every
mapped column (filtered by va_cols, if any). But the only way to do that is
to query the pg_stats view on the remote end, and if we have done that,
then we've already fetched the stats. Yes, we could avoid selecting the
actual statistical values, and that would save some network bandwidth at
the cost of having to do the query again with stats. So I don't really see
the savings.

Also, the pg_stats view is our security-barrier black box into statistics,
and it gives no insight into how recently those stats were acquired. We
could poke pg_stat_all_tables, assuming we can even query it, and then make
a value judgement on the value of (CURRENT_TIMESTAMP -
GREATEST(last_analyze, last_autoanalyze), but that value is highly
subjective.

I suppose we could move all of the statistics fetching
into StatisticsAreImportable, And carry those values forward if they are
satisfactory. That would leave ImportStatistics() with little to do other
than form up the calls to pg_restore_*_stats(), but those could still fail,
and at that point we'd have no way to fall back to sampling and analysis.

I really want to make sampling fallback possible.

Anyway, here's v8, incorporating the documentation feedback and Matheus's
notes.

Attachments:

v8-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchtext/x-patch; charset=US-ASCII; name=v8-0001-Add-remote-statistics-fetching-to-postgres_fdw.patchDownload
From 57733548ed2e06b10f18f5299dae3e58aa453e67 Mon Sep 17 00:00:00 2001
From: Corey Huinker <corey.huinker@gmail.com>
Date: Thu, 7 Aug 2025 23:58:38 -0400
Subject: [PATCH v8] Add remote statistics fetching to postgres_fdw.

This adds the ability to fetch and import statistics from a remote
server table table rather than fetching the data or data sample from
that table.

This is managed via two new options, fetch_stats and remote_analyze,
both are available at the server level and table level. If fetch_stats
is true, then the ANALYZE command will attempt to fetch statistics
from the remote table and import those statistics locally.

If remote_analyze is true, and if the first attempt to fetch remote
statistics found no attribute statistics, then an attempt will be made
to ANALYZE the remote table before a second and final attempt to fetch
remote statistics.

If the attempts to fetch statistics result in some analyzed columns
missing imported statistics, then the ANALYZE will raise an error.

This operation will only work on remote relations that can have stored
statistics: tables, partitioned tables, and materialized views. If the
remote relation is a view then remote fetching/analyzing is just wasted
effort and the user is better off setting fetch_stats to false for that
table.

The default for fetch_stats is, for now, true at both server and table
level. The default for remote_analyze is false at both the server
and table level.  In both cases, setting a value at the table level
will override the corresponding server-level setting.
---
 src/include/foreign/fdwapi.h                  |   9 +-
 src/backend/commands/analyze.c                |  52 +-
 doc/src/sgml/postgres-fdw.sgml                |  38 +-
 .../postgres_fdw/expected/postgres_fdw.out    |  52 +-
 contrib/postgres_fdw/option.c                 |  10 +
 contrib/postgres_fdw/postgres_fdw.c           | 797 ++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  46 +-
 7 files changed, 983 insertions(+), 21 deletions(-)

diff --git a/src/include/foreign/fdwapi.h b/src/include/foreign/fdwapi.h
index 96b6f692d2a..e54d441125a 100644
--- a/src/include/foreign/fdwapi.h
+++ b/src/include/foreign/fdwapi.h
@@ -19,7 +19,6 @@
 /* avoid including explain_state.h here */
 typedef struct ExplainState ExplainState;
 
-
 /*
  * Callback function signatures --- see fdwhandler.sgml for more info.
  */
@@ -157,6 +156,12 @@ typedef bool (*AnalyzeForeignTable_function) (Relation relation,
 											  AcquireSampleRowsFunc *func,
 											  BlockNumber *totalpages);
 
+typedef bool (*StatisticsAreImportable_function)  (Relation relation);
+
+typedef void (*ImportStatistics_function) (Relation relation,
+										   List *va_cols,
+										   int elevel);
+
 typedef List *(*ImportForeignSchema_function) (ImportForeignSchemaStmt *stmt,
 											   Oid serverOid);
 
@@ -255,6 +260,8 @@ typedef struct FdwRoutine
 
 	/* Support functions for ANALYZE */
 	AnalyzeForeignTable_function AnalyzeForeignTable;
+	StatisticsAreImportable_function StatisticsAreImportable;
+	ImportStatistics_function ImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	ImportForeignSchema_function ImportForeignSchema;
diff --git a/src/backend/commands/analyze.c b/src/backend/commands/analyze.c
index a483424152c..e69288968fc 100644
--- a/src/backend/commands/analyze.c
+++ b/src/backend/commands/analyze.c
@@ -113,6 +113,8 @@ analyze_rel(Oid relid, RangeVar *relation,
 	int			elevel;
 	AcquireSampleRowsFunc acquirefunc = NULL;
 	BlockNumber relpages = 0;
+	FdwRoutine *fdwroutine = NULL;
+	bool		import_stats = false;
 
 	/* Select logging level */
 	if (params.options & VACOPT_VERBOSE)
@@ -195,26 +197,33 @@ analyze_rel(Oid relid, RangeVar *relation,
 	else if (onerel->rd_rel->relkind == RELKIND_FOREIGN_TABLE)
 	{
 		/*
-		 * For a foreign table, call the FDW's hook function to see whether it
-		 * supports analysis.
+		 * For a foreign table, call the FDW's hook functions to see whether
+		 * it supports statistics import or analysis.
 		 */
-		FdwRoutine *fdwroutine;
-		bool		ok = false;
 
 		fdwroutine = GetFdwRoutineForRelation(onerel, false);
 
-		if (fdwroutine->AnalyzeForeignTable != NULL)
-			ok = fdwroutine->AnalyzeForeignTable(onerel,
-												 &acquirefunc,
-												 &relpages);
-
-		if (!ok)
+		if (fdwroutine->ImportStatistics != NULL &&
+			fdwroutine->StatisticsAreImportable != NULL &&
+			fdwroutine->StatisticsAreImportable(onerel))
+			import_stats = true;
+		else
 		{
-			ereport(WARNING,
-					(errmsg("skipping \"%s\" --- cannot analyze this foreign table",
-							RelationGetRelationName(onerel))));
-			relation_close(onerel, ShareUpdateExclusiveLock);
-			return;
+			bool	ok = false;
+
+			if (fdwroutine->AnalyzeForeignTable != NULL)
+				ok = fdwroutine->AnalyzeForeignTable(onerel,
+													 &acquirefunc,
+													 &relpages);
+
+			if (!ok)
+			{
+				ereport(WARNING,
+						errmsg("skipping \"%s\" -- cannot analyze this foreign table.",
+							   RelationGetRelationName(onerel)));
+				relation_close(onerel, ShareUpdateExclusiveLock);
+				return;
+			}
 		}
 	}
 	else if (onerel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
@@ -248,9 +257,18 @@ analyze_rel(Oid relid, RangeVar *relation,
 
 	/*
 	 * Do the normal non-recursive ANALYZE.  We can skip this for partitioned
-	 * tables, which don't contain any rows.
+	 * tables, which don't contain any rows.  For foreign tables, if they
+	 * support importing statistics, do that instead of the non-recursive
+	 * ANALYZE.
 	 */
-	if (onerel->rd_rel->relkind != RELKIND_PARTITIONED_TABLE)
+	if (import_stats)
+	{
+		Assert(onerel->rd_rel->relkind == RELKIND_FOREIGN_TABLE);
+		Assert(fdwroutine != NULL);
+		Assert(fdwroutine->ImportStatistics != NULL);
+		fdwroutine->ImportStatistics(onerel, va_cols, elevel);
+	}
+	else if (onerel->rd_rel->relkind != RELKIND_PARTITIONED_TABLE)
 		do_analyze_rel(onerel, params, va_cols, acquirefunc,
 					   relpages, false, in_outer_xact, elevel);
 
diff --git a/doc/src/sgml/postgres-fdw.sgml b/doc/src/sgml/postgres-fdw.sgml
index 9b032fbf675..05928835355 100644
--- a/doc/src/sgml/postgres-fdw.sgml
+++ b/doc/src/sgml/postgres-fdw.sgml
@@ -332,7 +332,7 @@ OPTIONS (ADD password_required 'false');
    </para>
 
    <para>
-    The following option controls how such an <command>ANALYZE</command>
+    The following options control how such an <command>ANALYZE</command>
     operation behaves:
    </para>
 
@@ -364,6 +364,42 @@ OPTIONS (ADD password_required 'false');
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>fetch_stats</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines if <command>ANALYZE</command> on a foreign table
+       will instead attempt to fetch the existing relation and attribute
+       statistics from the remote table, and if all of the attributes being
+       analyzed have statistics in the remote table, then it will import
+       those statistics directly using
+       <function>pg_restore_relation_stats</relation> and
+       <function>pg_restore_attribute_stats</relation>. This option is only
+       useful if the remote relation is one that can have regular statistics
+       (tables and materialized views).
+       The default is <literal>true</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><literal>remote_analyze</literal> (<type>boolean</type>)</term>
+     <listitem>
+      <para>
+       This option, which can be specified for a foreign table or a foreign
+       server, determines whether an <command>ANALYZE</command> on a foreign
+       table will attempt to <command>ANALYZE</command> the remote table if
+       the first attempt to fetch remote statistics fails, and will then
+       make a second and final attempt to fetch remote statistics. This option
+       is only meaningful if the foreign table has
+       <literal>fetch_stats</literal> enabled at either the server or table
+       level.
+       The default is <literal>false</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
    </variablelist>
 
   </sect3>
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 6066510c7c0..1f7ebeda82c 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -252,6 +252,7 @@ SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should work again
 -- Now we should be able to run ANALYZE.
 -- To exercise multiple code paths, we use local stats on ft1
 -- and remote-estimate mode on ft2.
+ALTER SERVER loopback OPTIONS (ADD fetch_stats 'false');
 ANALYZE ft1;
 ALTER FOREIGN TABLE ft2 OPTIONS (use_remote_estimate 'true');
 -- ===================================================================
@@ -4551,7 +4552,8 @@ REINDEX TABLE reind_fdw_parent; -- ok
 REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -11462,6 +11464,11 @@ CREATE FOREIGN TABLE async_p2 PARTITION OF async_pt FOR VALUES FROM (2000) TO (3
   SERVER loopback2 OPTIONS (table_name 'base_tbl2');
 INSERT INTO async_p1 SELECT 1000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 INSERT INTO async_p2 SELECT 2000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p2
+ANALYZE async_pt;
+ERROR:  Failed to import statistics from remote table public.base_tbl2, no statistics found.
+-- Turn off fetch_stats at the table level.
+ALTER FOREIGN TABLE async_p2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 -- simple queries
 CREATE TABLE result_tbl (a int, b int, c text);
@@ -11568,6 +11575,11 @@ CREATE TABLE base_tbl3 (a int, b int, c text);
 CREATE FOREIGN TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4000)
   SERVER loopback2 OPTIONS (table_name 'base_tbl3');
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p3/loopback2
+ANALYZE async_pt;
+ERROR:  Failed to import statistics from remote table public.base_tbl3, no statistics found.
+-- Turn off fetch_stats at the server level.
+ALTER SERVER loopback2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 EXPLAIN (VERBOSE, COSTS OFF)
 INSERT INTO result_tbl SELECT * FROM async_pt WHERE b === 505;
@@ -12665,6 +12677,44 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 -- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table',
+                fetch_stats 'true',
+                remote_analyze 'true');
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+ tablename | num_stats 
+-----------+-----------
+(0 rows)
+
+ANALYZE remote_analyze_ftable;
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+       tablename       | num_stats 
+-----------------------+-----------
+ remote_analyze_ftable |         3
+ remote_analyze_table  |         3
+(2 rows)
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+-- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================
 -- Disable debug_discard_caches in order to manage remote connections
diff --git a/contrib/postgres_fdw/option.c b/contrib/postgres_fdw/option.c
index b0bd72d1e58..2941ecbfb87 100644
--- a/contrib/postgres_fdw/option.c
+++ b/contrib/postgres_fdw/option.c
@@ -120,6 +120,8 @@ postgres_fdw_validator(PG_FUNCTION_ARGS)
 			strcmp(def->defname, "async_capable") == 0 ||
 			strcmp(def->defname, "parallel_commit") == 0 ||
 			strcmp(def->defname, "parallel_abort") == 0 ||
+			strcmp(def->defname, "fetch_stats") == 0 ||
+			strcmp(def->defname, "remote_analyze") == 0 ||
 			strcmp(def->defname, "keep_connections") == 0)
 		{
 			/* these accept only boolean values */
@@ -278,6 +280,14 @@ InitPgFdwOptions(void)
 		{"use_scram_passthrough", ForeignServerRelationId, false},
 		{"use_scram_passthrough", UserMappingRelationId, false},
 
+		/* fetch_stats is available on both server and table */
+		{"fetch_stats", ForeignServerRelationId, false},
+		{"fetch_stats", ForeignTableRelationId, false},
+
+		/* remote_analyze is available on both server and table */
+		{"remote_analyze", ForeignServerRelationId, false},
+		{"remote_analyze", ForeignTableRelationId, false},
+
 		/*
 		 * sslcert and sslkey are in fact libpq options, but we repeat them
 		 * here to allow them to appear in both foreign server context (when
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 3572689e33b..e69cb421523 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -22,6 +22,8 @@
 #include "commands/explain_format.h"
 #include "commands/explain_state.h"
 #include "executor/execAsync.h"
+#include "executor/spi.h"
+#include "fmgr.h"
 #include "foreign/fdwapi.h"
 #include "funcapi.h"
 #include "miscadmin.h"
@@ -48,6 +50,7 @@
 #include "utils/rel.h"
 #include "utils/sampling.h"
 #include "utils/selfuncs.h"
+#include "utils/syscache.h"
 
 PG_MODULE_MAGIC_EXT(
 					.name = "postgres_fdw",
@@ -317,6 +320,20 @@ typedef struct
 	List	   *already_used;	/* expressions already dealt with */
 } ec_member_foreign_arg;
 
+/* Result sets that are returned from a foreign statistics scan */
+typedef struct
+{
+	PGresult	*rel;
+	PGresult	*att;
+} RemoteStatsResults;
+
+/* Pairs of remote columns with local attnums */
+typedef struct
+{
+	char		remote_attname[NAMEDATALEN];
+	AttrNumber	local_attnum;
+} RemoteAttributeMapping;
+
 /*
  * SQL functions
  */
@@ -402,6 +419,10 @@ static void postgresExecForeignTruncate(List *rels,
 static bool postgresAnalyzeForeignTable(Relation relation,
 										AcquireSampleRowsFunc *func,
 										BlockNumber *totalpages);
+static bool postgresStatisticsAreImportable(Relation relation);
+static void postgresImportStatistics(Relation relation,
+									 List *va_cols,
+									 int elevel);
 static List *postgresImportForeignSchema(ImportForeignSchemaStmt *stmt,
 										 Oid serverOid);
 static void postgresGetForeignJoinPaths(PlannerInfo *root,
@@ -546,6 +567,86 @@ static void merge_fdw_options(PgFdwRelationInfo *fpinfo,
 							  const PgFdwRelationInfo *fpinfo_i);
 static int	get_batch_size_option(Relation rel);
 
+/*
+ * Static queries for querying remote statistics.
+ */
+
+/* All static relstats queries have the same column order */
+enum RelStatsColumns {
+	RELSTATS_RELKIND = 0,
+	RELSTATS_RELPAGES,
+	RELSTATS_RELTUPLES,
+	RELSTATS_NUM_FIELDS
+};
+
+/* range stats introduced in v17 */
+static const char *attstats_query_17 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"s.range_length_histogram, s.range_empty_frac, s.range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"AND s.attname = ANY($3::text[]) "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* elements stats introduced in 9.2 */
+static const char *attstats_query_9_2 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, s.most_common_elems, "
+	"s.most_common_elem_freqs, s.elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"AND s.attname = ANY($3::text[]) "
+	"ORDER BY s.attname, s.inherited DESC";
+
+/* inherited introduced in 9.0 */
+static const char *attstats_query_9_0 =
+	"SELECT DISTINCT ON (s.attname) attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"AND s.attname = ANY($3::text[]) "
+	"ORDER BY s.attname, s.inherited DESC";
+
+static const char *attstats_query_default =
+	"SELECT s.attname, s.null_frac, s.avg_width, "
+	"s.n_distinct, s.most_common_vals, s.most_common_freqs, "
+	"s.histogram_bounds, s.correlation, NULL AS most_common_elems, "
+	"NULL AS most_common_elem_freqs, NULL AS elem_count_histogram, "
+	"NULL AS range_length_histogram, NULL AS range_empty_frac, "
+	"NULL AS range_bounds_histogram "
+	"FROM pg_catalog.pg_stats AS s "
+	"WHERE s.schemaname = $1 AND s.tablename = $2 "
+	"AND s.attname = ANY($3::text[]) "
+	"ORDER BY s.attname";
+
+/* All static attstats queries have the same column order */
+enum AttStatsColumns {
+	ATTSTATS_ATTNAME = 0,
+	ATTSTATS_NULL_FRAC,
+	ATTSTATS_AVG_WIDTH,
+	ATTSTATS_N_DISTINCT,
+	ATTSTATS_MOST_COMMON_VALS,
+	ATTSTATS_MOST_COMMON_FREQS,
+	ATTSTATS_HISTOGRAM_BOUNDS,
+	ATTSTATS_CORRELATION,
+	ATTSTATS_MOST_COMMON_ELEMS,
+	ATTSTATS_MOST_COMMON_ELEM_FREQS,
+	ATTSTATS_ELEM_COUNT_HISTOGRAM,
+	ATTSTATS_RANGE_LENGTH_HISTOGRAM,
+	ATTSTATS_RANGE_EMPTY_FRAC,
+	ATTSTATS_RANGE_BOUNDS_HISTOGRAM,
+	ATTSTATS_NUM_FIELDS
+};
 
 /*
  * Foreign-data wrapper handler function: return a struct with pointers
@@ -595,6 +696,8 @@ postgres_fdw_handler(PG_FUNCTION_ARGS)
 
 	/* Support functions for ANALYZE */
 	routine->AnalyzeForeignTable = postgresAnalyzeForeignTable;
+	routine->StatisticsAreImportable = postgresStatisticsAreImportable;
+	routine->ImportStatistics = postgresImportStatistics;
 
 	/* Support functions for IMPORT FOREIGN SCHEMA */
 	routine->ImportForeignSchema = postgresImportForeignSchema;
@@ -4935,6 +5038,700 @@ postgresAnalyzeForeignTable(Relation relation,
 	return true;
 }
 
+/*
+ * Process optional argument.
+ *
+ * Cannot be the first argument in the SQL function call.
+ *
+ * It is safe to presume that argname and argtype are quote-safe.
+ *
+ * Argument values can potentially be quite large, so free the quoted string
+ * after use.
+ */
+static void
+append_optional(StringInfo str, PGresult *res, int row, int field,
+				const char *argname, const char *argtype)
+{
+	if (!PQgetisnull(res, row, field))
+	{
+		/* Argument values can be quite large, so free after use */
+		char *argval_l = quote_literal_cstr(PQgetvalue(res, row, field));
+
+		appendStringInfo(str, ",\n\t'%s', %s::%s", argname, argval_l, argtype);
+
+		pfree(argval_l);
+	}
+}
+
+
+/*
+ * Generate a pg_restore_relation_stats command.
+ */
+static char *
+restore_relation_stats_sql(PGresult *res, const char *schemaname,
+						   const char *relname, const int server_version_num)
+{
+	StringInfoData	sql;
+
+	char	   *schemaname_l = quote_literal_cstr(schemaname);
+	char	   *relname_l = quote_literal_cstr(relname);
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_relation_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s",
+					 server_version_num, schemaname_l, relname_l);
+
+	pfree(schemaname_l);
+	pfree(relname_l);
+
+	append_optional(&sql, res, 0, RELSTATS_RELPAGES, "relpages", "integer");
+	append_optional(&sql, res, 0, RELSTATS_RELTUPLES, "reltuples", "real");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * Generate a pg_restore_attribute_stats command.
+ */
+static char *
+restore_attribute_stats_sql(PGresult *res, int row,
+							const char *schemaname, const char *relname,
+							const AttrNumber attnum, const int server_version_num)
+{
+	StringInfoData	sql;
+
+	char	   *schemaname_l = quote_literal_cstr(schemaname);
+	char	   *relname_l = quote_literal_cstr(relname);
+
+	initStringInfo(&sql);
+	appendStringInfo(&sql, "SELECT pg_catalog.pg_restore_attribute_stats(\n"
+					 "\t'version', %d::integer,\n"
+					 "\t'schemaname', %s,\n"
+					 "\t'relname', %s,\n"
+					 "\t'attnum', %d::smallint,\n"
+					 "\t'inherited', false::boolean",
+					 server_version_num, schemaname_l, relname_l, attnum);
+
+	pfree(schemaname_l);
+	pfree(relname_l);
+
+	append_optional(&sql, res, row, ATTSTATS_NULL_FRAC, "null_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_AVG_WIDTH, "avg_width", "integer");
+	append_optional(&sql, res, row, ATTSTATS_N_DISTINCT, "n_distinct", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_VALS, "most_common_vals", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_FREQS, "most_common_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_HISTOGRAM_BOUNDS, "histogram_bounds", "text");
+	append_optional(&sql, res, row, ATTSTATS_CORRELATION, "correlation", "real");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEMS, "most_common_elems", "text");
+	append_optional(&sql, res, row, ATTSTATS_MOST_COMMON_ELEM_FREQS, "most_common_elem_freqs", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_ELEM_COUNT_HISTOGRAM, "elem_count_histogram", "real[]");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_LENGTH_HISTOGRAM, "range_length_histogram", "text");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_EMPTY_FRAC, "range_empty_frac", "real");
+	append_optional(&sql, res, row, ATTSTATS_RANGE_BOUNDS_HISTOGRAM, "range_bounds_histogram", "text");
+
+	appendStringInfoChar(&sql, ')');
+
+	return sql.data;
+}
+
+/*
+ * Test if an attribute name is in the list.
+ *
+ * An empty list means that all attribute names are in the list.
+ */
+static bool
+attname_in_list(const char *attname, List *va_cols)
+{
+	ListCell   *le;
+
+	if (va_cols == NIL)
+		return true;
+
+	foreach(le, va_cols)
+	{
+		char	   *col = strVal(lfirst(le));
+
+		if (strcmp(attname, col) == 0)
+			return true;
+	}
+	return false;
+}
+
+/*
+ * Import fetched statistics into the local statistics tables.
+ */
+static void
+import_fetched_statistics(const char *schemaname, const char *relname,
+						  int server_version_num, int natts,
+						  const RemoteAttributeMapping *remattrmap,
+						  RemoteStatsResults *remstats)
+{
+	PGresult   *res = remstats->att;
+	int			spirc;
+	char	   *relimport_sql;
+	int			mapidx = 0;
+
+	Assert(natts > 0);
+
+	SPI_connect();
+
+	/*
+	 * Match result set rows to local attnums.
+	 *
+	 * Every row of the result should be an attribute that we specifically
+	 * filtered for, so every row should have at least one match in the
+	 * RemoteAttributeMapping, which is also ordered by attname, so we only
+	 * need to walk that array once.
+	 *
+	 * We import the attribute statistics first, because those are more prone
+	 * to errors. This avoids making a modification of pg_class that will just
+	 * get rolled back by a failed attribute import.
+	 */
+	for (int i = 0; i < PQntuples(remstats->att); i++)
+	{
+		char	   *remote_attname;
+		char	   *attimport_sql;
+		bool		match_found = false;
+		int			cmp = 0;
+
+		if (PQgetisnull(res, i, ATTSTATS_ATTNAME))
+			ereport(ERROR,
+					errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+					errmsg("Remote statistics returned a row with NULL attribute name"));
+
+		remote_attname = PQgetvalue(res, i, ATTSTATS_ATTNAME);
+
+		/*
+		 * Handle a series of matching elements in the mapping.
+		 *
+		 * We exit this loop in the following ways:
+		 *
+		 * 1. Having found at least match and exhausting the list. We're done.
+		 * 2. Having found at least match and ending on a <, advance.
+		 * 3. Having found at least match and ending on a >, bad sort.
+		 * 4. No match, list exhausted.
+		 * 5. No match, <.
+		 * 6. No match, >.
+		 *
+		 */
+		for (; mapidx < natts; mapidx++)
+		{
+			cmp = strcmp(remote_attname, remattrmap[mapidx].remote_attname);
+
+			/* Stop scanning on the first non-match */
+			if (cmp != 0)
+				break;
+
+			match_found = true;
+
+			attimport_sql = restore_attribute_stats_sql(res, i,
+														schemaname, relname,
+														remattrmap[mapidx].local_attnum,
+														server_version_num);
+
+			spirc = SPI_execute(attimport_sql, false, 1);
+			pfree(attimport_sql);
+
+			/*
+			 * It takes a lot to make a restore command fail outright, so any
+			 * actual failure is a sign that the statistics are seriously
+			 * malformed, and we should give up on importing stats for this
+			 * table.
+			 */
+			if (spirc != SPI_OK_SELECT)
+				ereport(ERROR,
+						errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+						errmsg("Attribute statistics import failed %s",
+							   attimport_sql));
+		}
+
+		/* We found a match, move onto the next result. */
+		if (match_found)
+			continue;
+
+		/*
+		 * If no match and we ended on a <, then the mapidx will never find a
+		 * match, so stop now and let the cleanup report the error.
+		 */
+		if (cmp > 0)
+			break;
+
+		/*
+		 * Otherwise this result found no matching mapidx, which should only
+		 * happen if the results are out of sort or the wrong query was
+		 * executed.
+		 */
+		if (cmp < 0)
+			ereport(ERROR,
+					errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+					errmsg("No remote statistics found for %s of %s.",
+						quote_identifier(remattrmap[mapidx].remote_attname),
+						quote_qualified_identifier(schemaname, relname)));
+	}
+
+	/* Look for a mapidx with no possible matching result */
+	if (mapidx < natts)
+		ereport(ERROR,
+				errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+				errmsg("No remote statistics found for %s of %s.",
+					quote_identifier(remattrmap[mapidx].remote_attname),
+					quote_qualified_identifier(schemaname, relname)));
+
+	/*
+	 * Import relation stats.
+	 */
+	relimport_sql = restore_relation_stats_sql(remstats->rel, schemaname,
+											   relname, server_version_num);
+
+	spirc = SPI_execute(relimport_sql, false, 1);
+
+	/*
+	 * It takes a lot to make a restore command fail outright, so any actual
+	 * failure is a sign that the statistics are seriously malformed, and
+	 * we should give up on importing stats for this table.
+	 */
+	if (spirc != SPI_OK_SELECT)
+		ereport(ERROR,
+				errcode(ERRCODE_FDW_SCHEMA_NOT_FOUND),
+				errmsg("Relation statistics import failed: %s.",
+					   relimport_sql));
+
+	pfree(relimport_sql);
+	SPI_finish();
+}
+
+/*
+ * Analyze a remote table.
+ */
+static void
+analyze_remote_table(PGconn *conn, const char *remote_schemaname,
+					 const char *remote_relname)
+{
+	StringInfoData	buf;
+	PGresult	   *res;
+
+	initStringInfo(&buf);
+
+	appendStringInfo(&buf, "ANALYZE %s",
+					 quote_qualified_identifier(remote_schemaname, remote_relname));
+
+	res = pgfdw_exec_query(conn, buf.data, NULL);
+
+	if (res == NULL ||
+		PQresultStatus(res) != PGRES_COMMAND_OK)
+		pgfdw_report_error(res, conn, buf.data);
+
+	PQclear(res);
+	pfree(buf.data);
+}
+
+/*
+ * Attempt to fetch remote relations stats.
+ * Verify that the result is of the proper shape.
+ */
+static PGresult *
+fetch_relstats(PGconn *conn,
+			   const char *remote_schemaname, const char *remote_relname)
+{
+	const char *params[2] = { remote_schemaname, remote_relname };
+
+	/*
+	 * Before v14, a reltuples value of 0 was ambiguous: it could either mean
+	 * the relation is empty, or it could mean that it hadn't yet been
+	 * vacuumed or analyzed.  (Newer versions use -1 for the latter case).
+	 *
+	 * We can ignore this change, because if the remote table wasn't analyzed,
+	 * then it would have no attribute stats, and thus we wouldn't have stats that
+	 * we would try to import. So we can take the reltuples value as-is.
+	 */
+	const char *sql = "SELECT c.relkind, c.relpages, c.reltuples "
+					  "FROM pg_catalog.pg_class AS c "
+					  "JOIN pg_catalog.pg_namespace AS n "
+					  "ON n.oid = c.relnamespace "
+					  "WHERE n.nspname = $1 AND c.relname = $2";
+
+	PGresult   *res;
+
+	if (!PQsendQueryParams(conn, sql, 2, NULL, params, NULL, NULL, 0))
+		pgfdw_report_error(NULL, conn, sql);
+
+	res = pgfdw_get_result(conn);
+
+	if (res == NULL
+		|| PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQntuples(res) != 1
+		|| PQnfields(res) != RELSTATS_NUM_FIELDS
+		|| PQgetisnull(res, 0, RELSTATS_RELKIND))
+		pgfdw_report_error(res, conn, sql);
+
+	return res;
+}
+
+/*
+ * Attempt to fetch remote attribute stats.
+ * Verify that the result is of the proper shape.
+ * Note that we do not verify the row count.
+ */
+static PGresult *
+fetch_attstats(PGconn *conn,
+			   const char *remote_schemaname, const char *remote_relname,
+			   const char *column_list, bool is_retry)
+{
+	const char *params[3] = { remote_schemaname, remote_relname, column_list };
+	int			version = PQserverVersion(conn);
+	const char *sql;
+	PGresult   *res;
+
+	if (version >= 170000)
+		sql = attstats_query_17;
+	else if (version >= 90200)
+		sql = attstats_query_9_2;
+	else if (version >= 90000)
+		sql = attstats_query_9_0;
+	else
+		sql = attstats_query_default;
+
+	if (!PQsendQueryParams(conn, sql, 3, NULL, params, NULL, NULL, 0))
+		pgfdw_report_error(NULL, conn, sql);
+
+	res = pgfdw_get_result(conn);
+
+	if (res == NULL
+		|| PQresultStatus(res) != PGRES_TUPLES_OK
+		|| PQnfields(res) != ATTSTATS_NUM_FIELDS)
+		pgfdw_report_error(res, conn, sql);
+
+	/* Getting nothing on the second try is a failure */
+	if (is_retry && PQntuples(res) == 0)
+		pgfdw_report_error(res, conn, sql);
+
+	return res;
+}
+
+/*
+ * Attempt to fetch statistics from a remote server.
+ */
+static void
+fetch_remote_statistics(PGconn *conn,
+						const char *remote_schemaname,
+						const char *remote_relname,
+						int server_version_num, int natts,
+						const RemoteAttributeMapping *remattrmap,
+						bool remote_analyze, RemoteStatsResults *remstats)
+{
+	StringInfoData	column_list;
+
+	char	relkind;
+
+	remstats->rel = fetch_relstats(conn, remote_schemaname, remote_relname);
+
+	/*
+	 * Verify that the remote table is the sort that can have meaningful stats
+	 * in pg_stats.
+	 *
+	 * Note that while relations of kinds RELKIND_INDEX and
+	 * RELKIND_PARTITIONED_INDEX can have rows in pg_stats, they obviously can't
+	 * support a foreign table.
+	 */
+	relkind = *PQgetvalue(remstats->rel, 0, RELSTATS_RELKIND);
+
+	switch(relkind)
+	{
+		case RELKIND_RELATION:
+		case RELKIND_PARTITIONED_TABLE:
+		case RELKIND_FOREIGN_TABLE:
+		case RELKIND_MATVIEW:
+			break;
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					errmsg("Remote table %s does not support statistics.",
+						   quote_qualified_identifier(remote_schemaname,
+													  remote_relname)));
+			return;
+	}
+
+	initStringInfo(&column_list);
+	appendStringInfoChar(&column_list, '{');
+	for (int i = 0; i < natts; i++)
+	{
+		if (i > 0)
+			appendStringInfoChar(&column_list, ',');
+		appendStringInfoString(&column_list,
+							   quote_identifier(remattrmap[i].remote_attname));
+	}
+	appendStringInfoChar(&column_list, '}');
+
+	/* See if it actually has any attribute stats. */
+	remstats->att = fetch_attstats(conn, remote_schemaname, remote_relname,
+								   column_list.data, false);
+
+	/*
+	 * If we got attribute statistics results, then we are done with fetching.
+	 */
+	if (PQntuples(remstats->att) > 0)
+		return;
+
+	/*
+	 * Clear off any existing fetched statistics, if any. If the analyze works
+	 * then they we want to fetch the new ones.
+	 */
+	PQclear(remstats->att);
+	PQclear(remstats->rel);
+	remstats->att = NULL;
+	remstats->rel = NULL;
+
+	/*
+	 * If remote_analyze is enabled, then we will try to analyze the table and
+	 * then try again.
+	 */
+	if (remote_analyze)
+		analyze_remote_table(conn, remote_schemaname, remote_relname);
+	else
+		ereport(ERROR,
+				errcode(ERRCODE_NO_DATA_FOUND),
+				errmsg("Failed to import statistics from remote table %s, "
+					   "no statistics found.",
+					   quote_qualified_identifier(remote_schemaname,
+												  remote_relname)));
+
+	/*
+	 * Remote ANALYZE complete, so re-fetch attribute stats query.
+	 */
+	remstats->att = fetch_attstats(conn, remote_schemaname, remote_relname,
+								   column_list.data, true);
+
+	/* Re-fetch basic relation stats, as they have been updated. */
+	remstats->rel = fetch_relstats(conn, remote_schemaname, remote_relname);
+
+	pfree(column_list.data);
+}
+
+static bool
+postgresStatisticsAreImportable(Relation relation)
+{
+	ForeignTable   *table;
+	ForeignServer  *server;
+	ListCell	   *lc;
+	bool			fetch_stats = true;
+
+	table = GetForeignTable(RelationGetRelid(relation));
+	server = GetForeignServer(table->serverid);
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+		{
+			fetch_stats = defGetBoolean(def);
+			break;
+		}
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+		{
+			fetch_stats = defGetBoolean(def);
+			break;
+		}
+	}
+
+	return fetch_stats;
+}
+
+/*
+ * Compare two RemoteAttributeMappings for sorting.
+ */
+static int
+remattrmap_cmp(const void *v1, const void *v2)
+{
+	const RemoteAttributeMapping *r1 = v1;
+	const RemoteAttributeMapping *r2 = v2;
+
+	return strncmp(r1->remote_attname, r2->remote_attname, NAMEDATALEN);
+}
+
+/*
+ * Fetch attstattarget from a pg_attribute tuple.
+ */
+static int16
+get_attstattarget(Relation relation, AttrNumber attnum)
+{
+	HeapTuple	atttuple;
+	bool		isnull;
+	Datum		dat;
+	int16		attstattarget;
+	Oid			relid = RelationGetRelid(relation);
+
+	atttuple = SearchSysCache2(ATTNUM,
+							   ObjectIdGetDatum(relid),
+							   Int16GetDatum(attnum));
+	if (!HeapTupleIsValid(atttuple))
+		elog(ERROR, "cache lookup failed for attribute %d of relation %u",
+			 attnum, relid);
+	dat = SysCacheGetAttr(ATTNUM, atttuple,
+						  Anum_pg_attribute_attstattarget,
+						  &isnull);
+	attstattarget = isnull ? -1 : DatumGetInt16(dat);
+	ReleaseSysCache(atttuple);
+
+	return attstattarget;
+}
+
+/*
+ * postgresImportStatistics
+ * 		Attempt to fetch remote statistics and apply those instead of analyzing.
+ */
+static void
+postgresImportStatistics(Relation relation, List *va_cols, int elevel)
+{
+
+	ForeignTable   *table;
+	ForeignServer  *server;
+	UserMapping	   *user;
+	PGconn		   *conn;
+	ListCell	   *lc;
+	bool			remote_analyze = false;
+	int				server_version_num = 0;
+	const char	   *schemaname = NULL;
+	const char	   *relname = NULL;
+	const char	   *remote_schemaname = NULL;
+	const char	   *remote_relname = NULL;
+	TupleDesc		tupdesc = RelationGetDescr(relation);
+	int				natts = 0;
+
+	RemoteAttributeMapping	   *remattrmap;
+
+	RemoteStatsResults	remstats = { .rel = NULL, .att = NULL };
+
+	table = GetForeignTable(RelationGetRelid(relation));
+	server = GetForeignServer(table->serverid);
+	user = GetUserMapping(GetUserId(), table->serverid);
+	conn = GetConnection(user, false, NULL);
+	server_version_num = PQserverVersion(conn);
+	schemaname = get_namespace_name(RelationGetNamespace(relation));
+	relname = RelationGetRelationName(relation);
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "schema_name") == 0)
+			remote_schemaname = defGetString(def);
+		else if (strcmp(def->defname, "table_name") == 0)
+			remote_relname = defGetString(def);
+		else if (strcmp(def->defname, "remote_analyze") == 0)
+			remote_analyze = defGetBoolean(def);
+	}
+
+	/*
+	 * Assume the relation/schema names are the same as the local name unless
+	 * the options tell us otherwise.
+	 */
+	if (remote_schemaname == NULL)
+		remote_schemaname = schemaname;
+	if (remote_relname == NULL)
+		remote_relname = relname;
+
+	/*
+	 * Build attnum/remote-attname list.
+	 *
+	 */
+	remattrmap = palloc_array(RemoteAttributeMapping, tupdesc->natts);
+	for (int i = 0; i < tupdesc->natts; i++)
+	{
+		char	   *attname;
+		char	   *remote_colname;
+		List	   *fc_options;
+		ListCell   *fc_lc;
+		AttrNumber	attnum;
+
+		Form_pg_attribute	attr = TupleDescAttr(tupdesc, i);
+
+		/* Ignore dropped columns. */
+		if (attr->attisdropped)
+			continue;
+
+		/* Ignore generated columns. */
+		if (attr->attgenerated)
+			continue;
+
+		attname = NameStr(attr->attname);
+
+		/* If a list is specified, exclude any attnames not in it. */
+		if (!attname_in_list(attname, va_cols))
+			continue;
+
+		attnum = attr->attnum;
+
+		/* Ignore if attstatarget is 0 */
+		if (get_attstattarget(relation,attnum) == 0)
+			continue;
+
+		/* If column_name is not specified, go with attname. */
+		remote_colname = attname;
+		fc_options = GetForeignColumnOptions(RelationGetRelid(relation), attnum);
+
+		foreach(fc_lc, fc_options)
+		{
+			DefElem    *def = (DefElem *) lfirst(fc_lc);
+
+			if (strcmp(def->defname, "column_name") == 0)
+			{
+				remote_colname = defGetString(def);
+				break;
+			}
+		}
+
+		remattrmap[natts].local_attnum = attnum;
+		strncpy(remattrmap[natts].remote_attname, remote_colname, NAMEDATALEN);
+		natts++;
+	}
+
+	/* Sort mapping by remote attribute name */
+	qsort(remattrmap, natts, sizeof(RemoteAttributeMapping), remattrmap_cmp);
+
+	fetch_remote_statistics(conn, remote_schemaname, remote_relname,
+							server_version_num, natts, remattrmap,
+							remote_analyze, &remstats);
+
+	ReleaseConnection(conn);
+
+	Assert(remstats.rel != NULL);
+	Assert(remstats.att != NULL);
+	import_fetched_statistics(schemaname, relname, server_version_num, natts,
+							  remattrmap, &remstats);
+
+	pfree(remattrmap);
+	PQclear(remstats.att);
+	PQclear(remstats.rel);
+}
+
+
 /*
  * postgresGetAnalyzeInfoForForeignTable
  *		Count tuples in foreign table (just get pg_class.reltuples).
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 4f7ab2ed0ac..c527a1fbb15 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -241,6 +241,7 @@ SELECT c3, c4 FROM ft1 ORDER BY c3, c1 LIMIT 1;  -- should work again
 -- Now we should be able to run ANALYZE.
 -- To exercise multiple code paths, we use local stats on ft1
 -- and remote-estimate mode on ft2.
+ALTER SERVER loopback OPTIONS (ADD fetch_stats 'false');
 ANALYZE ft1;
 ALTER FOREIGN TABLE ft2 OPTIONS (use_remote_estimate 'true');
 
@@ -1286,7 +1287,8 @@ REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 
 -- ===================================================================
--- conversion error
+-- conversion error, will generate a WARNING for imported stats and an
+-- error on locally computed stats.
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
 SELECT * FROM ft1 ftx(x1,x2,x3,x4,x5,x6,x7,x8) WHERE x1 = 1;  -- ERROR
@@ -3903,6 +3905,10 @@ CREATE FOREIGN TABLE async_p2 PARTITION OF async_pt FOR VALUES FROM (2000) TO (3
   SERVER loopback2 OPTIONS (table_name 'base_tbl2');
 INSERT INTO async_p1 SELECT 1000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
 INSERT INTO async_p2 SELECT 2000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p2
+ANALYZE async_pt;
+-- Turn off fetch_stats at the table level.
+ALTER FOREIGN TABLE async_p2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 
 -- simple queries
@@ -3940,6 +3946,10 @@ CREATE TABLE base_tbl3 (a int, b int, c text);
 CREATE FOREIGN TABLE async_p3 PARTITION OF async_pt FOR VALUES FROM (3000) TO (4000)
   SERVER loopback2 OPTIONS (table_name 'base_tbl3');
 INSERT INTO async_p3 SELECT 3000 + i, i, to_char(i, 'FM0000') FROM generate_series(0, 999, 5) i;
+-- Will fail because fetch_stats = true (the default) on async_p3/loopback2
+ANALYZE async_pt;
+-- Turn off fetch_stats at the server level.
+ALTER SERVER loopback2 OPTIONS (ADD fetch_stats 'false');
 ANALYZE async_pt;
 
 EXPLAIN (VERBOSE, COSTS OFF)
@@ -4387,6 +4397,40 @@ ANALYZE analyze_ftable;
 DROP FOREIGN TABLE analyze_ftable;
 DROP TABLE analyze_table;
 
+-- ===================================================================
+-- test remote analyze
+-- ===================================================================
+CREATE TABLE remote_analyze_table (id int, a text, b bigint);
+INSERT INTO remote_analyze_table (SELECT x FROM generate_series(1,1000) x);
+
+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table',
+                fetch_stats 'true',
+                remote_analyze 'true');
+
+-- no stats before
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+ANALYZE remote_analyze_ftable;
+
+-- both stats after
+SELECT s.tablename, COUNT(*) AS num_stats
+FROM pg_stats AS s
+WHERE s.schemaname = 'public'
+AND s.tablename IN ('remote_analyze_table', 'remote_analyze_ftable')
+GROUP BY s.tablename
+ORDER BY s.tablename;
+
+-- cleanup
+DROP FOREIGN TABLE remote_analyze_ftable;
+DROP TABLE remote_analyze_table;
+
 -- ===================================================================
 -- test for postgres_fdw_get_connections function with check_conn = true
 -- ===================================================================

base-commit: a2e632ece1691be18771644182f769b525991f97
-- 
2.52.0

#32Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Corey Huinker (#31)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Wed Jan 7, 2026 at 3:04 AM -03, Corey Huinker wrote:

I agree, if there is no fallback, then the default should be false. When

I was initially brainstorming this patch, Nathan Bossart had suggested
making it the default because 1) that would be an automatic benefit to
users and 2) the cost for attempting to import stats was small in
comparison to a table stample, so it was worth the attempt. I still want
users to get that automatic benefit, but if there is no fallback to
sampling then the default only makes sense as false.

I think that the FDW API that I proposed could actually allow us to
fall back to sampling, by modifying StatisticsAreImportable so that it
also checks if 1) there are statistics on the remote server and 2) the
data is fresh enough, and if so, returns true; otherwise, returns
false; in the latter case we could fall back to sampling. And if we
modified it as such, I think we could change the default to true.
(Checking #2 is necessary to avoid importing stale data, which would
degrade plan quality.)

I've given this some more thought.

First, we'd have to add the va_cols param to StatisticsAreImportable, which
isn't itself terrible.

Then, we'd have to determine that there are stats available for every
mapped column (filtered by va_cols, if any). But the only way to do that is
to query the pg_stats view on the remote end, and if we have done that,
then we've already fetched the stats. Yes, we could avoid selecting the
actual statistical values, and that would save some network bandwidth at
the cost of having to do the query again with stats. So I don't really see
the savings.

Also, the pg_stats view is our security-barrier black box into statistics,
and it gives no insight into how recently those stats were acquired. We
could poke pg_stat_all_tables, assuming we can even query it, and then make
a value judgement on the value of (CURRENT_TIMESTAMP -
GREATEST(last_analyze, last_autoanalyze), but that value is highly
subjective.

I suppose we could move all of the statistics fetching
into StatisticsAreImportable, And carry those values forward if they are
satisfactory. That would leave ImportStatistics() with little to do other
than form up the calls to pg_restore_*_stats(), but those could still fail,
and at that point we'd have no way to fall back to sampling and analysis.

I really want to make sampling fallback possible.

Anyway, here's v8, incorporating the documentation feedback and Matheus's
notes.

Thanks for the new version.

+static bool
+postgresStatisticsAreImportable(Relation relation)
+...
+
+	/*
+	 * Server-level options can be overridden by table-level options, so check
+	 * server-level first.
+	 */
+	foreach(lc, server->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+		{
+			fetch_stats = defGetBoolean(def);
+			break;
+		}
+	}
+
+	foreach(lc, table->options)
+	{
+		DefElem    *def = (DefElem *) lfirst(lc);
+
+		if (strcmp(def->defname, "fetch_stats") == 0)
+		{
+			fetch_stats = defGetBoolean(def);
+			break;
+		}
+	}
+
I don't think that it's good to make StatisticsAreImportable() routine
check if fetch_stats is enabled on foreign server/table options because
if so, every fdw implementation would need this same block of code and
also fdw implementations may forget or bypass these options which I
don't think that it would be a desired behavior. What about move this
check to analyze_rel()? Perhaps create a function that just check if the
fetch_stats is enabled.

If the above statement make sense, it seems to me that
StatisticsAreImportable() may not be needed at all.

I think that we could make analyze_rel() check if fetch_stats is enable
on the foreign server/table and then call ImportStatistics() which could
return true or false. If it returns true it means that the statistics
was imported successfully, otherwise if it returns false we could
fallback to table sampling as we already do today. ImportStatistics
could return false if the foreign server don't have statistics for the
requested table, even after running ANALYZE if remote_analyze is true.

Is that make sense? Any thoughts?

--
Matheus Alcantara
EDB: https://www.enterprisedb.com

#33Corey Huinker
corey.huinker@gmail.com
In reply to: Matheus Alcantara (#32)
Re: Import Statistics in postgres_fdw before resorting to sampling.

+
I don't think that it's good to make StatisticsAreImportable() routine
check if fetch_stats is enabled on foreign server/table options because
if so, every fdw implementation would need this same block of code and
also fdw implementations may forget or bypass these options which I
don't think that it would be a desired behavior. What about move this
check to analyze_rel()? Perhaps create a function that just check if the
fetch_stats is enabled.

StatisticsAreImportable() is a virtual function whose goal is to determine
if this specific table supports stats exporting.

postgresStatisticsAreImportable() is the postgres_fdw implementation of
that virtual function.

Any other FDWs that want to implement stats import will need to invent
their own tests and configurations to determine if that is possible.

If the above statement make sense, it seems to me that
StatisticsAreImportable() may not be needed at all.

It wasn't there, initially.

I think that we could make analyze_rel() check if fetch_stats is enable
on the foreign server/table and then call ImportStatistics() which could
return true or false.

That we can't do, because there's a chance that those FDWs already have a
setting named fetch_stats.

If it returns true it means that the statistics
was imported successfully, otherwise if it returns false we could
fallback to table sampling as we already do today. ImportStatistics
could return false if the foreign server don't have statistics for the
requested table, even after running ANALYZE if remote_analyze is true.

Is that make sense? Any thoughts?

That sounds very similar to the design that was presented in v1.

#34Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Corey Huinker (#33)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Wed Jan 7, 2026 at 4:17 PM -03, Corey Huinker wrote:

+
I don't think that it's good to make StatisticsAreImportable() routine
check if fetch_stats is enabled on foreign server/table options because
if so, every fdw implementation would need this same block of code and
also fdw implementations may forget or bypass these options which I
don't think that it would be a desired behavior. What about move this
check to analyze_rel()? Perhaps create a function that just check if the
fetch_stats is enabled.

StatisticsAreImportable() is a virtual function whose goal is to determine
if this specific table supports stats exporting.

postgresStatisticsAreImportable() is the postgres_fdw implementation of
that virtual function.

Any other FDWs that want to implement stats import will need to invent
their own tests and configurations to determine if that is possible.

Ok, now I understand. I thought that fetch_stats and remote_analyze was
a generally fdw option and not only specific to postgres_fdw. Now I
understand that is up to the fdw implementation decide how this should
be enabled or disabled. Thanks for making it clear now.

If it returns true it means that the statistics
was imported successfully, otherwise if it returns false we could
fallback to table sampling as we already do today. ImportStatistics
could return false if the foreign server don't have statistics for the
requested table, even after running ANALYZE if remote_analyze is true.

Is that make sense? Any thoughts?

That sounds very similar to the design that was presented in v1.

Yeah, I think that my suggestion don't make sense, I miss understood the
feature. Sorry about the noise, I'll continue reviewing the v8 patch.

--
Matheus Alcantara
EDB: https://www.enterprisedb.com

#35Matheus Alcantara
matheusssilv97@gmail.com
In reply to: Corey Huinker (#31)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Wed Jan 7, 2026 at 3:04 AM -03, Corey Huinker wrote:

Anyway, here's v8, incorporating the documentation feedback and Matheus's
notes.

+CREATE FOREIGN TABLE remote_analyze_ftable (id int, a text, b bigint)
+       SERVER loopback
+       OPTIONS (table_name 'remote_analyze_table',
+                fetch_stats 'true',
+                remote_analyze 'true');

I think that it would be good also to have a test case where
remote_analyze is false. The test could manually execute an ANALYZE on
the target table and ensure that an ANALYZE on the foreign table fetch
the statistics correctly.

---

If the table don't have columns it fails to fetch the statistics with
remote_analyze=false even if the target table has statistics:
ERROR: P0002: Failed to import statistics from remote table public.t2, no statistics found.

And if I set remote_analyze=true it fails with the following error:

postgres=# analyze t2_fdw;
ERROR: 08006: could not obtain message string for remote error
CONTEXT: remote SQL command: SELECT DISTINCT ON (s.attname) attname,
s.null_frac, s.avg_width, s.n_distinct, s.most_common_vals,
s.most_common_freqs, s.histogram_bounds, s.correlation,
s.most_common_elems, s.most_common_elem_freqs, s.elem_count_histogram,
s.range_length_histogram, s.range_empty_frac, s.range_bounds_histogram
FROM pg_catalog.pg_stats AS s WHERE s.schemaname = $1 AND s.tablename =
$2 AND s.attname = ANY($3::text[]) ORDER BY s.attname, s.inherited DESC
LOCATION: pgfdw_report_internal, connection.c:1037

---

If we try to run ANALYZE on a specific table column that don't exists we
get:
postgres=# analyze t(c);
ERROR: 42703: column "c" of relation "t" does not exist
LOCATION: do_analyze_rel, analyze.c:412

With fetch_stats=false we get the same error:

postgres=# ALTER FOREIGN TABLE t_fdw OPTIONS (add fetch_stats 'false');
ALTER FOREIGN TABLE

postgres=# ANALYZE t_fdw(c);
ERROR: 42703: column "c" of relation "t_fdw" does not exist

But with fetch_stats=true we get a different error:

postgres=# ALTER FOREIGN TABLE t_fdw OPTIONS (drop fetch_stats);
ALTER FOREIGN TABLE

postgres=# ANALYZE t_fdw(c);
ERROR: P0002: Failed to import statistics from remote table public.t, no statistics found.

Should all these errors be consistency?

---

I hope that these comments are more useful now. Thanks.

--
Matheus Alcantara
EDB: https://www.enterprisedb.com

#36Ashutosh Bapat
ashutosh.bapat.oss@gmail.com
In reply to: Corey Huinker (#31)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Wed, Jan 7, 2026 at 11:34 AM Corey Huinker <corey.huinker@gmail.com> wrote:

Anyway, here's v8, incorporating the documentation feedback and Matheus's notes.

I went through the patches. I have one question: The column names of
the foreign table on the local server are sorted using qsort, which
uses C sorting. The column names the result obtained from the foreign
server are sorted using ORDER BY clause. If the default collation on
the foreign server is different from the one used by qsort(), the
merge sort in import_fetched_statistics() may fail. Shouldn't these
two sorts use the same collation?

Some minor comments

/* avoid including explain_state.h here */
typedef struct ExplainState ExplainState;
-

unintended line deletion?

fetch_remote_statistics() fetches the statistics twice if the first
attempt fails. I think we need same check after the second attempt as
well. The second attempt should not fail, but I think we need some
safety checks and Assert at least, in case the foreign server
misbehaves.

--
Best Wishes,
Ashutosh Bapat

#37Corey Huinker
corey.huinker@gmail.com
In reply to: Ashutosh Bapat (#36)
Re: Import Statistics in postgres_fdw before resorting to sampling.

On Fri, Jan 9, 2026 at 4:35 AM Ashutosh Bapat <ashutosh.bapat.oss@gmail.com>
wrote:

On Wed, Jan 7, 2026 at 11:34 AM Corey Huinker <corey.huinker@gmail.com>
wrote:

Anyway, here's v8, incorporating the documentation feedback and

Matheus's notes.

I went through the patches. I have one question: The column names of
the foreign table on the local server are sorted using qsort, which
uses C sorting. The column names the result obtained from the foreign
server are sorted using ORDER BY clause. If the default collation on
the foreign server is different from the one used by qsort(), the
merge sort in import_fetched_statistics() may fail. Shouldn't these
two sorts use the same collation?

I wondered about that, but somehow thought that because they're of type
pg_catalog.name rather than text/varchar that they weren't subject to
collation settings. We definitely should explicitly sort by C collation
regardless.

Some minor comments

/* avoid including explain_state.h here */
typedef struct ExplainState ExplainState;
-

unintended line deletion?

Yeah, must have been.

fetch_remote_statistics() fetches the statistics twice if the first
attempt fails. I think we need same check after the second attempt as
well. The second attempt should not fail, but I think we need some
safety checks and Assert at least, in case the foreign server
misbehaves.

I think the only test *not* done on re-fetch is checking the relkind of the
relation, are you speaking of another check? It's really no big deal to do
that one again, but I made the distinction early on in the coding, and in
retrospect there are relatively few checks we'd consider skipping on the
second pass, so maybe it's not worth the distinction.

Since you're joining the thread, we have an outstanding debate about what
the desired basic workflow should be, and I think we should get some
consensus before we paint ourselves into a corner.

1. The Simplest Possible Model

* There is no remote_analyze functionality
* fetch_stats defaults to false
* Failure to fetch stats results in a failure, no failover to sampling.

2. Simplest Model, but with Failover

* Same as #1, but if we aren't satisfied with the stats we get from the
remote, we issue a WARNING, then fall back to sampling, trusting that the
user will eventually turn off fetch_stats on tables where it isn't working.

3. Analyze and Retry

* Same as #2, but we add remote_analyze option (default false).
* If the first attempt fails AND remote_analyze is set on, then we send the
remote analyze, then retry. Only if that fails do we fall back to sampling.

4. Analyze and Retry, Optimistic

* Same as #3, but fetch_stats defaults to ON, because the worst case
scenario is that we issue a few queries that return 0-1 rows before giving
up and just sampling.
* This is the option that Nathan advocated for in our initial conversation
about the topic, and I found it quite persuasive at the time, but he's been
slammed with other stuff and hasn't been able to add to this thread.

5. Fetch With Retry Or Sample, Optimisitc

* If fetch_stats is on, AND the remote table is seemingly capable of
holding stats, attempt to fetch them, possibly retrying after ANALYZE
depending on remote_analyze.
* If fetching stats failed, just error, as a way to prime the user into
changing the table's setting.
* This is what's currently implemented, and it's not quite what anyone
wants. Defaulting fetch_stats to true doesn't seem great, but not
defaulting it to true will reduce adoption of this feature.

6. Fetch With Retry Or Sample, Pessimistic

* Same as #5, but with fetch_stats = false.