pageinspect support for SpGiST

Started by Kirill Reshke1 day ago2 messages
#1Kirill Reshke
reshkekirill@gmail.com
1 attachment(s)

Hi hackers!

I am currently working for amcheck & pageinspect support for SpGiST indices.
Here is a patch implementing the pageinspect part.

My current design is two functions, which are

* spgist_page_opaque_info - akin to gin/gist opaque info functions.
* spgist_page_items - function that returns information about tuples
on the SpGiST page. Currently this works only for LEAF pages, Internal
pages to be supported.

Following "Desirability -> Design -> Implement -> Test -> Review ->
Commit" developing process, posting v1 patch with PoC and looking for
Desirability/Design feedback.

PFA (WIP) v1.

--
Best regards,
Kirill Reshke

Attachments:

v1-0001-Add-pageinspect-support-for-SpGiST-indexes.patchapplication/octet-stream; name=v1-0001-Add-pageinspect-support-for-SpGiST-indexes.patchDownload
From 513fd8b8957b7d3d066233f3a529aa45b4d29024 Mon Sep 17 00:00:00 2001
From: reshke <reshke@double.cloud>
Date: Sat, 10 Jan 2026 17:29:57 +0000
Subject: [PATCH v1] Add pageinspect support for SpGiST indexes.

---
 contrib/pageinspect/Makefile                  |   1 +
 contrib/pageinspect/expected/spgist.out       |  44 +++
 contrib/pageinspect/meson.build               |   3 +
 .../pageinspect/pageinspect--1.13--1.14.sql   |  32 ++
 contrib/pageinspect/pageinspect.control       |   2 +-
 contrib/pageinspect/spgistfuncs.c             | 338 ++++++++++++++++++
 contrib/pageinspect/sql/spgist.sql            |  27 ++
 7 files changed, 446 insertions(+), 1 deletion(-)
 create mode 100644 contrib/pageinspect/expected/spgist.out
 create mode 100644 contrib/pageinspect/pageinspect--1.13--1.14.sql
 create mode 100644 contrib/pageinspect/spgistfuncs.c
 create mode 100644 contrib/pageinspect/sql/spgist.sql

diff --git a/contrib/pageinspect/Makefile b/contrib/pageinspect/Makefile
index eae989569d0..7462118fa4d 100644
--- a/contrib/pageinspect/Makefile
+++ b/contrib/pageinspect/Makefile
@@ -32,6 +32,7 @@ REGRESS = \
 	gin \
 	gist \
 	hash \
+	spgist \
 	oldextversions
 
 ifdef USE_PGXS
diff --git a/contrib/pageinspect/expected/spgist.out b/contrib/pageinspect/expected/spgist.out
new file mode 100644
index 00000000000..339878b5bbb
--- /dev/null
+++ b/contrib/pageinspect/expected/spgist.out
@@ -0,0 +1,44 @@
+-- The gist_page_opaque_info() function prints the page's LSN.
+-- Use an unlogged index, so that the LSN is predictable.
+CREATE UNLOGGED TABLE test_gist AS SELECT point(i,i) p, i::text t FROM
+    generate_series(1,1000) i;
+CREATE INDEX test_spgist_idx ON test_gist USING spgist (p);
+-- Page 0 is the root, the rest are leaf pages
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 0));
+    lsn     | nplaceholder | nredirection | flags  
+------------+--------------+--------------+--------
+ 0/00000000 |            0 |            0 | {meta}
+(1 row)
+
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 1));
+    lsn     | nplaceholder | nredirection | flags 
+------------+--------------+--------------+-------
+ 0/00000000 |            0 |            0 | {}
+(1 row)
+
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 2));
+    lsn     | nplaceholder | nredirection |    flags     
+------------+--------------+--------------+--------------
+ 0/00000000 |            0 |            0 | {leaf,nulls}
+(1 row)
+
+-- Suppress the DETAIL message, to allow the tests to work across various
+-- page sizes and architectures.
+\set VERBOSITY terse
+-- Failure with various modes.
+-- invalid page size
+SELECT spgist_page_opaque_info('aaa'::bytea);
+ERROR:  invalid page size
+-- invalid special area size
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_gist', 0));
+ERROR:  input page is not a valid SpGiST page
+\set VERBOSITY default
+-- Tests with all-zero pages.
+SHOW block_size \gset
+SELECT spgist_page_opaque_info(decode(repeat('00', :block_size), 'hex'));
+ spgist_page_opaque_info 
+-------------------------
+ 
+(1 row)
+
+DROP TABLE test_gist;
diff --git a/contrib/pageinspect/meson.build b/contrib/pageinspect/meson.build
index c43ea400a4d..13408d0491a 100644
--- a/contrib/pageinspect/meson.build
+++ b/contrib/pageinspect/meson.build
@@ -9,6 +9,7 @@ pageinspect_sources = files(
   'hashfuncs.c',
   'heapfuncs.c',
   'rawpage.c',
+  'spgistfuncs.c',
 )
 
 if host_system == 'windows'
@@ -38,6 +39,7 @@ install_data(
   'pageinspect--1.10--1.11.sql',
   'pageinspect--1.11--1.12.sql',
   'pageinspect--1.12--1.13.sql',
+  'pageinspect--1.13--1.14.sql',
   'pageinspect.control',
   kwargs: contrib_data_args,
 )
@@ -56,6 +58,7 @@ tests += {
       'hash',
       'checksum',
       'oldextversions',
+      'spgist',
     ],
   },
 }
