From 1ae417c5b0ffde791e5d6f24ae90015d40af1ffa Mon Sep 17 00:00:00 2001
From: Zhong ShiHao <zhongshihao@ZhongdeMacBook-Pro.local>
Date: Thu, 27 Aug 2026 21:33:27 -0400
Subject: [PATCH] Add pg_stat_tablespace statistics view

pg_stat_io reports I/O by object and context but not by tablespace, so
there is no way to see which tablespace a workload is actually hitting.
That matters when tablespaces sit on storage with different performance
characteristics.

Add a variable-numbered stats kind that aggregates, per tablespace,
blocks read and hit, block read and write time, temporary file count and
size, and the tuple counters, exposed through the pg_stat_tablespace
view.

Block and tuple counts are folded in when relation and index statistics
are flushed, so the per-tablespace totals track the corresponding
pg_stat_database totals.  Indexes are credited to their own tablespace,
which need not be the one holding the table.  I/O timings are reported
from the buffer manager, from both the shared and the local buffer paths.
Temporary files are credited to the tablespace they were created in,
recovered from the file path since fd.c no longer knows it by the time
the file is deleted.

pgstat_relation_update_tablespace() keeps the recorded tablespace current
when a relation is moved; see its comment for why two call sites are
needed.

Note that a relation moved in the middle of a transaction has that whole
transaction's counts credited to the tablespace it ends up in, since the
pending entry carries a single tablespace and is only resolved at flush
time.

This adds a pg_proc entry, so catversion will need to be bumped when
committed.
---
 doc/src/sgml/monitoring.sgml                  | 226 ++++++++++++++++++
 src/backend/catalog/system_views.sql          |  19 ++
 src/backend/commands/tablespace.c             |   4 +
 src/backend/storage/buffer/bufmgr.c           |  14 ++
 src/backend/storage/buffer/localbuf.c         |   4 +
 src/backend/storage/file/fd.c                 |   2 +-
 src/backend/utils/activity/Makefile           |   1 +
 src/backend/utils/activity/meson.build        |   1 +
 src/backend/utils/activity/pgstat.c           |  17 ++
 src/backend/utils/activity/pgstat_database.c  |  13 +-
 src/backend/utils/activity/pgstat_index.c     |  15 ++
 src/backend/utils/activity/pgstat_relation.c  |  46 +++-
 .../utils/activity/pgstat_tablespace.c        | 191 +++++++++++++++
 src/backend/utils/adt/pgstatfuncs.c           |  79 +++++-
 src/backend/utils/cache/relcache.c            |  16 ++
 src/include/catalog/pg_proc.dat               |   8 +
 src/include/pgstat.h                          |  33 ++-
 src/include/utils/backend_status.h            |   2 +-
 src/include/utils/pgstat_internal.h           |  14 ++
 src/include/utils/pgstat_kind.h               |  15 +-
 src/test/regress/expected/rules.out           |  16 ++
 src/test/regress/expected/stats.out           | 122 +++++++++-
 src/test/regress/expected/tablespace.out      |  38 +++
 src/test/regress/sql/stats.sql                |  61 +++++
 src/test/regress/sql/tablespace.sql           |  21 ++
 25 files changed, 954 insertions(+), 24 deletions(-)
 create mode 100644 src/backend/utils/activity/pgstat_tablespace.c

diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml
index 53d81ac63d1..39dbb508deb 100644
--- a/doc/src/sgml/monitoring.sgml
+++ b/doc/src/sgml/monitoring.sgml
@@ -555,6 +555,15 @@ postgres   27093  0.0  0.0  30096  2752 ?        Ss   11:34   0:00 postgres: ser
       </entry>
      </row>
 
+     <row>
+      <entry><structname>pg_stat_tablespace</structname><indexterm><primary>pg_stat_tablespace</primary></indexterm></entry>
+      <entry>One row per tablespace, showing statistics about blocks, temporary
+       files and tuples for relations in that tablespace. See
+       <link linkend="monitoring-pg-stat-tablespace-view">
+       <structname>pg_stat_tablespace</structname></link> for details.
+      </entry>
+     </row>
+
      <row>
       <entry><structname>pg_stat_subscription_stats</structname><indexterm><primary>pg_stat_subscription_stats</primary></indexterm></entry>
       <entry>One row per subscription, showing statistics about errors and conflicts.
@@ -5736,6 +5745,217 @@ description | Waiting for a newly initialized WAL file to reach durable storage
 
  </sect2>
 
+  <sect2 id="monitoring-pg-stat-tablespace-view">
+   <title><structname>pg_stat_tablespace</structname></title>
+
+   <indexterm>
+    <primary>pg_stat_tablespace</primary>
+   </indexterm>
+
+   <para>
+    The <structname>pg_stat_tablespace</structname> view will contain one row
+    for each tablespace in the cluster, showing statistics about blocks read
+    and hit, temporary file usage, and tuples read and written for relations
+    stored in that tablespace.
+   </para>
+
+   <para>
+    Note that temporary files are attributed to the tablespace they were
+    created in, which is controlled by <xref linkend="guc-temp-tablespaces"/>,
+    and is not necessarily the tablespace of any relation involved in the
+    query.
+   </para>
+
+   <table id="pg-stat-tablespace-view" xreflabel="pg_stat_tablespace">
+    <title><structname>pg_stat_tablespace</structname> View</title>
+    <tgroup cols="1">
+     <thead>
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         Column Type
+        </para>
+        <para>
+         Description
+        </para>
+       </entry>
+      </row>
+     </thead>
+
+     <tbody>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>tablespace_id</structfield> <type>oid</type>
+        </para>
+        <para>
+         OID of this tablespace
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>tablespace_name</structfield> <type>name</type>
+        </para>
+        <para>
+         Name of this tablespace
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>blks_read</structfield> <type>bigint</type>
+        </para>
+        <para>
+         Number of disk blocks read in this tablespace
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>blks_hit</structfield> <type>bigint</type>
+        </para>
+        <para>
+         Number of times disk blocks were found already in the buffer cache, so
+         that a read was not necessary (this only includes hits in the
+         PostgreSQL buffer cache, not the operating system's file system
+         cache)
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>blk_read_time</structfield> <type>double precision</type>
+        </para>
+        <para>
+         Time spent reading data file blocks in this tablespace by backends, in
+         milliseconds (if <xref linkend="guc-track-io-timing"/> is enabled,
+         otherwise zero)
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>blk_write_time</structfield> <type>double precision</type>
+        </para>
+        <para>
+         Time spent writing data file blocks in this tablespace by backends, in
+         milliseconds (if <xref linkend="guc-track-io-timing"/> is enabled,
+         otherwise zero)
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>temp_files</structfield> <type>bigint</type>
+        </para>
+        <para>
+         Number of temporary files created in this tablespace.  All temporary
+         files are counted, regardless of why the temporary file was created,
+         and regardless of the <xref linkend="guc-log-temp-files"/> setting.
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>temp_bytes</structfield> <type>bigint</type>
+        </para>
+        <para>
+         Total amount of data written to temporary files in this tablespace.
+         All temporary files are counted, regardless of why the temporary file
+         was created, and regardless of the
+         <xref linkend="guc-log-temp-files"/> setting.
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>tup_returned</structfield> <type>bigint</type>
+        </para>
+        <para>
+         Number of live rows fetched by sequential scans and index entries
+         returned by index scans for relations in this tablespace
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>tup_fetched</structfield> <type>bigint</type>
+        </para>
+        <para>
+         Number of live rows fetched by index scans for relations in this
+         tablespace
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>tup_inserted</structfield> <type>bigint</type>
+        </para>
+        <para>
+         Number of rows inserted into relations in this tablespace
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>tup_updated</structfield> <type>bigint</type>
+        </para>
+        <para>
+         Number of rows updated in relations in this tablespace
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>tup_deleted</structfield> <type>bigint</type>
+        </para>
+        <para>
+         Number of rows deleted from relations in this tablespace
+        </para>
+       </entry>
+      </row>
+
+      <row>
+       <entry role="catalog_table_entry">
+        <para role="column_definition">
+         <structfield>stats_reset</structfield> <type>timestamp with time zone</type>
+        </para>
+        <para>
+         Time at which these statistics were last reset
+        </para>
+       </entry>
+      </row>
+     </tbody>
+    </tgroup>
+   </table>
+  </sect2>
+
  <sect2 id="monitoring-stats-functions">
   <title>Statistics Functions</title>
 