diff --git a/contrib/pageinspect/pageinspect--1.13--1.14.sql b/contrib/pageinspect/pageinspect--1.13--1.14.sql
new file mode 100644
index 00000000000..581aa308b59
--- /dev/null
+++ b/contrib/pageinspect/pageinspect--1.13--1.14.sql
@@ -0,0 +1,32 @@
+/* contrib/pageinspect/pageinspect--1.14--1.15.sql */
+
+-- complain if script is sourced in psql, rather than via ALTER EXTENSION
+\echo Use "ALTER EXTENSION pageinspect UPDATE TO '1.14'" to load this file. \quit
+
+--
+-- spgist_page_opaque_info()
+--
+CREATE FUNCTION spgist_page_opaque_info(IN page bytea,
+    OUT lsn pg_lsn,
+    OUT nPlaceholder SMALLINT,
+    OUT nRedirection SMALLINT,
+    OUT flags text[])
+AS 'MODULE_PATHNAME', 'spgist_page_opaque_info'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+--
+-- spgist_page_items()
+--
+CREATE FUNCTION spgist_page_items(IN page bytea,
+    IN index_oid regclass,
+    OUT itemoffset smallint,
+    OUT ctid tid,
+    OUT size smallint,
+    OUT hasnullmask boolean,
+    OUT nextoffset smallint,
+    OUT state TEXT,
+    OUT xid xid,
+    OUT keys text)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'spgist_page_items'
+LANGUAGE C STRICT PARALLEL SAFE;
diff --git a/contrib/pageinspect/pageinspect.control b/contrib/pageinspect/pageinspect.control
index cfc87feac03..aee3f598a9e 100644
--- a/contrib/pageinspect/pageinspect.control
+++ b/contrib/pageinspect/pageinspect.control
@@ -1,5 +1,5 @@
 # pageinspect extension
 comment = 'inspect the contents of database pages at a low level'
-default_version = '1.13'
+default_version = '1.14'
 module_pathname = '$libdir/pageinspect'
 relocatable = true