@@ -6011,6 +6231,12 @@ description | Waiting for a newly initialized WAL file to reach durable storage
           <structname>pg_stat_slru</structname> view.
          </para>
         </listitem>
+        <listitem>
+         <para>
+          <literal>tablespace</literal>: Reset all the counters shown in the
+          <structname>pg_stat_tablespace</structname> view.
+         </para>
+        </listitem>
         <listitem>
          <para>
           <literal>wal</literal>: Reset all the counters shown in the
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 8612d99a890..4c2ba1480ae 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1146,6 +1146,25 @@ CREATE VIEW pg_stat_replication_slots AS
         LATERAL pg_stat_get_replication_slot(slot_name) as s
     WHERE r.datoid IS NOT NULL; -- excluding physical slots
 
+CREATE VIEW pg_stat_tablespace AS
+    SELECT
+        T.oid AS tablespace_id,
+        T.spcname AS tablespace_name,
+        S.blks_fetched - S.blks_hit AS blks_read,
+        S.blks_hit,
+        S.blk_read_time,
+        S.blk_write_time,
+        S.temp_files,
+        S.temp_bytes,
+        S.tup_returned,
+        S.tup_fetched,
+        S.tup_inserted,
+        S.tup_updated,
+        S.tup_deleted,
+        S.stats_reset
+    FROM pg_tablespace T,
+        LATERAL pg_stat_get_tablespace(T.oid) S;
+
 CREATE VIEW pg_stat_database AS
     SELECT
             D.oid AS datid,
diff --git a/src/backend/commands/tablespace.c b/src/backend/commands/tablespace.c
index e3c4a7fac87..874ba4b0d67 100644
--- a/src/backend/commands/tablespace.c
+++ b/src/backend/commands/tablespace.c
@@ -68,6 +68,7 @@
 #include "commands/tablespace.h"
 #include "common/file_perm.h"
 #include "miscadmin.h"
+#include "pgstat.h"
 #include "postmaster/bgwriter.h"
 #include "storage/fd.h"
 #include "storage/lwlock.h"
@@ -546,6 +547,9 @@ DropTableSpace(DropTableSpaceStmt *stmt)
 		(void) XLogInsert(RM_TBLSPC_ID, XLOG_TBLSPC_DROP);
 	}
 
+	/* Keep the cumulative stats system up-to-date */
+	pgstat_drop_tablespace(tablespaceoid);
+
 	/*
 	 * Note: because we checked that the tablespace was empty, there should be
 	 * no need to worry about flushing shared buffers or free space map
diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c
index 17f142e4c5b..172ce688706 100644
--- a/src/backend/storage/buffer/bufmgr.c
+++ b/src/backend/storage/buffer/bufmgr.c
@@ -1835,6 +1835,8 @@ WaitReadBuffers(ReadBuffersOperation *operation)
 				 */
 				pgstat_count_io_op_time(io_object, io_context, IOOP_READ,
 										io_start, 0, 0);
+				pgstat_count_tablespace_blk_read_time(operation->smgr->smgr_rlocator.locator.spcOid,
+													  io_start);
 			}
 			else
 			{
@@ -2155,6 +2157,8 @@ AsyncReadBuffers(ReadBuffersOperation *operation, int *nblocks_progress)
 				   io_pages, io_buffers_len);
 	pgstat_count_io_op_time(io_object, io_context, IOOP_READ,
 							io_start, 1, io_buffers_len * BLCKSZ);
+	pgstat_count_tablespace_blk_read_time(operation->smgr->smgr_rlocator.locator.spcOid,
+										  io_start);
 
 	if (persistence == RELPERSISTENCE_TEMP)
 		pgBufferUsage.local_blks_read += io_buffers_len;
@@ -3044,6 +3048,14 @@ ExtendBufferedRelShared(BufferManagerRelation bmr,
 	pgstat_count_io_op_time(IOOBJECT_RELATION, io_context, IOOP_EXTEND,
 							io_start, 1, extend_by * BLCKSZ);
 
+	/*
+	 * Take the tablespace from the smgr rather than bmr.rel, which is NULL
+	 * when the caller used BMR_SMGR() -- as XLogReadBufferExtended() does
+	 * during WAL replay.
+	 */
+	pgstat_count_tablespace_blk_write_time(BMR_GET_SMGR(bmr)->smgr_rlocator.locator.spcOid,
+										   io_start);
+
 	/* Set BM_VALID, terminate IO, and wake up any waiters */
 	for (uint32 i = 0; i < extend_by; i++)
 	{
@@ -4623,6 +4635,8 @@ FlushBuffer(BufferDesc *buf, SMgrRelation reln, IOObject io_object,
 	 */
 	pgstat_count_io_op_time(io_object, io_context,
 							IOOP_WRITE, io_start, 1, BLCKSZ);
+	pgstat_count_tablespace_blk_write_time(reln->smgr_rlocator.locator.spcOid,
+										   io_start);
 
 	pgBufferUsage.shared_blks_written++;
 
diff --git a/src/backend/storage/buffer/localbuf.c b/src/backend/storage/buffer/localbuf.c
index 4870c8e13d0..dfc9e4fca05 100644
--- a/src/backend/storage/buffer/localbuf.c
+++ b/src/backend/storage/buffer/localbuf.c
@@ -214,6 +214,8 @@ FlushLocalBuffer(BufferDesc *bufHdr, SMgrRelation reln)
 	/* Temporary table I/O does not use Buffer Access Strategies */
 	pgstat_count_io_op_time(IOOBJECT_TEMP_RELATION, IOCONTEXT_NORMAL,
 							IOOP_WRITE, io_start, 1, BLCKSZ);
+	pgstat_count_tablespace_blk_write_time(reln->smgr_rlocator.locator.spcOid,
+										   io_start);
 
 	/* Mark not-dirty */
 	TerminateLocalBufferIO(bufHdr, true, 0, false);
@@ -471,6 +473,8 @@ ExtendBufferedRelLocal(BufferManagerRelation bmr,
 
 	pgstat_count_io_op_time(IOOBJECT_TEMP_RELATION, IOCONTEXT_NORMAL, IOOP_EXTEND,
 							io_start, 1, extend_by * BLCKSZ);
+	pgstat_count_tablespace_blk_write_time(BMR_GET_SMGR(bmr)->smgr_rlocator.locator.spcOid,
+										   io_start);
 
 	for (uint32 i = 0; i < extend_by; i++)
 	{
diff --git a/src/backend/storage/file/fd.c b/src/backend/storage/file/fd.c
index 190c9974494..58fa52afbeb 100644
--- a/src/backend/storage/file/fd.c
+++ b/src/backend/storage/file/fd.c
@@ -1515,7 +1515,7 @@ FileAccess(File file)
 static void
 ReportTemporaryFileUsage(const char *path, pgoff_t size)
 {
-	pgstat_report_tempfile(size);
+	pgstat_report_tempfile(size, path);
 
 	if (log_temp_files >= 0)
 	{
diff --git a/src/backend/utils/activity/Makefile b/src/backend/utils/activity/Makefile
index 2e32d1485d6..e3292a22219 100644
--- a/src/backend/utils/activity/Makefile
+++ b/src/backend/utils/activity/Makefile
@@ -34,6 +34,7 @@ OBJS = \
 	pgstat_shmem.o \
 	pgstat_slru.o \
 	pgstat_subscription.o \
+	pgstat_tablespace.o \
 	pgstat_wal.o \
 	pgstat_xact.o \
 	wait_event.o \
diff --git a/src/backend/utils/activity/meson.build b/src/backend/utils/activity/meson.build
index e6dcb2e26fc..3049bdf4887 100644
--- a/src/backend/utils/activity/meson.build
+++ b/src/backend/utils/activity/meson.build
@@ -19,6 +19,7 @@ backend_sources += files(
   'pgstat_shmem.c',
   'pgstat_slru.c',
   'pgstat_subscription.c',
+  'pgstat_tablespace.c',
   'pgstat_wal.c',
   'pgstat_xact.c',
 )
diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index 4615f610106..97b1f2762cb 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -301,6 +301,23 @@ static const PgStat_KindInfo pgstat_kind_builtin_infos[PGSTAT_KIND_BUILTIN_SIZE]
 		.reset_timestamp_cb = pgstat_database_reset_timestamp_cb,
 	},
 
+	[PGSTAT_KIND_TABLESPACE] = {
+		.name = "tablespace",
+
+		.fixed_amount = false,
+		.write_to_file = true,
+		/* so pg_stat_tablespace can be read from any database */
+		.accessed_across_databases = true,
+
+		.shared_size = sizeof(PgStatShared_Tablespace),
+		.shared_data_off = offsetof(PgStatShared_Tablespace, stats),
+		.shared_data_len = sizeof(((PgStatShared_Tablespace *) 0)->stats),
+		.pending_size = sizeof(PgStat_StatTabspaceEntry),
+
+		.flush_pending_cb = pgstat_tablespace_flush_cb,
+		.reset_timestamp_cb = pgstat_tablespace_reset_timestamp_cb,
+	},
+
 	[PGSTAT_KIND_RELATION] = {
 		.name = "relation",
 
diff --git a/src/backend/utils/activity/pgstat_database.c b/src/backend/utils/activity/pgstat_database.c
index 7f3bc016593..58a0efa3e88 100644
--- a/src/backend/utils/activity/pgstat_database.c
+++ b/src/backend/utils/activity/pgstat_database.c
@@ -218,9 +218,10 @@ pgstat_report_checksum_failures_in_db(Oid dboid, int failurecount)
  * Report creation of temporary file.
  */
 void
-pgstat_report_tempfile(size_t filesize)
+pgstat_report_tempfile(size_t filesize, const char *path)
 {
 	PgStat_StatDBEntry *dbent;
+	Oid			spcoid;
 
 	if (!pgstat_track_counts)
 		return;
@@ -228,6 +229,16 @@ pgstat_report_tempfile(size_t filesize)
 	dbent = pgstat_prep_database_pending(MyDatabaseId);
 	dbent->temp_bytes += filesize;
 	dbent->temp_files++;
+
+	spcoid = pgstat_tablespace_from_tempfile_path(path);
+	if (OidIsValid(spcoid))
+	{
+		PgStat_StatTabspaceEntry *tsent;
+
+		tsent = pgstat_prep_tablespace_pending(spcoid);
+		tsent->temp_bytes += filesize;
+		tsent->temp_files++;
+	}
 }
 
 /*
diff --git a/src/backend/utils/activity/pgstat_index.c b/src/backend/utils/activity/pgstat_index.c
index a1f9a4c6ac1..c40cbce9641 100644
--- a/src/backend/utils/activity/pgstat_index.c
+++ b/src/backend/utils/activity/pgstat_index.c
@@ -80,6 +80,21 @@ pgstat_index_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
 	dbentry->blocks_fetched += lstats->idx.blocks_fetched;
 	dbentry->blocks_hit += lstats->idx.blocks_hit;
 
+	/*
+	 * Likewise for the tablespace the index lives in, which need not be the
+	 * one holding the table.
+	 */
+	if (OidIsValid(lstats->tablespace_oid))
+	{
+		PgStat_StatTabspaceEntry *tsentry;
+
+		tsentry = pgstat_prep_tablespace_pending(lstats->tablespace_oid);
+		tsentry->tuples_returned += lstats->idx.tuples_returned;
+		tsentry->tuples_fetched += lstats->idx.tuples_fetched;
+		tsentry->blocks_fetched += lstats->idx.blocks_fetched;
+		tsentry->blocks_hit += lstats->idx.blocks_hit;
+	}
+
 	return true;
 }
 
diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c
index 17746bf5c54..5a9f8e02985 100644
--- a/src/backend/utils/activity/pgstat_relation.c
+++ b/src/backend/utils/activity/pgstat_relation.c
@@ -37,12 +37,14 @@ typedef struct TwoPhasePgStatRecord
 	PgStat_Counter updated_pre_truncdrop;
 	PgStat_Counter deleted_pre_truncdrop;
 	Oid			id;				/* table's OID */
+	Oid			tablespace_oid; /* table's tablespace OID */
 	bool		shared;			/* is it a shared catalog? */
 	bool		truncdropped;	/* was the relation truncated/dropped? */
 } TwoPhasePgStatRecord;
 
 
 static PgStat_RelationStatus *pgstat_prep_relation_pending(PgStat_Kind kind,
+														   Oid tablespace_oid,
 														   Oid rel_id, bool isshared);
 static void add_tabstat_xact_level(PgStat_RelationStatus *pgstat_info, int nest_level);
 static void ensure_tabstat_xact_level(PgStat_RelationStatus *pgstat_info);
@@ -179,6 +181,7 @@ pgstat_assoc_relation(Relation rel)
 
 	/* find or make the PgStat_RelationStatus entry, and update link */
 	rel->pgstat_info = pgstat_prep_relation_pending(kind,
+													rel->rd_locator.spcOid,
 													RelationGetRelid(rel),
 													rel->rd_rel->relisshared);
 
@@ -206,6 +209,21 @@ pgstat_unlink_relation(Relation rel)
 	rel->pgstat_info = NULL;
 }
 
+/*
+ * Refresh the tablespace recorded in a relation's pending statistics.
+ *
+ * pgstat_assoc_relation() records the tablespace once, when the pending entry
+ * is first created, but a relation can subsequently be moved to a different
+ * tablespace.  RelationRebuildRelation() preserves pgstat_info across a rebuild,
+ * so without this the entry would keep crediting the old tablespace.
+ */
+void
+pgstat_relation_update_tablespace(Relation rel)
+{
+	if (rel->pgstat_info != NULL)
+		rel->pgstat_info->tablespace_oid = rel->rd_locator.spcOid;
+}
+
 /*
  * Ensure that stats are dropped if transaction aborts.
  */
@@ -780,6 +798,7 @@ AtPrepare_PgStat_Relations(PgStat_SubXactStatus *xact_state)
 		record.updated_pre_truncdrop = trans->updated_pre_truncdrop;
 		record.deleted_pre_truncdrop = trans->deleted_pre_truncdrop;
 		record.id = relstat->tab.id;
+		record.tablespace_oid = relstat->tablespace_oid;
 		record.shared = relstat->tab.shared;
 		record.truncdropped = trans->truncdropped;
 
@@ -823,7 +842,9 @@ pgstat_twophase_postcommit(FullTransactionId fxid, uint16 info,
 	PgStat_RelationStatus *pgstat_info;
 
 	/* Find or create a relstat entry for the rel */
-	pgstat_info = pgstat_prep_relation_pending(PGSTAT_KIND_RELATION, rec->id, rec->shared);
+	pgstat_info = pgstat_prep_relation_pending(PGSTAT_KIND_RELATION,
+											   rec->tablespace_oid, rec->id,
+											   rec->shared);
 
 	/* Same math as in AtEOXact_PgStat, commit case */
 	pgstat_info->tab.counts.tuples_inserted += rec->tuples_inserted;
@@ -859,7 +880,9 @@ pgstat_twophase_postabort(FullTransactionId fxid, uint16 info,
 	PgStat_RelationStatus *pgstat_info;
 
 	/* Find or create a relstat entry for the rel */
-	pgstat_info = pgstat_prep_relation_pending(PGSTAT_KIND_RELATION, rec->id, rec->shared);
+	pgstat_info = pgstat_prep_relation_pending(PGSTAT_KIND_RELATION,
+											   rec->tablespace_oid, rec->id,
+											   rec->shared);
 
 	/* Same math as in AtEOXact_PgStat, abort case */
 	if (rec->truncdropped)
@@ -967,6 +990,21 @@ pgstat_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
 	dbentry->blocks_fetched += lstats->tab.counts.blocks_fetched;
 	dbentry->blocks_hit += lstats->tab.counts.blocks_hit;
 
+	/* Likewise for the tablespace the relation lives in */
+	if (OidIsValid(lstats->tablespace_oid))
+	{
+		PgStat_StatTabspaceEntry *tsentry;
+
+		tsentry = pgstat_prep_tablespace_pending(lstats->tablespace_oid);
+		tsentry->tuples_returned += lstats->tab.counts.tuples_returned;
+		tsentry->tuples_fetched += lstats->tab.counts.tuples_fetched;
+		tsentry->tuples_inserted += lstats->tab.counts.tuples_inserted;
+		tsentry->tuples_updated += lstats->tab.counts.tuples_updated;
+		tsentry->tuples_deleted += lstats->tab.counts.tuples_deleted;
+		tsentry->blocks_fetched += lstats->tab.counts.blocks_fetched;
+		tsentry->blocks_hit += lstats->tab.counts.blocks_hit;
+	}
+
 	return true;
 }
 
@@ -990,7 +1028,8 @@ pgstat_relation_reset_timestamp_cb(PgStatShared_Common *header, TimestampTz ts)
  * initialized if not exists.
  */
 static PgStat_RelationStatus *
-pgstat_prep_relation_pending(PgStat_Kind kind, Oid rel_id, bool isshared)
+pgstat_prep_relation_pending(PgStat_Kind kind, Oid tablespace_oid, Oid rel_id,
+							 bool isshared)
 {
 	PgStat_EntryRef *entry_ref;
 	PgStat_RelationStatus *pending;
@@ -1000,6 +1039,7 @@ pgstat_prep_relation_pending(PgStat_Kind kind, Oid rel_id, bool isshared)
 										  rel_id, NULL);
 	pending = entry_ref->pending;
 	pending->kind = kind;
+	pending->tablespace_oid = tablespace_oid;
 	if (kind != PGSTAT_KIND_INDEX)
 	{
 		pending->tab.id = rel_id;
diff --git a/src/backend/utils/activity/pgstat_tablespace.c b/src/backend/utils/activity/pgstat_tablespace.c
new file mode 100644
index 00000000000..f2060373cbe
--- /dev/null
+++ b/src/backend/utils/activity/pgstat_tablespace.c
@@ -0,0 +1,191 @@
+/* -------------------------------------------------------------------------
+ *
+ * pgstat_tablespace.c
+ *	  Implementation of tablespace statistics.
+ *
+ * This file contains the implementation of tablespace statistics.  It is kept
+ * separate from other statistics implementations for the sake of readability.
+ *
+ * Tablespace statistics are aggregated from several sources: block and tuple
+ * counts are folded in when relation and index statistics are flushed, I/O
+ * timings are reported by the buffer manager, and temporary file usage is
+ * reported by fd.c.
+ *
+ * Copyright (c) 2001-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/backend/utils/activity/pgstat_tablespace.c
+ * -------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "catalog/pg_tablespace_d.h"
+#include "common/relpath.h"
+#include "storage/fd.h"
+#include "utils/pgstat_internal.h"
+#include "utils/timestamp.h"
+
+
+/*
+ * Remove entry for the tablespace being dropped.
+ */
+void
+pgstat_drop_tablespace(Oid spcoid)
+{
+	pgstat_drop_transactional(PGSTAT_KIND_TABLESPACE, InvalidOid, spcoid);
+}
+
+/*
+ * Fetch tablespace statistics.
+ */
+PgStat_StatTabspaceEntry *
+pgstat_fetch_stat_tabspaceentry(Oid spcoid)
+{
+	return (PgStat_StatTabspaceEntry *)
+		pgstat_fetch_entry(PGSTAT_KIND_TABLESPACE, InvalidOid, spcoid, NULL);
+}
+
+/*
+ * Prepare for reporting tablespace stats.
+ */
+PgStat_StatTabspaceEntry *
+pgstat_prep_tablespace_pending(Oid spcoid)
+{
+	PgStat_EntryRef *entry_ref;
+
+	Assert(OidIsValid(spcoid));
+
+	entry_ref = pgstat_prep_pending_entry(PGSTAT_KIND_TABLESPACE,
+										  InvalidOid, spcoid, NULL);
+
+	return (PgStat_StatTabspaceEntry *) entry_ref->pending;
+}
+
+/*
+ * Determine which tablespace a temporary file belongs to, based on its path.
+ *
+ * fd.c does not remember the tablespace a temporary file was created in -- by
+ * the time the file is deleted and its usage reported, only the path is still
+ * available.  Rather than widen Vfd, we recover the OID from the path.
+ *
+ * TempTablespacePath() builds these paths, and produces just two shapes: one
+ * rooted at PG_TBLSPC_DIR for a real tablespace, and one rooted in the data
+ * directory for the default tablespace.  Rather than hard-code the latter, we
+ * ask TempTablespacePath() itself what it looks like, so this stays correct if
+ * the layout ever changes.  Note that it also maps the global tablespace onto
+ * the default one, so temporary files never belong to pg_global.
+ *
+ * Returns InvalidOid if the path is not recognized, in which case the caller
+ * simply does not attribute the file to any tablespace.
+ */
+Oid
+pgstat_tablespace_from_tempfile_path(const char *path)
+{
+	char		defaultpath[MAXPGPATH];
+
+	if (path == NULL)
+		return InvalidOid;
+
+	if (strncmp(path, PG_TBLSPC_DIR_SLASH, strlen(PG_TBLSPC_DIR_SLASH)) == 0)
+		return atooid(path + strlen(PG_TBLSPC_DIR_SLASH));
+
+	TempTablespacePath(defaultpath, DEFAULTTABLESPACE_OID);
+	if (strncmp(path, defaultpath, strlen(defaultpath)) == 0)
+		return DEFAULTTABLESPACE_OID;
+
+	return InvalidOid;
+}
+
+/*
+ * Count time spent reading blocks in a tablespace.
+ *
+ * "io_start" is the value returned by pgstat_prepare_io_time(); it is zero
+ * when I/O timing is disabled, in which case we must not read the clock at
+ * all -- doing so would both cost time on a hot path and, worse, produce a
+ * bogus duration measured from the epoch.
+ */
+void
+pgstat_count_tablespace_blk_read_time(Oid spcoid, instr_time io_start)
+{
+	PgStat_StatTabspaceEntry *tsent;
+	instr_time	io_time;
+
+	if (INSTR_TIME_IS_ZERO(io_start) || !OidIsValid(spcoid))
+		return;
+
+	INSTR_TIME_SET_CURRENT(io_time);
+	INSTR_TIME_SUBTRACT(io_time, io_start);
+
+	tsent = pgstat_prep_tablespace_pending(spcoid);
+	tsent->blk_read_time += INSTR_TIME_GET_MICROSEC(io_time);
+}
+
+/*
+ * Count time spent writing or extending blocks in a tablespace.
+ *
+ * See pgstat_count_tablespace_blk_read_time() for the io_start convention.
+ */
+void
+pgstat_count_tablespace_blk_write_time(Oid spcoid, instr_time io_start)
+{
+	PgStat_StatTabspaceEntry *tsent;
+	instr_time	io_time;
+
+	if (INSTR_TIME_IS_ZERO(io_start) || !OidIsValid(spcoid))
+		return;
+
+	INSTR_TIME_SET_CURRENT(io_time);
+	INSTR_TIME_SUBTRACT(io_time, io_start);
+
+	tsent = pgstat_prep_tablespace_pending(spcoid);
+	tsent->blk_write_time += INSTR_TIME_GET_MICROSEC(io_time);
+}
+
+/*
+ * Flush out pending stats for the entry.
+ */
+bool
+pgstat_tablespace_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
+{
+	PgStatShared_Tablespace *sharedent;
+	PgStat_StatTabspaceEntry *pendingent;
+
+	pendingent = (PgStat_StatTabspaceEntry *) entry_ref->pending;
+	sharedent = (PgStatShared_Tablespace *) entry_ref->shared_stats;
+
+	if (!pgstat_lock_entry(entry_ref, nowait))
+		return false;
+
+#define PGSTAT_ACCUM_TABSPACECOUNT(item)		\
+	(sharedent)->stats.item += (pendingent)->item
+
+	PGSTAT_ACCUM_TABSPACECOUNT(blocks_fetched);
+	PGSTAT_ACCUM_TABSPACECOUNT(blocks_hit);
+	PGSTAT_ACCUM_TABSPACECOUNT(blk_read_time);
+	PGSTAT_ACCUM_TABSPACECOUNT(blk_write_time);
+	PGSTAT_ACCUM_TABSPACECOUNT(temp_files);
+	PGSTAT_ACCUM_TABSPACECOUNT(temp_bytes);
+	PGSTAT_ACCUM_TABSPACECOUNT(tuples_returned);
+	PGSTAT_ACCUM_TABSPACECOUNT(tuples_fetched);
+	PGSTAT_ACCUM_TABSPACECOUNT(tuples_inserted);
+	PGSTAT_ACCUM_TABSPACECOUNT(tuples_updated);
+	PGSTAT_ACCUM_TABSPACECOUNT(tuples_deleted);
+
+#undef PGSTAT_ACCUM_TABSPACECOUNT
+
+	pgstat_unlock_entry(entry_ref);
+
+	memset(pendingent, 0, sizeof(*pendingent));
+
+	return true;
+}
+
+/*
+ * Reset stats reset timestamp.
+ */
+void
+pgstat_tablespace_reset_timestamp_cb(PgStatShared_Common *header, TimestampTz ts)
+{
+	((PgStatShared_Tablespace *) header)->stats.stat_reset_timestamp = ts;
+}
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 0d47d745c18..a59c0196001 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2098,6 +2098,7 @@ pg_stat_reset_shared(PG_FUNCTION_ARGS)
 		pgstat_reset_of_kind(PGSTAT_KIND_LOCK);
 		XLogPrefetchResetStats();
 		pgstat_reset_of_kind(PGSTAT_KIND_SLRU);
+		pgstat_reset_of_kind(PGSTAT_KIND_TABLESPACE);
 		pgstat_reset_of_kind(PGSTAT_KIND_WAL);
 
 		PG_RETURN_VOID();
@@ -2119,13 +2120,15 @@ pg_stat_reset_shared(PG_FUNCTION_ARGS)
 		XLogPrefetchResetStats();
 	else if (strcmp(target, "slru") == 0)
 		pgstat_reset_of_kind(PGSTAT_KIND_SLRU);
+	else if (strcmp(target, "tablespace") == 0)
+		pgstat_reset_of_kind(PGSTAT_KIND_TABLESPACE);
 	else if (strcmp(target, "wal") == 0)
 		pgstat_reset_of_kind(PGSTAT_KIND_WAL);
 	else
 		ereport(ERROR,
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
 				 errmsg("unrecognized reset target: \"%s\"", target),
-				 errhint("Target must be \"archiver\", \"bgwriter\", \"checkpointer\", \"io\", \"lock\", \"recovery_prefetch\", \"slru\", or \"wal\".")));
+				 errhint("Target must be \"archiver\", \"bgwriter\", \"checkpointer\", \"io\", \"lock\", \"recovery_prefetch\", \"slru\", \"tablespace\", or \"wal\".")));
 
 	PG_RETURN_VOID();
 }
@@ -2493,6 +2496,80 @@ pg_stat_get_subscription_stats(PG_FUNCTION_ARGS)
 	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
 }
 
+/*
+ * Returns statistics for the given tablespace.  If no statistics have been
+ * collected yet, all-zero stats are returned.
+ */
+Datum
+pg_stat_get_tablespace(PG_FUNCTION_ARGS)
+{
+#define PG_STAT_GET_TABLESPACE_COLS	12
+	Oid			spcoid = PG_GETARG_OID(0);
+	TupleDesc	tupdesc;
+	Datum		values[PG_STAT_GET_TABLESPACE_COLS] = {0};
+	bool		nulls[PG_STAT_GET_TABLESPACE_COLS] = {0};
+	PgStat_StatTabspaceEntry *tsentry;
+	PgStat_StatTabspaceEntry allzero;
+	int			i = 0;
+
+	tupdesc = CreateTemplateTupleDesc(PG_STAT_GET_TABLESPACE_COLS);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 1, "blks_fetched",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 2, "blks_hit",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 3, "blk_read_time",
+					   FLOAT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 4, "blk_write_time",
+					   FLOAT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 5, "temp_files",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 6, "temp_bytes",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 7, "tup_returned",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 8, "tup_fetched",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 9, "tup_inserted",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 10, "tup_updated",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 11, "tup_deleted",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 12, "stats_reset",
+					   TIMESTAMPTZOID, -1, 0);
+	TupleDescFinalize(tupdesc);
+	tupdesc = BlessTupleDesc(tupdesc);
+
+	tsentry = pgstat_fetch_stat_tabspaceentry(spcoid);
+	if (!tsentry)
+	{
+		memset(&allzero, 0, sizeof(PgStat_StatTabspaceEntry));
+		tsentry = &allzero;
+	}
+
+	values[i++] = Int64GetDatum(tsentry->blocks_fetched);
+	values[i++] = Int64GetDatum(tsentry->blocks_hit);
+	values[i++] = Float8GetDatum(pg_stat_us_to_ms(tsentry->blk_read_time));
+	values[i++] = Float8GetDatum(pg_stat_us_to_ms(tsentry->blk_write_time));
+	values[i++] = Int64GetDatum(tsentry->temp_files);
+	values[i++] = Int64GetDatum(tsentry->temp_bytes);
+	values[i++] = Int64GetDatum(tsentry->tuples_returned);
+	values[i++] = Int64GetDatum(tsentry->tuples_fetched);
+	values[i++] = Int64GetDatum(tsentry->tuples_inserted);
+	values[i++] = Int64GetDatum(tsentry->tuples_updated);
+	values[i++] = Int64GetDatum(tsentry->tuples_deleted);
+
+	if (tsentry->stat_reset_timestamp == 0)
+		nulls[i] = true;
+	else
+		values[i] = TimestampTzGetDatum(tsentry->stat_reset_timestamp);
+
+	Assert(i + 1 == PG_STAT_GET_TABLESPACE_COLS);
+
+	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
+#undef PG_STAT_GET_TABLESPACE_COLS
+}
+
 /*
  * Checks for presence of stats for object with provided kind, database oid,
  * object oid.
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index f475d703977..cde0d8d364d 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -1351,6 +1351,15 @@ RelationInitPhysicalAddr(Relation relation)
 	else
 		relation->rd_locator.dbOid = MyDatabaseId;
 
+	/*
+	 * The relation may have been moved to another tablespace, so keep the
+	 * tablespace recorded for statistics purposes in step.  Paths that reload
+	 * an entry in place, such as RelationReloadIndexInfo(), reach here with a
+	 * live pgstat_info; RelationRebuildRelation() instead builds a fresh entry
+	 * and swaps pgstat_info back afterwards, so it repeats this itself.
+	 */
+	pgstat_relation_update_tablespace(relation);
+
 	if (relation->rd_rel->relfilenode)
 	{
 		/*
@@ -2760,6 +2769,13 @@ RelationRebuildRelation(Relation relation)
 		/* pgstat_info / enabled must be preserved */
 		SWAPFIELD(struct PgStat_RelationStatus *, pgstat_info);
 		SWAPFIELD(bool, pgstat_enabled);
+
+		/*
+		 * RelationInitPhysicalAddr() ran on the newly built entry, which had
+		 * no pgstat_info yet, so redo the refresh now that the preserved
+		 * pgstat_info and the rebuilt rd_locator are on the same entry.
+		 */
+		pgstat_relation_update_tablespace(relation);
 		/* preserve old partition key if we have one */
 		if (keep_partkey)
 		{
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 6979c7d1161..05e279ab4d1 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -6122,6 +6122,14 @@
   proargnames => '{name,blks_zeroed,blks_hit,blks_read,blks_written,blks_exists,flushes,truncates,stats_reset}',
   prosrc => 'pg_stat_get_slru' },
 
+{ oid => '8463', descr => 'statistics: information about tablespace',
+  proname => 'pg_stat_get_tablespace', provolatile => 's',
+  proparallel => 'r', prorettype => 'record', proargtypes => 'oid',
+  proallargtypes => '{oid,int8,int8,float8,float8,int8,int8,int8,int8,int8,int8,int8,timestamptz}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{spcoid,blks_fetched,blks_hit,blk_read_time,blk_write_time,temp_files,temp_bytes,tup_returned,tup_fetched,tup_inserted,tup_updated,tup_deleted,stats_reset}',
+  prosrc => 'pg_stat_get_tablespace' },
+
 { oid => '2978', descr => 'statistics: number of function calls',
   proname => 'pg_stat_get_function_calls', provolatile => 's',
   proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index 204782fd630..dd7de917cff 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -203,6 +203,7 @@ typedef struct PgStat_RelationStatus
 {
 	PgStat_Kind kind;			/* PGSTAT_KIND_RELATION or PGSTAT_KIND_INDEX */
 	Relation	relation;		/* rel that is using this entry */
+	Oid			tablespace_oid; /* tablespace the relation lives in */
 	union
 	{
 		/* table counters */
@@ -251,7 +252,7 @@ typedef struct PgStat_TableXactStatus
  * ------------------------------------------------------------
  */
 
-#define PGSTAT_FILE_FORMAT_ID	0x01A5BCBD
+#define PGSTAT_FILE_FORMAT_ID	0x01A5BCBE
 
 typedef struct PgStat_ArchiverStats
 {
@@ -435,6 +436,23 @@ typedef struct PgStat_StatDBEntry
 	TimestampTz stat_reset_timestamp;
 } PgStat_StatDBEntry;
 
+typedef struct PgStat_StatTabspaceEntry
+{
+	PgStat_Counter blocks_fetched;
+	PgStat_Counter blocks_hit;
+	PgStat_Counter blk_read_time;	/* times in microseconds */
+	PgStat_Counter blk_write_time;
+	PgStat_Counter temp_files;
+	PgStat_Counter temp_bytes;
+	PgStat_Counter tuples_returned;
+	PgStat_Counter tuples_fetched;
+	PgStat_Counter tuples_inserted;
+	PgStat_Counter tuples_updated;
+	PgStat_Counter tuples_deleted;
+
+	TimestampTz stat_reset_timestamp;
+} PgStat_StatTabspaceEntry;
+
 typedef struct PgStat_StatFuncEntry
 {
 	PgStat_Counter numcalls;
@@ -755,6 +773,7 @@ extern void pgstat_copy_relation_stats(Relation dst, Relation src);
 extern void pgstat_init_relation(Relation rel);
 extern void pgstat_assoc_relation(Relation rel);
 extern void pgstat_unlink_relation(Relation rel);
+extern void pgstat_relation_update_tablespace(Relation rel);
 
 extern void pgstat_report_vacuum(Relation rel, PgStat_Counter livetuples,
 								 PgStat_Counter deadtuples,
@@ -861,6 +880,18 @@ extern PgStat_StatIdxEntry *pgstat_fetch_stat_idxentry_ext(bool shared,
 														   bool *may_free);
 
 
+/*
+ * Functions in pgstat_tablespace.c
+ */
+
+extern void pgstat_drop_tablespace(Oid spcoid);
+extern PgStat_StatTabspaceEntry *pgstat_fetch_stat_tabspaceentry(Oid spcoid);
+extern PgStat_StatTabspaceEntry *pgstat_prep_tablespace_pending(Oid spcoid);
+extern Oid	pgstat_tablespace_from_tempfile_path(const char *path);
+extern void pgstat_count_tablespace_blk_read_time(Oid spcoid, instr_time io_start);
+extern void pgstat_count_tablespace_blk_write_time(Oid spcoid, instr_time io_start);
+
+
 /*
  * Functions in pgstat_replslot.c
  */
diff --git a/src/include/utils/backend_status.h b/src/include/utils/backend_status.h
index a334e096e4a..5ef7b5aaaed 100644
--- a/src/include/utils/backend_status.h
+++ b/src/include/utils/backend_status.h
@@ -315,7 +315,7 @@ extern void pgstat_clear_backend_activity_snapshot(void);
 extern void pgstat_report_activity(BackendState state, const char *cmd_str);
 extern void pgstat_report_query_id(int64 query_id, bool force);
 extern void pgstat_report_plan_id(int64 plan_id, bool force);
-extern void pgstat_report_tempfile(size_t filesize);
+extern void pgstat_report_tempfile(size_t filesize, const char *path);
 extern void pgstat_report_appname(const char *appname);
 extern void pgstat_report_xact_timestamp(TimestampTz tstamp);
 extern const char *pgstat_get_backend_current_activity(int pid, bool checkUser);
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index 14369e59a1c..6bd05ba5cdb 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -502,6 +502,12 @@ typedef struct PgStatShared_Database
 	PgStat_StatDBEntry stats;
 } PgStatShared_Database;
 
+typedef struct PgStatShared_Tablespace
+{
+	PgStatShared_Common header;
+	PgStat_StatTabspaceEntry stats;
+} PgStatShared_Tablespace;
+
 typedef struct PgStatShared_Relation
 {
 	PgStatShared_Common header;
@@ -753,6 +759,14 @@ extern bool pgstat_database_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
 extern void pgstat_database_reset_timestamp_cb(PgStatShared_Common *header, TimestampTz ts);
 
 
+/*
+ * Functions in pgstat_tablespace.c
+ */
+
+extern bool pgstat_tablespace_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
+extern void pgstat_tablespace_reset_timestamp_cb(PgStatShared_Common *header, TimestampTz ts);
+
+
 /*
  * Functions in pgstat_function.c
  */
diff --git a/src/include/utils/pgstat_kind.h b/src/include/utils/pgstat_kind.h
index 45ca599d0dd..811b79f3b10 100644
--- a/src/include/utils/pgstat_kind.h
+++ b/src/include/utils/pgstat_kind.h
@@ -31,15 +31,16 @@
 #define PGSTAT_KIND_REPLSLOT	5	/* per-slot statistics */
 #define PGSTAT_KIND_SUBSCRIPTION	6	/* per-subscription statistics */
 #define PGSTAT_KIND_BACKEND	7	/* per-backend statistics */
+#define PGSTAT_KIND_TABLESPACE	8	/* per-tablespace statistics */
 
 /* stats for fixed-numbered objects */
-#define PGSTAT_KIND_ARCHIVER	8
-#define PGSTAT_KIND_BGWRITER	9
-#define PGSTAT_KIND_CHECKPOINTER	10
-#define PGSTAT_KIND_IO	11
-#define PGSTAT_KIND_LOCK	12
-#define PGSTAT_KIND_SLRU	13
-#define PGSTAT_KIND_WAL	14
+#define PGSTAT_KIND_ARCHIVER	9
+#define PGSTAT_KIND_BGWRITER	10
+#define PGSTAT_KIND_CHECKPOINTER	11
+#define PGSTAT_KIND_IO	12
+#define PGSTAT_KIND_LOCK	13
+#define PGSTAT_KIND_SLRU	14
+#define PGSTAT_KIND_WAL	15
 
 #define PGSTAT_KIND_BUILTIN_MIN PGSTAT_KIND_DATABASE
 #define PGSTAT_KIND_BUILTIN_MAX PGSTAT_KIND_WAL
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 1a29d46213e..01f5d4555a0 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2371,6 +2371,22 @@ pg_stat_sys_tables| SELECT relid,
     stats_reset
    FROM pg_stat_all_tables
   WHERE ((schemaname = ANY (ARRAY['pg_catalog'::name, 'information_schema'::name])) OR (schemaname ~ '^pg_toast'::text));
+pg_stat_tablespace| SELECT t.oid AS tablespace_id,
+    t.spcname AS tablespace_name,
+    (s.blks_fetched - s.blks_hit) AS blks_read,
+    s.blks_hit,
+    s.blk_read_time,
+    s.blk_write_time,
+    s.temp_files,
+    s.temp_bytes,
+    s.tup_returned,
+    s.tup_fetched,
+    s.tup_inserted,
+    s.tup_updated,
+    s.tup_deleted,
+    s.stats_reset
+   FROM pg_tablespace t,
+    LATERAL pg_stat_get_tablespace(t.oid) s(blks_fetched, blks_hit, blk_read_time, blk_write_time, temp_files, temp_bytes, tup_returned, tup_fetched, tup_inserted, tup_updated, tup_deleted, stats_reset);
 pg_stat_user_functions| SELECT p.oid AS funcid,
     n.nspname AS schemaname,
     p.proname AS funcname,
diff --git a/src/test/regress/expected/stats.out b/src/test/regress/expected/stats.out
index c682a9ed60a..b78255a0423 100644
--- a/src/test/regress/expected/stats.out
+++ b/src/test/regress/expected/stats.out
@@ -121,14 +121,15 @@ SELECT id, name, fixed_amount,
   5 | replslot     | f            | t         | t
   6 | subscription | f            | t         | t
   7 | backend      | f            | t         | f
-  8 | archiver     | t            | f         | t
-  9 | bgwriter     | t            | f         | t
- 10 | checkpointer | t            | f         | t
- 11 | io           | t            | f         | t
- 12 | lock         | t            | f         | t
- 13 | slru         | t            | f         | t
- 14 | wal          | t            | f         | t
-(14 rows)
+  8 | tablespace   | f            | t         | t
+  9 | archiver     | t            | f         | t
+ 10 | bgwriter     | t            | f         | t
+ 11 | checkpointer | t            | f         | t
+ 12 | io           | t            | f         | t
+ 13 | lock         | t            | f         | t
+ 14 | slru         | t            | f         | t
+ 15 | wal          | t            | f         | t
+(15 rows)
 
 -- ensure that both seqscan and indexscan plans are allowed
 SET enable_seqscan TO on;
@@ -1181,7 +1182,7 @@ SELECT stats_reset > :'wal_reset_ts'::timestamptz FROM pg_stat_wal;
 -- Test error case for reset_shared with unknown stats type
 SELECT pg_stat_reset_shared('unknown');
 ERROR:  unrecognized reset target: "unknown"
-HINT:  Target must be "archiver", "bgwriter", "checkpointer", "io", "lock", "recovery_prefetch", "slru", or "wal".
+HINT:  Target must be "archiver", "bgwriter", "checkpointer", "io", "lock", "recovery_prefetch", "slru", "tablespace", or "wal".
 -- Test that reset works for pg_stat_database and pg_stat_database_conflicts
 -- Since pg_stat_database stats_reset starts out as NULL, reset it once first so that we
 -- have a baseline for comparison. The same for pg_stat_database_conflicts as it shares
@@ -2069,4 +2070,107 @@ SELECT fastpath_exceeded > :backend_fastpath_exceeded_before
 (1 row)
 
 DROP TABLE part_test;
+-- Test pg_stat_tablespace
+-- pg_default and pg_global always exist
+SELECT tablespace_name FROM pg_stat_tablespace
+  WHERE tablespace_name IN ('pg_default', 'pg_global')
+  ORDER BY tablespace_name;
+ tablespace_name 
+-----------------
+ pg_default
+ pg_global
+(2 rows)
+
+-- Check only that the counters move, not by how much.  pg_stat_tablespace
+-- aggregates every relation in the tablespace and other sessions in this
+-- parallel group are busy in pg_default too, so no exact value is
+-- reproducible.  Block reads and I/O timings may legitimately not move at
+-- all, since they depend on what happens to be cached and on
+-- track_io_timing, so those are only checked for sanity.
+SELECT tup_inserted AS ts_ins_before,
+       tup_updated AS ts_upd_before,
+       tup_deleted AS ts_del_before,
+       tup_returned AS ts_ret_before,
+       blks_hit AS ts_hit_before
+  FROM pg_stat_tablespace WHERE tablespace_name = 'pg_default' \gset
+CREATE TABLE test_tablespace_stats (a int);
+INSERT INTO test_tablespace_stats SELECT generate_series(1, 100);
+UPDATE test_tablespace_stats SET a = a + 1 WHERE a > 50;
+DELETE FROM test_tablespace_stats WHERE a > 90;
+SELECT count(*) > 0 FROM test_tablespace_stats;
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT tup_inserted > :ts_ins_before AS inserts_counted,
+       tup_updated > :ts_upd_before AS updates_counted,
+       tup_deleted > :ts_del_before AS deletes_counted,
+       tup_returned > :ts_ret_before AS returns_counted,
+       blks_hit > :ts_hit_before AS hits_counted,
+       blks_read >= 0 AS blks_read_sane,
+       blk_read_time >= 0 AS blk_read_time_sane,
+       blk_write_time >= 0 AS blk_write_time_sane
+  FROM pg_stat_tablespace WHERE tablespace_name = 'pg_default';
+ inserts_counted | updates_counted | deletes_counted | returns_counted | hits_counted | blks_read_sane | blk_read_time_sane | blk_write_time_sane 
+-----------------+-----------------+-----------------+-----------------+--------------+----------------+--------------------+---------------------
+ t               | t               | t               | t               | t            | t              | t                  | t
+(1 row)
+
+DROP TABLE test_tablespace_stats;
+-- Temporary files are attributed to the tablespace they were created in,
+-- which without temp_tablespaces set is pg_default.
+SELECT temp_files AS ts_tmpf_before, temp_bytes AS ts_tmpb_before
+  FROM pg_stat_tablespace WHERE tablespace_name = 'pg_default' \gset
+SET work_mem = '64kB';
+SELECT count(*) > 0 FROM
+  (SELECT * FROM generate_series(1, 10000) AS s ORDER BY s DESC) AS foo;
+ ?column? 
+----------
+ t
+(1 row)
+
+RESET work_mem;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT temp_files > :ts_tmpf_before AS temp_files_counted,
+       temp_bytes > :ts_tmpb_before AS temp_bytes_counted
+  FROM pg_stat_tablespace WHERE tablespace_name = 'pg_default';
+ temp_files_counted | temp_bytes_counted 
+--------------------+--------------------
+ t                  | t
+(1 row)
+
+-- Resetting sets the timestamp, and resetting again does not move it backwards
+SELECT pg_stat_reset_shared('tablespace');
+ pg_stat_reset_shared 
+----------------------
+ 
+(1 row)
+
+SELECT stats_reset AS ts_reset_before FROM pg_stat_tablespace
+  WHERE tablespace_name = 'pg_default' \gset
+SELECT pg_stat_reset_shared('tablespace');
+ pg_stat_reset_shared 
+----------------------
+ 
+(1 row)
+
+SELECT stats_reset >= :'ts_reset_before'::timestamptz FROM pg_stat_tablespace
+  WHERE tablespace_name = 'pg_default';
+ ?column? 
+----------
+ t
+(1 row)
+
 -- End of Stats Test
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..3a8124e937c 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,44 @@ ERROR:  permission denied for tablespace regress_tblspace
 REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
 ERROR:  permission denied for tablespace regress_tblspace
 RESET ROLE;
+-- A relation moved to another tablespace must be credited to the new one in
+-- pg_stat_tablespace.  pgstat_info is preserved across a relcache rebuild, so
+-- doing this while the relation is already in use exercises
+-- pgstat_relation_update_tablespace().
+CREATE TABLE tablespace_stats_move (a int);
+INSERT INTO tablespace_stats_move SELECT generate_series(1, 10);
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT tup_inserted AS stats_move_before FROM pg_stat_tablespace
+  WHERE tablespace_name = 'regress_tblspace' \gset
+BEGIN;
+SELECT count(*) > 0 FROM tablespace_stats_move;
+ ?column? 
+----------
+ t
+(1 row)
+
+ALTER TABLE tablespace_stats_move SET TABLESPACE regress_tblspace;
+INSERT INTO tablespace_stats_move SELECT generate_series(1, 10);
+COMMIT;
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT tup_inserted > :stats_move_before AS credited_to_new_tablespace
+  FROM pg_stat_tablespace WHERE tablespace_name = 'regress_tblspace';
+ credited_to_new_tablespace 
+----------------------------
+ t
+(1 row)
+
+DROP TABLE tablespace_stats_move;
 ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
 ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
 ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/stats.sql b/src/test/regress/sql/stats.sql
index d4623c32cd3..b962d432f1c 100644
--- a/src/test/regress/sql/stats.sql
+++ b/src/test/regress/sql/stats.sql
@@ -1024,4 +1024,65 @@ SELECT fastpath_exceeded > :backend_fastpath_exceeded_before
 
 DROP TABLE part_test;
 
+-- Test pg_stat_tablespace
+-- pg_default and pg_global always exist
+SELECT tablespace_name FROM pg_stat_tablespace
+  WHERE tablespace_name IN ('pg_default', 'pg_global')
+  ORDER BY tablespace_name;
+
+-- Check only that the counters move, not by how much.  pg_stat_tablespace
+-- aggregates every relation in the tablespace and other sessions in this
+-- parallel group are busy in pg_default too, so no exact value is
+-- reproducible.  Block reads and I/O timings may legitimately not move at
+-- all, since they depend on what happens to be cached and on
+-- track_io_timing, so those are only checked for sanity.
+SELECT tup_inserted AS ts_ins_before,
+       tup_updated AS ts_upd_before,
+       tup_deleted AS ts_del_before,
+       tup_returned AS ts_ret_before,
+       blks_hit AS ts_hit_before
+  FROM pg_stat_tablespace WHERE tablespace_name = 'pg_default' \gset
+
+CREATE TABLE test_tablespace_stats (a int);
+INSERT INTO test_tablespace_stats SELECT generate_series(1, 100);
+UPDATE test_tablespace_stats SET a = a + 1 WHERE a > 50;
+DELETE FROM test_tablespace_stats WHERE a > 90;
+SELECT count(*) > 0 FROM test_tablespace_stats;
+SELECT pg_stat_force_next_flush();
+
+SELECT tup_inserted > :ts_ins_before AS inserts_counted,
+       tup_updated > :ts_upd_before AS updates_counted,
+       tup_deleted > :ts_del_before AS deletes_counted,
+       tup_returned > :ts_ret_before AS returns_counted,
+       blks_hit > :ts_hit_before AS hits_counted,
+       blks_read >= 0 AS blks_read_sane,
+       blk_read_time >= 0 AS blk_read_time_sane,
+       blk_write_time >= 0 AS blk_write_time_sane
+  FROM pg_stat_tablespace WHERE tablespace_name = 'pg_default';
+
+DROP TABLE test_tablespace_stats;
+
+-- Temporary files are attributed to the tablespace they were created in,
+-- which without temp_tablespaces set is pg_default.
+SELECT temp_files AS ts_tmpf_before, temp_bytes AS ts_tmpb_before
+  FROM pg_stat_tablespace WHERE tablespace_name = 'pg_default' \gset
+
+SET work_mem = '64kB';
+SELECT count(*) > 0 FROM
+  (SELECT * FROM generate_series(1, 10000) AS s ORDER BY s DESC) AS foo;
+RESET work_mem;
+SELECT pg_stat_force_next_flush();
+
+SELECT temp_files > :ts_tmpf_before AS temp_files_counted,
+       temp_bytes > :ts_tmpb_before AS temp_bytes_counted
+  FROM pg_stat_tablespace WHERE tablespace_name = 'pg_default';
+
+-- Resetting sets the timestamp, and resetting again does not move it backwards
+SELECT pg_stat_reset_shared('tablespace');
+SELECT stats_reset AS ts_reset_before FROM pg_stat_tablespace
+  WHERE tablespace_name = 'pg_default' \gset
+SELECT pg_stat_reset_shared('tablespace');
+SELECT stats_reset >= :'ts_reset_before'::timestamptz FROM pg_stat_tablespace
+  WHERE tablespace_name = 'pg_default';
+
 -- End of Stats Test
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..e99a39b1aa8 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,27 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
 REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
 RESET ROLE;
 
+-- A relation moved to another tablespace must be credited to the new one in
+-- pg_stat_tablespace.  pgstat_info is preserved across a relcache rebuild, so
+-- doing this while the relation is already in use exercises
+-- pgstat_relation_update_tablespace().
+CREATE TABLE tablespace_stats_move (a int);
+INSERT INTO tablespace_stats_move SELECT generate_series(1, 10);
+SELECT pg_stat_force_next_flush();
+SELECT tup_inserted AS stats_move_before FROM pg_stat_tablespace
+  WHERE tablespace_name = 'regress_tblspace' \gset
+
+BEGIN;
+SELECT count(*) > 0 FROM tablespace_stats_move;
+ALTER TABLE tablespace_stats_move SET TABLESPACE regress_tblspace;
+INSERT INTO tablespace_stats_move SELECT generate_series(1, 10);
+COMMIT;
+SELECT pg_stat_force_next_flush();
+
+SELECT tup_inserted > :stats_move_before AS credited_to_new_tablespace
+  FROM pg_stat_tablespace WHERE tablespace_name = 'regress_tblspace';
+DROP TABLE tablespace_stats_move;
+
 ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
 
 ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
-- 
2.37.1 (Apple Git-137.1)