diff --git a/contrib/pageinspect/spgistfuncs.c b/contrib/pageinspect/spgistfuncs.c
new file mode 100644
index 00000000000..193a0bdc5dc
--- /dev/null
+++ b/contrib/pageinspect/spgistfuncs.c
@@ -0,0 +1,338 @@
+/*
+ * gistfuncs.c
+ *		Functions to investigate the content of GiST indexes
+ *
+ * Copyright (c) 2014-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		contrib/pageinspect/gistfuncs.c
+ */
+#include "postgres.h"
+
+#include "access/relation.h"
+#include "access/spgist.h"
+#include "access/spgist_private.h"
+#include "access/htup.h"
+#include "access/htup_details.h"
+#include "catalog/pg_am_d.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "pageinspect.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/rel.h"
+#include "utils/ruleutils.h"
+
+PG_FUNCTION_INFO_V1(spgist_page_opaque_info);
+PG_FUNCTION_INFO_V1(spgist_page_items);
+
+#define IS_SPGIST(r) ((r)->rd_rel->relam == SPGIST_AM_OID)
+
+
+static Page verify_gist_page(bytea *raw_page);
+
+/*
+ * Verify that the given bytea contains a GIST page or die in the attempt.
+ * A pointer to the page is returned.
+ */
+static Page
+verify_gist_page(bytea *raw_page)
+{
+	Page		page = get_page_from_raw(raw_page);
+	SpGistPageOpaque opaq;
+
+	if (PageIsNew(page))
+		return page;
+
+	/* verify the special space has the expected size */
+	if (PageGetSpecialSize(page) != MAXALIGN(sizeof(SpGistPageOpaque)))
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("input page is not a valid %s page", "SpGiST"),
+				 errdetail("Expected special size %d, got %d.",
+						   (int) MAXALIGN(sizeof(SpGistPageOpaque)),
+						   (int) PageGetSpecialSize(page))));
+
+	opaq = SpGistPageGetOpaque(page);
+	if (opaq->spgist_page_id != SPGIST_PAGE_ID)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("input page is not a valid %s page", "GiST"),
+				 errdetail("Expected %08x, got %08x.",
+						   SPGIST_PAGE_ID,
+						   opaq->spgist_page_id)));
+
+	return page;
+}
+
+Datum
+spgist_page_opaque_info(PG_FUNCTION_ARGS)
+{
+	bytea	   *raw_page = PG_GETARG_BYTEA_P(0);
+	TupleDesc	tupdesc;
+	Page		page;
+	HeapTuple	resultTuple;
+	Datum		values[4];
+	bool		nulls[4];
+	Datum		flags[16];
+	int			nflags = 0;
+	uint16		flagbits;
+
+	if (!superuser())
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("must be superuser to use raw page functions")));
+
+	page = verify_gist_page(raw_page);
+
+	if (PageIsNew(page))
+		PG_RETURN_NULL();
+
+	/* Build a tuple descriptor for our result type */
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "return type must be a row type");
+
+	/* Convert the flags bitmask to an array of human-readable names */
+	flagbits = SpGistPageGetOpaque(page)->flags;
+	if (flagbits & SPGIST_META)
+		flags[nflags++] = CStringGetTextDatum("meta");
+	if (flagbits & SPGIST_DELETED)
+		flags[nflags++] = CStringGetTextDatum("deleted");
+	if (flagbits & SPGIST_LEAF)
+		flags[nflags++] = CStringGetTextDatum("leaf");
+	if (flagbits & SPGIST_NULLS)
+		flags[nflags++] = CStringGetTextDatum("nulls");
+	flagbits &= ~(SPGIST_META | SPGIST_DELETED | SPGIST_LEAF | SPGIST_NULLS);
+	if (flagbits)
+	{
+		/* any flags we don't recognize are printed in hex */
+		flags[nflags++] = DirectFunctionCall1(to_hex32, Int32GetDatum(flagbits));
+	}
+
+	memset(nulls, 0, sizeof(nulls));
+
+	values[0] = LSNGetDatum(PageGetLSN(page));
+	values[1] = Int16GetDatum(SpGistPageGetOpaque(page)->nPlaceholder);
+	values[2] = Int16GetDatum(SpGistPageGetOpaque(page)->nRedirection);
+	values[3] = PointerGetDatum(construct_array_builtin(flags, nflags, TEXTOID));
+
+	/* Build and return the result tuple. */
+	resultTuple = heap_form_tuple(tupdesc, values, nulls);
+
+	return HeapTupleGetDatum(resultTuple);
+}
+
+
+
+Datum
+spgist_page_items(PG_FUNCTION_ARGS)
+{
+	bytea	   *raw_page = PG_GETARG_BYTEA_P(0);
+	Oid			indexRelid = PG_GETARG_OID(1);
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	Relation	indexRel;
+	TupleDesc	tupdesc;
+	Page		page;
+	uint16		flagbits;
+	bits16		printflags = 0;
+	OffsetNumber offset;
+	OffsetNumber maxoff = InvalidOffsetNumber;
+	char	   *index_columns;
+
+	if (!superuser())
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("must be superuser to use raw page functions")));
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Open the relation */
+	indexRel = index_open(indexRelid, AccessShareLock);
+
+	if (!IS_SPGIST(indexRel))
+		ereport(ERROR,
+				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				 errmsg("\"%s\" is not a %s index",
+						RelationGetRelationName(indexRel), "SpGiST")));
+
+	page = verify_gist_page(raw_page);
+
+	if (PageIsNew(page))
+	{
+		index_close(indexRel, AccessShareLock);
+		PG_RETURN_NULL();
+	}
+
+	flagbits = SpGistPageGetOpaque(page)->flags;
+
+	/*
+	 * Included attributes are added when dealing with leaf pages, discarded
+	 * for non-leaf pages as these include only data for key attributes.
+	 */
+	printflags |= RULE_INDEXDEF_PRETTY;
+	if (flagbits & SPGIST_LEAF)
+	{
+		tupdesc = RelationGetDescr(indexRel);
+	}
+	else
+	{
+		tupdesc = CreateTupleDescTruncatedCopy(RelationGetDescr(indexRel),
+											   IndexRelationGetNumberOfKeyAttributes(indexRel));
+		printflags |= RULE_INDEXDEF_KEYS_ONLY;
+	}
+
+	index_columns = pg_get_indexdef_columns_extended(indexRelid,
+													 printflags);
+
+	/* Avoid bogus PageGetMaxOffsetNumber() call with deleted pages */
+	if (SpGistPageIsDeleted(page))
+		elog(NOTICE, "page is deleted");
+	else
+		maxoff = PageGetMaxOffsetNumber(page);
+
+	for (offset = FirstOffsetNumber;
+		 offset <= maxoff;
+		 offset++)
+	{
+		Datum		values[8];
+		bool		nulls[8];
+		ItemId		id;
+		IndexTuple	itup;
+		Datum		itup_values[INDEX_MAX_KEYS];
+		bool		itup_isnull[INDEX_MAX_KEYS];
+		StringInfoData buf;
+		SpGistLeafTuple leafTuple;
+		int			i;
+		bool		hasNullsMask;
+		bool		has_datums;
+		char	   *tp;
+		bits8	   *bp;
+
+		id = PageGetItemId(page, offset);
+
+		if (!ItemIdIsValid(id))
+			elog(ERROR, "invalid ItemId");
+
+		itup = (IndexTuple) PageGetItem(page, id);
+		leafTuple = (SpGistLeafTuple) itup;
+		hasNullsMask = SGLT_GET_HASNULLMASK(leafTuple);
+
+		tp = (char *) leafTuple + SGLTHDRSZ(hasNullsMask);
+		bp = (bits8 *) ((char *) leafTuple + sizeof(SpGistLeafTupleData));
+		has_datums = false;
+
+		index_deform_tuple_internal(tupdesc,
+									itup_values, itup_isnull,
+									tp, bp, hasNullsMask);
+
+		memset(nulls, 0, sizeof(nulls));
+
+		values[0] = Int16GetDatum(offset);
+		values[1] = ItemPointerGetDatum(&leafTuple->heapPtr);
+		values[2] = Int32GetDatum(leafTuple->size);
+		values[3] = BoolGetDatum(hasNullsMask);
+		values[4] = Int16GetDatum(SGLT_GET_NEXTOFFSET(leafTuple));
+
+		values[6] = InvalidTransactionId;
+
+		switch (leafTuple->tupstate)
+		{
+			case SPGIST_LIVE:
+				values[5] = CStringGetTextDatum("LIVE");
+				has_datums = true;
+				break;
+			case SPGIST_REDIRECT:
+				values[5] = CStringGetTextDatum("REDIRECT");
+				values[6] = ((SpGistDeadTuple) leafTuple)->xid;
+				break;
+			case SPGIST_DEAD:
+				values[5] = CStringGetTextDatum("DEAD");
+				values[6] = ((SpGistDeadTuple) leafTuple)->xid;
+				break;
+			case SPGIST_PLACEHOLDER:
+				values[5] = CStringGetTextDatum("PLACEHOLDER");
+				values[6] = ((SpGistDeadTuple) leafTuple)->xid;
+				break;
+			default:
+				ereport(ERROR, errcode(ERRCODE_INDEX_CORRUPTED), errmsg("malformed SpGist leaf tuple state %d", leafTuple->tupstate));
+		}
+
+		if (has_datums && index_columns)
+		{
+			initStringInfo(&buf);
+			appendStringInfo(&buf, "(%s)=(", index_columns);
+
+			/* Most of this is copied from record_out(). */
+			for (i = 0; i < tupdesc->natts; i++)
+			{
+				char	   *value;
+				char	   *tmp;
+				bool		nq = false;
+
+				if (itup_isnull[i])
+					value = "null";
+				else
+				{
+					Oid			foutoid;
+					bool		typisvarlena;
+					Oid			typoid;
+
+					typoid = TupleDescAttr(tupdesc, i)->atttypid;
+					getTypeOutputInfo(typoid, &foutoid, &typisvarlena);
+					value = OidOutputFunctionCall(foutoid, itup_values[i]);
+				}
+
+				if (i == IndexRelationGetNumberOfKeyAttributes(indexRel))
+					appendStringInfoString(&buf, ") INCLUDE (");
+				else if (i > 0)
+					appendStringInfoString(&buf, ", ");
+
+				/* Check whether we need double quotes for this value */
+				nq = (value[0] == '\0');	/* force quotes for empty string */
+				for (tmp = value; *tmp; tmp++)
+				{
+					char		ch = *tmp;
+
+					if (ch == '"' || ch == '\\' ||
+						ch == '(' || ch == ')' || ch == ',' ||
+						isspace((unsigned char) ch))
+					{
+						nq = true;
+						break;
+					}
+				}
+
+				/* And emit the string */
+				if (nq)
+					appendStringInfoCharMacro(&buf, '"');
+				for (tmp = value; *tmp; tmp++)
+				{
+					char		ch = *tmp;
+
+					if (ch == '"' || ch == '\\')
+						appendStringInfoCharMacro(&buf, ch);
+					appendStringInfoCharMacro(&buf, ch);
+				}
+				if (nq)
+					appendStringInfoCharMacro(&buf, '"');
+			}
+
+			appendStringInfoChar(&buf, ')');
+
+			values[7] = CStringGetTextDatum(buf.data);
+		}
+		else
+		{
+			values[7] = (Datum) 0;
+			nulls[7] = true;
+		}
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls);
+	}
+
+	index_close(indexRel, AccessShareLock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pageinspect/sql/spgist.sql b/contrib/pageinspect/sql/spgist.sql
new file mode 100644
index 00000000000..780b820989a
--- /dev/null
+++ b/contrib/pageinspect/sql/spgist.sql
@@ -0,0 +1,27 @@
+-- The gist_page_opaque_info() function prints the page's LSN.
+-- Use an unlogged index, so that the LSN is predictable.
+CREATE UNLOGGED TABLE test_gist AS SELECT point(i,i) p, i::text t FROM
+    generate_series(1,1000) i;
+CREATE INDEX test_spgist_idx ON test_gist USING spgist (p);
+
+-- Page 0 is the root, the rest are leaf pages
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 0));
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 1));
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 2));
+
+-- Suppress the DETAIL message, to allow the tests to work across various
+-- page sizes and architectures.
+\set VERBOSITY terse
+
+-- Failure with various modes.
+-- invalid page size
+SELECT spgist_page_opaque_info('aaa'::bytea);
+-- invalid special area size
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_gist', 0));
+\set VERBOSITY default
+
+-- Tests with all-zero pages.
+SHOW block_size \gset
+SELECT spgist_page_opaque_info(decode(repeat('00', :block_size), 'hex'));
+
+DROP TABLE test_gist;
-- 
2.43.0

#2Kirill Reshke
reshkekirill@gmail.com
In reply to: Kirill Reshke (#1)
1 attachment(s)
Re: pageinspect support for SpGiST

On Sat, 10 Jan 2026 at 23:05, I wrote:

Currently this works only for LEAF pages, Internal pages to be supported.

I have added the spgist_innerpage_items function to address that.
spgist_page_items renamed to spgist_leafpage_items

PFA v2

--
Best regards,
Kirill Reshke

Attachments:

v2-0001-Add-pageinspect-support-for-SpGiST-indexes.patchapplication/octet-stream; name=v2-0001-Add-pageinspect-support-for-SpGiST-indexes.patchDownload
From c251ce2bb79b3d242a3c76b9fa5477bda1c06562 Mon Sep 17 00:00:00 2001
From: reshke <reshke@double.cloud>
Date: Sat, 10 Jan 2026 17:29:57 +0000
Subject: [PATCH v2] Add pageinspect support for SpGiST indexes.

---
 contrib/pageinspect/Makefile                  |   6 +-
 contrib/pageinspect/expected/spgist.out       |  44 ++
 contrib/pageinspect/meson.build               |   3 +
 .../pageinspect/pageinspect--1.13--1.14.sql   |  60 ++
 contrib/pageinspect/pageinspect.control       |   2 +-
 contrib/pageinspect/spgistfuncs.c             | 526 ++++++++++++++++++
 contrib/pageinspect/sql/spgist.sql            |  27 +
 7 files changed, 665 insertions(+), 3 deletions(-)
 create mode 100644 contrib/pageinspect/expected/spgist.out
 create mode 100644 contrib/pageinspect/pageinspect--1.13--1.14.sql
 create mode 100644 contrib/pageinspect/spgistfuncs.c
 create mode 100644 contrib/pageinspect/sql/spgist.sql

diff --git a/contrib/pageinspect/Makefile b/contrib/pageinspect/Makefile
index eae989569d0..c565c0d9b23 100644
--- a/contrib/pageinspect/Makefile
+++ b/contrib/pageinspect/Makefile
@@ -10,10 +10,11 @@ OBJS = \
 	gistfuncs.o \
 	hashfuncs.o \
 	heapfuncs.o \
-	rawpage.o
+	rawpage.o \
+	spgistfuncs.o
 
 EXTENSION = pageinspect
-DATA =  pageinspect--1.12--1.13.sql \
+DATA =  pageinspect--1.13--1.14.sql pageinspect--1.12--1.13.sql \
 	pageinspect--1.11--1.12.sql pageinspect--1.10--1.11.sql \
 	pageinspect--1.9--1.10.sql pageinspect--1.8--1.9.sql \
 	pageinspect--1.7--1.8.sql pageinspect--1.6--1.7.sql \
@@ -32,6 +33,7 @@ REGRESS = \
 	gin \
 	gist \
 	hash \
+	spgist \
 	oldextversions
 
 ifdef USE_PGXS
diff --git a/contrib/pageinspect/expected/spgist.out b/contrib/pageinspect/expected/spgist.out
new file mode 100644
index 00000000000..339878b5bbb
--- /dev/null
+++ b/contrib/pageinspect/expected/spgist.out
@@ -0,0 +1,44 @@
+-- The gist_page_opaque_info() function prints the page's LSN.
+-- Use an unlogged index, so that the LSN is predictable.
+CREATE UNLOGGED TABLE test_gist AS SELECT point(i,i) p, i::text t FROM
+    generate_series(1,1000) i;
+CREATE INDEX test_spgist_idx ON test_gist USING spgist (p);
+-- Page 0 is the root, the rest are leaf pages
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 0));
+    lsn     | nplaceholder | nredirection | flags  
+------------+--------------+--------------+--------
+ 0/00000000 |            0 |            0 | {meta}
+(1 row)
+
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 1));
+    lsn     | nplaceholder | nredirection | flags 
+------------+--------------+--------------+-------
+ 0/00000000 |            0 |            0 | {}
+(1 row)
+
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 2));
+    lsn     | nplaceholder | nredirection |    flags     
+------------+--------------+--------------+--------------
+ 0/00000000 |            0 |            0 | {leaf,nulls}
+(1 row)
+
+-- Suppress the DETAIL message, to allow the tests to work across various
+-- page sizes and architectures.
+\set VERBOSITY terse
+-- Failure with various modes.
+-- invalid page size
+SELECT spgist_page_opaque_info('aaa'::bytea);
+ERROR:  invalid page size
+-- invalid special area size
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_gist', 0));
+ERROR:  input page is not a valid SpGiST page
+\set VERBOSITY default
+-- Tests with all-zero pages.
+SHOW block_size \gset
+SELECT spgist_page_opaque_info(decode(repeat('00', :block_size), 'hex'));
+ spgist_page_opaque_info 
+-------------------------
+ 
+(1 row)
+
+DROP TABLE test_gist;
diff --git a/contrib/pageinspect/meson.build b/contrib/pageinspect/meson.build
index c43ea400a4d..13408d0491a 100644
--- a/contrib/pageinspect/meson.build
+++ b/contrib/pageinspect/meson.build
@@ -9,6 +9,7 @@ pageinspect_sources = files(
   'hashfuncs.c',
   'heapfuncs.c',
   'rawpage.c',
+  'spgistfuncs.c',
 )
 
 if host_system == 'windows'
@@ -38,6 +39,7 @@ install_data(
   'pageinspect--1.10--1.11.sql',
   'pageinspect--1.11--1.12.sql',
   'pageinspect--1.12--1.13.sql',
+  'pageinspect--1.13--1.14.sql',
   'pageinspect.control',
   kwargs: contrib_data_args,
 )
@@ -56,6 +58,7 @@ tests += {
       'hash',
       'checksum',
       'oldextversions',
+      'spgist',
     ],
   },
 }
diff --git a/contrib/pageinspect/pageinspect--1.13--1.14.sql b/contrib/pageinspect/pageinspect--1.13--1.14.sql
new file mode 100644
index 00000000000..c8b42e467a1
--- /dev/null
+++ b/contrib/pageinspect/pageinspect--1.13--1.14.sql
@@ -0,0 +1,60 @@
+/* contrib/pageinspect/pageinspect--1.13--1.14.sql */
+
+-- complain if script is sourced in psql, rather than via ALTER EXTENSION
+\echo Use "ALTER EXTENSION pageinspect UPDATE TO '1.14'" to load this file. \quit
+
+--
+-- spgist_page_opaque_info()
+--
+CREATE FUNCTION spgist_page_opaque_info(IN page bytea,
+    OUT lsn pg_lsn,
+    OUT nPlaceholder SMALLINT,
+    OUT nRedirection SMALLINT,
+    OUT flags text[])
+AS 'MODULE_PATHNAME', 'spgist_page_opaque_info'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+--
+-- spgist_leafpage_items()
+--
+CREATE FUNCTION spgist_leafpage_items(IN page bytea,
+    IN index_oid regclass,
+    OUT itemoffset smallint,
+    OUT ctid tid,
+    OUT size smallint,
+    OUT hasnullmask boolean,
+    OUT nextoffset smallint,
+    OUT state TEXT,
+    OUT xid xid,
+    OUT keys text)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'spgist_leafpage_items'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+
+--
+-- spgist_innerpage_items()
+--
+CREATE FUNCTION spgist_innerpage_items(IN page bytea,
+    IN index_oid regclass,
+    OUT itemoffset smallint,
+    OUT allTheSame INT,
+    OUT nNodes INT,
+    OUT prefixSize INT,
+    OUT size smallint,
+    OUT state TEXT)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'spgist_innerpage_items'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+
+--
+-- spgist_metapage_items()
+--
+CREATE FUNCTION spgist_metapage_items(IN page bytea,
+    OUT itemoffset int,
+    OUT blkno smallint,
+    OUT freespace int)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'spgist_metapage_items'
+LANGUAGE C STRICT PARALLEL SAFE;
diff --git a/contrib/pageinspect/pageinspect.control b/contrib/pageinspect/pageinspect.control
index cfc87feac03..aee3f598a9e 100644
--- a/contrib/pageinspect/pageinspect.control
+++ b/contrib/pageinspect/pageinspect.control
@@ -1,5 +1,5 @@
 # pageinspect extension
 comment = 'inspect the contents of database pages at a low level'
-default_version = '1.13'
+default_version = '1.14'
 module_pathname = '$libdir/pageinspect'
 relocatable = true
diff --git a/contrib/pageinspect/spgistfuncs.c b/contrib/pageinspect/spgistfuncs.c
new file mode 100644
index 00000000000..175b0ee2ec9
--- /dev/null
+++ b/contrib/pageinspect/spgistfuncs.c
@@ -0,0 +1,526 @@
+/*
+ * gistfuncs.c
+ *		Functions to investigate the content of GiST indexes
+ *
+ * Copyright (c) 2014-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		contrib/pageinspect/gistfuncs.c
+ */
+#include "postgres.h"
+
+#include "access/relation.h"
+#include "access/spgist.h"
+#include "access/spgist_private.h"
+#include "access/htup.h"
+#include "access/htup_details.h"
+#include "catalog/pg_am_d.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "pageinspect.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+#include "utils/pg_lsn.h"
+#include "utils/rel.h"
+#include "utils/ruleutils.h"
+
+PG_FUNCTION_INFO_V1(spgist_page_opaque_info);
+PG_FUNCTION_INFO_V1(spgist_leafpage_items);
+PG_FUNCTION_INFO_V1(spgist_innerpage_items);
+PG_FUNCTION_INFO_V1(spgist_metapage_items);
+
+#define IS_SPGIST(r) ((r)->rd_rel->relam == SPGIST_AM_OID)
+
+static Page verify_gist_page(bytea *raw_page);
+
+/*
+ * Verify that the given bytea contains a GIST page or die in the attempt.
+ * A pointer to the page is returned.
+ */
+static Page
+verify_gist_page(bytea *raw_page)
+{
+	Page		page = get_page_from_raw(raw_page);
+	SpGistPageOpaque opaq;
+
+	if (PageIsNew(page))
+		return page;
+
+	/* verify the special space has the expected size */
+	if (PageGetSpecialSize(page) != MAXALIGN(sizeof(SpGistPageOpaque)))
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("input page is not a valid %s page", "SpGiST"),
+				 errdetail("Expected special size %d, got %d.",
+						   (int) MAXALIGN(sizeof(SpGistPageOpaque)),
+						   (int) PageGetSpecialSize(page))));
+
+	opaq = SpGistPageGetOpaque(page);
+	if (opaq->spgist_page_id != SPGIST_PAGE_ID)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("input page is not a valid %s page", "GiST"),
+				 errdetail("Expected %08x, got %08x.",
+						   SPGIST_PAGE_ID,
+						   opaq->spgist_page_id)));
+
+	return page;
+}
+
+Datum
+spgist_page_opaque_info(PG_FUNCTION_ARGS)
+{
+	bytea	   *raw_page = PG_GETARG_BYTEA_P(0);
+	TupleDesc	tupdesc;
+	Page		page;
+	HeapTuple	resultTuple;
+	Datum		values[4];
+	bool		nulls[4];
+	Datum		flags[16];
+	int			nflags = 0;
+	uint16		flagbits;
+
+	if (!superuser())
+		ereport(ERROR,
+				errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				errmsg("must be superuser to use raw page functions"));
+
+	page = verify_gist_page(raw_page);
+
+	if (PageIsNew(page))
+		PG_RETURN_NULL();
+
+	/* Build a tuple descriptor for our result type */
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "return type must be a row type");
+
+	/* Convert the flags bitmask to an array of human-readable names */
+	flagbits = SpGistPageGetOpaque(page)->flags;
+	if (flagbits & SPGIST_META)
+		flags[nflags++] = CStringGetTextDatum("meta");
+	if (flagbits & SPGIST_DELETED)
+		flags[nflags++] = CStringGetTextDatum("deleted");
+	if (flagbits & SPGIST_LEAF)
+		flags[nflags++] = CStringGetTextDatum("leaf");
+	if (flagbits & SPGIST_NULLS)
+		flags[nflags++] = CStringGetTextDatum("nulls");
+	flagbits &= ~(SPGIST_META | SPGIST_DELETED | SPGIST_LEAF | SPGIST_NULLS);
+	if (flagbits)
+	{
+		/* any flags we don't recognize are printed in hex */
+		flags[nflags++] = DirectFunctionCall1(to_hex32, Int32GetDatum(flagbits));
+	}
+
+	memset(nulls, 0, sizeof(nulls));
+
+	values[0] = LSNGetDatum(PageGetLSN(page));
+	values[1] = Int16GetDatum(SpGistPageGetOpaque(page)->nPlaceholder);
+	values[2] = Int16GetDatum(SpGistPageGetOpaque(page)->nRedirection);
+	values[3] = PointerGetDatum(construct_array_builtin(flags, nflags, TEXTOID));
+
+	/* Build and return the result tuple. */
+	resultTuple = heap_form_tuple(tupdesc, values, nulls);
+
+	return HeapTupleGetDatum(resultTuple);
+}
+
+
+Datum
+spgist_metapage_items(PG_FUNCTION_ARGS)
+{
+	bytea	   *raw_page = PG_GETARG_BYTEA_P(0);
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	Page		metapage;
+	uint16		flagbits;
+	SpGistMetaPageData *metadata;
+
+	if (!superuser())
+		ereport(ERROR,
+				errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				errmsg("must be superuser to use raw page functions"));
+
+	InitMaterializedSRF(fcinfo, 0);
+
+
+	metapage = verify_gist_page(raw_page);
+
+	flagbits = SpGistPageGetOpaque(metapage)->flags;
+
+	if (!(flagbits & SPGIST_META))
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("input page is not a %s meta page", "SpGiST"));
+
+	metadata = SpGistPageGetMeta(metapage);
+
+	if (metadata->magicNumber != SPGIST_MAGIC_NUMBER)
+		ereport(ERROR,
+				errcode(ERRCODE_INDEX_CORRUPTED),
+				errmsg("input page is not a valid SpGiST metapage"),
+				errdetail("Expected special size %d, got %d.",
+						  (int) SPGIST_MAGIC_NUMBER,
+						  (int) metadata->magicNumber));
+
+	for (int i = 0; i < SPGIST_CACHED_PAGES; i++)
+	{
+		Datum		values[3];
+		bool		nulls[3];
+
+		memset(nulls, 0, sizeof(nulls));
+
+		values[0] = Int32GetDatum(i);
+		values[1] = Int16GetDatum(metadata->lastUsedPages.cachedPage[i].blkno);
+		values[2] = Int32GetDatum(metadata->lastUsedPages.cachedPage[i].freeSpace);
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
+Datum
+spgist_leafpage_items(PG_FUNCTION_ARGS)
+{
+	bytea	   *raw_page = PG_GETARG_BYTEA_P(0);
+	Oid			indexRelid = PG_GETARG_OID(1);
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	Relation	indexRel;
+	TupleDesc	tupdesc;
+	Page		page;
+	uint16		flagbits;
+	bits16		printflags = 0;
+	OffsetNumber maxoff = InvalidOffsetNumber;
+	char	   *index_columns;
+
+	if (!superuser())
+		ereport(ERROR,
+				errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				errmsg("must be superuser to use raw page functions"));
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Open the index relation */
+	indexRel = index_open(indexRelid, AccessShareLock);
+
+	if (!IS_SPGIST(indexRel))
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("\"%s\" is not a %s index",
+					   RelationGetRelationName(indexRel), "SpGiST"));
+
+	page = verify_gist_page(raw_page);
+
+	if (PageIsNew(page))
+	{
+		index_close(indexRel, AccessShareLock);
+		PG_RETURN_NULL();
+	}
+
+	flagbits = SpGistPageGetOpaque(page)->flags;
+
+	if (flagbits & SPGIST_META)
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("input page is not a %s leaf page", "SpGiST"),
+				errhint("this appears to be %s metapage. Please use spgist_metapage_items.", "SpGiST"));
+
+	/*
+	 * Included attributes are added when dealing with leaf pages, discarded
+	 * for non-leaf pages as these include only data for key attributes.
+	 */
+	printflags |= RULE_INDEXDEF_PRETTY;
+	if (flagbits & SPGIST_LEAF)
+		tupdesc = RelationGetDescr(indexRel);
+	else
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("input page is not a %s leaf page", "SpGiST"),
+				errhint("this appears to be %s inner page. Please use spgist_innerpage_items.", "SpGiST"));
+
+	index_columns = pg_get_indexdef_columns_extended(indexRelid,
+													 printflags);
+
+	/* Avoid bogus PageGetMaxOffsetNumber() call with deleted pages */
+	if (SpGistPageIsDeleted(page))
+		elog(NOTICE, "page is deleted");
+	else
+		maxoff = PageGetMaxOffsetNumber(page);
+
+	for (OffsetNumber offset = FirstOffsetNumber;
+		 offset <= maxoff;
+		 offset++)
+	{
+		Datum		values[8];
+		bool		nulls[8];
+		ItemId		id;
+		IndexTuple	itup;
+		Datum		itup_values[INDEX_MAX_KEYS];
+		bool		itup_isnull[INDEX_MAX_KEYS];
+		SpGistLeafTuple leafTuple;
+		int			i;
+		bool		hasNullsMask;
+		bool		has_datums;
+		char	   *tp;
+		bits8	   *bp;
+
+		id = PageGetItemId(page, offset);
+
+		if (!ItemIdIsValid(id))
+			elog(ERROR, "invalid ItemId");
+
+		itup = (IndexTuple) PageGetItem(page, id);
+		leafTuple = (SpGistLeafTuple) itup;
+		hasNullsMask = SGLT_GET_HASNULLMASK(leafTuple);
+
+		tp = (char *) leafTuple + SGLTHDRSZ(hasNullsMask);
+		bp = (bits8 *) ((char *) leafTuple + sizeof(SpGistLeafTupleData));
+		has_datums = false;
+
+		index_deform_tuple_internal(tupdesc,
+									itup_values, itup_isnull,
+									tp, bp, hasNullsMask);
+
+		memset(nulls, 0, sizeof(nulls));
+
+		values[0] = UInt16GetDatum(offset);
+		values[1] = ItemPointerGetDatum(&leafTuple->heapPtr);
+		values[2] = UInt32GetDatum(leafTuple->size);
+		values[3] = BoolGetDatum(hasNullsMask);
+		values[4] = UInt16GetDatum(SGLT_GET_NEXTOFFSET(leafTuple));
+
+		values[6] = InvalidTransactionId;
+
+		switch (leafTuple->tupstate)
+		{
+			case SPGIST_LIVE:
+				values[5] = CStringGetTextDatum("LIVE");
+				has_datums = true;
+				break;
+			case SPGIST_REDIRECT:
+				values[5] = CStringGetTextDatum("REDIRECT");
+				values[6] = ((SpGistDeadTuple) leafTuple)->xid;
+				break;
+			case SPGIST_DEAD:
+				values[5] = CStringGetTextDatum("DEAD");
+				values[6] = ((SpGistDeadTuple) leafTuple)->xid;
+				break;
+			case SPGIST_PLACEHOLDER:
+				values[5] = CStringGetTextDatum("PLACEHOLDER");
+				values[6] = ((SpGistDeadTuple) leafTuple)->xid;
+				break;
+			default:
+				ereport(ERROR, errcode(ERRCODE_INDEX_CORRUPTED), errmsg("malformed SpGist leaf tuple state %d", leafTuple->tupstate));
+		}
+
+		if (has_datums && index_columns)
+		{
+			StringInfoData buf;
+
+			initStringInfo(&buf);
+			appendStringInfo(&buf, "(%s)=(", index_columns);
+
+			/* Most of this is copied from record_out(). */
+			for (i = 0; i < tupdesc->natts; i++)
+			{
+				char	   *value;
+				char	   *tmp;
+				bool		nq = false;
+
+				if (itup_isnull[i])
+					value = "null";
+				else
+				{
+					Oid			foutoid;
+					bool		typisvarlena;
+					Oid			typoid;
+
+					typoid = TupleDescAttr(tupdesc, i)->atttypid;
+					getTypeOutputInfo(typoid, &foutoid, &typisvarlena);
+					value = OidOutputFunctionCall(foutoid, itup_values[i]);
+				}
+
+				if (i == IndexRelationGetNumberOfKeyAttributes(indexRel))
+					appendStringInfoString(&buf, ") INCLUDE (");
+				else if (i > 0)
+					appendStringInfoString(&buf, ", ");
+
+				/* Check whether we need double quotes for this value */
+				nq = (value[0] == '\0');	/* force quotes for empty string */
+				for (tmp = value; *tmp; tmp++)
+				{
+					char		ch = *tmp;
+
+					if (ch == '"' || ch == '\\' ||
+						ch == '(' || ch == ')' || ch == ',' ||
+						isspace((unsigned char) ch))
+					{
+						nq = true;
+						break;
+					}
+				}
+
+				/* And emit the string */
+				if (nq)
+					appendStringInfoCharMacro(&buf, '"');
+				for (tmp = value; *tmp; tmp++)
+				{
+					char		ch = *tmp;
+
+					if (ch == '"' || ch == '\\')
+						appendStringInfoCharMacro(&buf, ch);
+					appendStringInfoCharMacro(&buf, ch);
+				}
+				if (nq)
+					appendStringInfoCharMacro(&buf, '"');
+			}
+
+			appendStringInfoChar(&buf, ')');
+
+			values[7] = CStringGetTextDatum(buf.data);
+		}
+		else
+		{
+			values[7] = (Datum) 0;
+			nulls[7] = true;
+		}
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls);
+	}
+
+	index_close(indexRel, AccessShareLock);
+
+	return (Datum) 0;
+}
+
+
+Datum
+spgist_innerpage_items(PG_FUNCTION_ARGS)
+{
+	bytea	   *raw_page = PG_GETARG_BYTEA_P(0);
+	Oid			indexRelid = PG_GETARG_OID(1);
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	Relation	indexRel;
+	TupleDesc	tupdesc;
+	Page		page;
+	uint16		flagbits;
+	bits16		printflags = 0;
+	OffsetNumber maxoff = InvalidOffsetNumber;
+	char	   *index_columns;
+
+	if (!superuser())
+		ereport(ERROR,
+				errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				errmsg("must be superuser to use raw page functions"));
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Open the index relation */
+	indexRel = index_open(indexRelid, AccessShareLock);
+
+	if (!IS_SPGIST(indexRel))
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("\"%s\" is not a %s index",
+					   RelationGetRelationName(indexRel), "SpGiST"));
+
+	page = verify_gist_page(raw_page);
+
+	if (PageIsNew(page))
+	{
+		index_close(indexRel, AccessShareLock);
+		PG_RETURN_NULL();
+	}
+
+	flagbits = SpGistPageGetOpaque(page)->flags;
+
+	if (flagbits & SPGIST_META)
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("input page is not a %s inner page", "SpGiST"),
+				errhint("this appears to be %s metapage. Please use spgist_metapage_items.", "SpGiST"));
+
+	/*
+	 * Included attributes are added when dealing with leaf pages, discarded
+	 * for non-leaf pages as these include only data for key attributes.
+	 */
+	printflags |= RULE_INDEXDEF_PRETTY;
+	if (flagbits & SPGIST_LEAF)
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("input page is not a %s inner page", "SpGiST"),
+				errhint("this appears to be %s leaf page. Please use spgist_leafpage_items.", "SpGiST"));
+
+	tupdesc = CreateTupleDescTruncatedCopy(RelationGetDescr(indexRel),
+										   IndexRelationGetNumberOfKeyAttributes(indexRel));
+	printflags |= RULE_INDEXDEF_KEYS_ONLY;
+
+	index_columns = pg_get_indexdef_columns_extended(indexRelid,
+													 printflags);
+
+	/* Avoid bogus PageGetMaxOffsetNumber() call with deleted pages */
+	if (SpGistPageIsDeleted(page))
+		elog(NOTICE, "page is deleted");
+	else
+		maxoff = PageGetMaxOffsetNumber(page);
+
+	for (OffsetNumber offset = FirstOffsetNumber;
+		 offset <= maxoff;
+		 offset++)
+	{
+		Datum		values[8];
+		bool		nulls[8];
+		ItemId		id;
+		IndexTuple	itup;
+		Datum		itup_values[INDEX_MAX_KEYS];
+		bool		itup_isnull[INDEX_MAX_KEYS];
+		SpGistInnerTuple innerTuple;
+		int			i;
+		bool		hasNullsMask;
+		bool		has_datums;
+		char	   *tp;
+		bits8	   *bp;
+
+		id = PageGetItemId(page, offset);
+
+		if (!ItemIdIsValid(id))
+			elog(ERROR, "invalid ItemId");
+
+		itup = (IndexTuple) PageGetItem(page, id);
+		innerTuple = (SpGistInnerTuple) itup;
+
+		memset(nulls, 0, sizeof(nulls));
+
+		values[0] = UInt16GetDatum(offset);
+		values[1] = UInt32GetDatum(innerTuple->allTheSame);
+		values[2] = UInt32GetDatum(innerTuple->nNodes);
+		values[3] = UInt32GetDatum(innerTuple->prefixSize);
+		values[4] = UInt16GetDatum(innerTuple->size);
+
+		switch (innerTuple->tupstate)
+		{
+			case SPGIST_LIVE:
+				values[5] = CStringGetTextDatum("LIVE");
+				has_datums = true;
+				break;
+			case SPGIST_REDIRECT:
+				values[5] = CStringGetTextDatum("REDIRECT");
+				break;
+			case SPGIST_DEAD:
+				values[5] = CStringGetTextDatum("DEAD");
+				break;
+			case SPGIST_PLACEHOLDER:
+				values[5] = CStringGetTextDatum("PLACEHOLDER");
+				break;
+			default:
+				ereport(ERROR, errcode(ERRCODE_INDEX_CORRUPTED),
+						errmsg("malformed SpGist leaf tuple state %d", innerTuple->tupstate));
+		}
+
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls);
+	}
+
+	index_close(indexRel, AccessShareLock);
+
+	return (Datum) 0;
+}
diff --git a/contrib/pageinspect/sql/spgist.sql b/contrib/pageinspect/sql/spgist.sql
new file mode 100644
index 00000000000..780b820989a
--- /dev/null
+++ b/contrib/pageinspect/sql/spgist.sql
@@ -0,0 +1,27 @@
+-- The gist_page_opaque_info() function prints the page's LSN.
+-- Use an unlogged index, so that the LSN is predictable.
+CREATE UNLOGGED TABLE test_gist AS SELECT point(i,i) p, i::text t FROM
+    generate_series(1,1000) i;
+CREATE INDEX test_spgist_idx ON test_gist USING spgist (p);
+
+-- Page 0 is the root, the rest are leaf pages
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 0));
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 1));
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_spgist_idx', 2));
+
+-- Suppress the DETAIL message, to allow the tests to work across various
+-- page sizes and architectures.
+\set VERBOSITY terse
+
+-- Failure with various modes.
+-- invalid page size
+SELECT spgist_page_opaque_info('aaa'::bytea);
+-- invalid special area size
+SELECT * FROM spgist_page_opaque_info(get_raw_page('test_gist', 0));
+\set VERBOSITY default
+
+-- Tests with all-zero pages.
+SHOW block_size \gset
+SELECT spgist_page_opaque_info(decode(repeat('00', :block_size), 'hex'));
+
+DROP TABLE test_gist;
-- 
2.43.0