This page zooms in on one concrete task from the broader Detecting GiST Index Bloat workflow: writing the exact SQL that quantifies bloat on a PostGIS GiST index, and wrapping it in a Python routine that flags any index above a leaf_fragmentation threshold and prints a ready-to-review REINDEX plan. The running example is a fleet telemetry database whose vehicle_tracks table carries a geometry(Point,4326) column named position, updated on every device ping, with a spatial index idx_vehicle_tracks_position_gist that has been quietly inflating for weeks. The goal is a repeatable, low-risk measurement you can schedule, not a one-off manual poke.

Why the Naive Approach Fails

Estimate versus measurement on the same four indexes Four indexes with two bars each. The catalogue-based estimate and the pgstattuple measurement agree closely on two B-tree indexes but diverge badly on the two GiST indexes, where the estimate assumes a fill factor that GiST never uses. Catalogue estimates were written for B-trees idx_orders_pkey (btree) 12% vs 13% — agreement idx_users_email (btree) 8% vs 9% — agreement idx_parcels_geom (gist) 44% estimated vs 14% real catalogue estimate pgstattuple measurement Acting on the estimate means rebuilding healthy GiST indexes on a schedule and calling it maintenance.

The tempting shortcut is to eyeball index size growth and rebuild whenever it “looks big”. That fails in two directions. First, raw size tells you nothing about packing — a large index may be perfectly dense because the table genuinely holds many rows, and rebuilding it wastes a maintenance window for no gain. Second, size alone hides fragmentation entirely: an index can be a modest size yet be riddled with half-empty, out-of-order leaf pages that inflate every scan.

The other common misstep is reaching straight for pgstattuple on every index:

sql
-- Slow and indiscriminate: full-scans every GiST index, business hours or not
SELECT * FROM pgstattuple('idx_vehicle_tracks_position_gist');
SELECT * FROM pgstattuple('idx_trip_segments_route_gist');

pgstattuple() reads the whole object. On a 30 GB position index that is a multi-minute full scan that evicts hot pages from shared buffers and competes with live telemetry writes. Running it blindly across a schema turns a diagnostic into an incident. The disciplined approach is tiered: probe cheaply first, then measure expensively only where the cheap probe raised a flag.

Production-Ready Implementation

Which pgstattuple field actually means bloat A pgstattuple_approx style result for a GiST index, with each field annotated. Free space percent is the headline bloat number. Dead tuple percent should be near zero after a vacuum. Tuple percent below forty on a GiST index is normal, not a problem, because internal pages are deliberately not packed full. Not every low number is a problem table_len | 1,412,505,600 tuple_count | 8,204,110 tuple_percent | 38.2 dead_tuple_percent | 0.4 free_percent | 41.7 free_space | 589,006,336 normal for GiST — internal pages hold keys, not rows should stay under 5% after vacuum this is the bloat figure — 563 MB you are caching for nothing pgstattuple reads every page, so run it on a replica or off-peak. pgstattuple_approx is cheaper but skips all-visible pages, which understates bloat on a freshly vacuumed index.

The complete implementation is one SQL pair plus one Python module. The SQL side gives you the metrics; the Python side applies thresholds and emits the plan.

The SQL: cheap probe, then authoritative scan

pgstatindex() is the cheap probe. It returns structural metadata without a full scan, so it is safe to run across every GiST index:

sql
-- Cheap structural probe: run on all GiST indexes
SELECT
    'idx_vehicle_tracks_position_gist'::text        AS index_name,
    pg_relation_size('idx_vehicle_tracks_position_gist') AS index_bytes,
    ps.leaf_pages,
    ps.empty_pages,
    ps.deleted_pages,
    ps.avg_leaf_density,
    ps.leaf_fragmentation
FROM pgstatindex('idx_vehicle_tracks_position_gist') ps;

pgstattuple() is the authoritative scan, reserved for flagged indexes. It reports the share of the object that is genuinely free space and would be reclaimed by a rebuild:

sql
-- Authoritative full scan: run only on flagged indexes
SELECT
    table_len            AS index_bytes,
    tuple_count          AS live_entries,
    dead_tuple_count,
    dead_tuple_percent,
    free_space,
    free_percent
FROM pgstattuple('idx_vehicle_tracks_position_gist');

To discover every GiST index on the telemetry tables rather than hard-coding names, enumerate them from the catalog. This is the query the Python flagger iterates over:

sql
SELECT
    i.indexrelid::regclass::text AS index_name,
    t.relname                    AS table_name,
    pg_relation_size(i.indexrelid) AS index_bytes
FROM pg_index i
JOIN pg_class t  ON t.oid  = i.indrelid
JOIN pg_class ic ON ic.oid = i.indexrelid
JOIN pg_am   am  ON am.oid = ic.relam
WHERE am.amname = 'gist'
  AND t.relname IN ('vehicle_tracks', 'trip_segments')
ORDER BY pg_relation_size(i.indexrelid) DESC;

The Python flagger

The module below connects with psycopg (v3), enumerates GiST indexes, runs the cheap probe on each, escalates to the full scan only for indexes that breach the fragmentation or density gates, and prints a REINDEX plan. It never executes DDL itself — emitting a plan for human review is deliberate, since a rebuild consumes disk and I/O:

python
from __future__ import annotations

import sys
from dataclasses import dataclass

import psycopg
from psycopg.rows import dict_row

# --- Thresholds. Flag an index only when it is BOTH fragmented AND sparse,
#     and above a minimum size so tiny indexes are never rebuilt. ---
LEAF_FRAGMENTATION_MAX = 30.0    # percent; above this the tree is disordered
AVG_LEAF_DENSITY_MIN = 55.0      # percent; below this leaves are half empty
MIN_INDEX_BYTES = 256 * 1024 * 1024  # ignore indexes smaller than 256 MB

ENUMERATE_GIST = """
    SELECT
        i.indexrelid::regclass::text AS index_name,
        t.relname                    AS table_name,
        pg_relation_size(i.indexrelid) AS index_bytes
    FROM pg_index i
    JOIN pg_class t  ON t.oid  = i.indrelid
    JOIN pg_class ic ON ic.oid = i.indexrelid
    JOIN pg_am   am  ON am.oid = ic.relam
    WHERE am.amname = 'gist'
      AND t.relname = ANY(%(tables)s)
    ORDER BY pg_relation_size(i.indexrelid) DESC;
"""

# %s for the index name is safe here because the value comes from the catalog
# enumeration above, not from user input. pgstatindex takes a regclass.
PROBE_INDEX = """
    SELECT avg_leaf_density, leaf_fragmentation
    FROM pgstatindex(%s);
"""

FULL_SCAN = """
    SELECT dead_tuple_percent, free_percent
    FROM pgstattuple(%s);
"""


@dataclass
class Candidate:
    index_name: str
    table_name: str
    index_bytes: int
    avg_leaf_density: float
    leaf_fragmentation: float
    free_percent: float

    @property
    def approx_reclaimable_bytes(self) -> int:
        return int(self.index_bytes * self.free_percent / 100.0)


def find_bloated_indexes(dsn: str, tables: list[str]) -> list[Candidate]:
    candidates: list[Candidate] = []
    with psycopg.connect(dsn, row_factory=dict_row) as conn:
        # Keep the audit read-only and non-blocking.
        conn.read_only = True
        with conn.cursor() as cur:
            cur.execute(ENUMERATE_GIST, {"tables": tables})
            indexes = cur.fetchall()

        for idx in indexes:
            name = idx["index_name"]
            size = idx["index_bytes"]
            if size < MIN_INDEX_BYTES:
                continue

            # Cheap probe first.
            with conn.cursor() as cur:
                cur.execute(PROBE_INDEX, (name,))
                probe = cur.fetchone()

            frag = float(probe["leaf_fragmentation"])
            density = float(probe["avg_leaf_density"])
            if frag <= LEAF_FRAGMENTATION_MAX or density >= AVG_LEAF_DENSITY_MIN:
                continue  # not bloated enough to justify the full scan

            # Escalate to the authoritative scan only for suspects.
            with conn.cursor() as cur:
                cur.execute(FULL_SCAN, (name,))
                scan = cur.fetchone()

            candidates.append(
                Candidate(
                    index_name=name,
                    table_name=idx["table_name"],
                    index_bytes=size,
                    avg_leaf_density=density,
                    leaf_fragmentation=frag,
                    free_percent=float(scan["free_percent"]),
                )
            )
    return candidates


def emit_reindex_plan(candidates: list[Candidate]) -> str:
    if not candidates:
        return "-- No GiST indexes exceeded the bloat thresholds.\n"

    lines = ["-- REINDEX plan (review before running; each rebuild is online but I/O heavy)"]
    total_reclaim = 0
    for c in candidates:
        mb = c.index_bytes / (1024 * 1024)
        reclaim_mb = c.approx_reclaimable_bytes / (1024 * 1024)
        total_reclaim += c.approx_reclaimable_bytes
        lines.append(
            f"-- {c.index_name} on {c.table_name}: "
            f"{mb:.0f} MB, frag={c.leaf_fragmentation:.1f}%, "
            f"density={c.avg_leaf_density:.1f}%, free={c.free_percent:.1f}% "
            f"(~{reclaim_mb:.0f} MB reclaimable)"
        )
        lines.append(f"REINDEX INDEX CONCURRENTLY {c.index_name};")
    lines.append(f"-- Estimated total reclaim: {total_reclaim / (1024 * 1024):.0f} MB")
    return "\n".join(lines) + "\n"


if __name__ == "__main__":
    dsn = "postgresql://monitor:secret@replica.internal:5432/fleet"
    flagged = find_bloated_indexes(dsn, ["vehicle_tracks", "trip_segments"])
    sys.stdout.write(emit_reindex_plan(flagged))

Running the module against a bloated fleet database prints something like:

-- REINDEX plan (review before running; each rebuild is online but I/O heavy)
-- idx_vehicle_tracks_position_gist on vehicle_tracks: 30720 MB, frag=37.4%, density=48.1%, free=44.2% (~13578 MB reclaimable)
REINDEX INDEX CONCURRENTLY idx_vehicle_tracks_position_gist;
-- Estimated total reclaim: 13578 MB

Configuration and Tuning Knobs

Threshold constants are the primary knobs. LEAF_FRAGMENTATION_MAX and AVG_LEAF_DENSITY_MIN gate flagging; requiring both to trip prevents rebuilding an index that is merely fragmented but still dense. MIN_INDEX_BYTES protects small indexes from ever being flagged, since reclaiming half of a 40 MB index is not worth a maintenance action.

Connection and safety settings for psycopg:

Setting Value Reason
conn.read_only = True enabled Guarantees the audit performs no writes
DSN target a hot standby Offloads the pgstattuple scan cost from the primary
statement_timeout 600000 (10 min) Caps a full scan on an unexpectedly huge index

Apply a statement timeout so a runaway pgstattuple scan cannot pin resources indefinitely:

python
with conn.cursor() as cur:
    cur.execute("SET statement_timeout = '600s'")

Scheduling. Run the flagger nightly from cron or pg_cron, capturing its output for review; only the emitted REINDEX INDEX CONCURRENTLY statements are ever executed against the primary, and only after a human confirms the plan.

Verification Steps

What REINDEX CONCURRENTLY actually reclaims Before the rebuild the index occupies 1.35 gigabytes of which 563 megabytes is free space. After the rebuild it occupies 810 megabytes with 40 megabytes free. During the rebuild both copies exist, so the database needs about 2.2 gigabytes of headroom for the duration. Before, during and after the rebuild before 563 MB free of 1.35 GB during both copies live — 2.2 GB of headroom needed after 810 MB, 40 MB free live entries reclaimable free space Check pg_database_size against the volume before starting: a rebuild that runs out of disk leaves an INVALID index.

Confirm the measurement matches reality on one flagged index. Record the pre-rebuild metrics, run the emitted statement manually, then re-measure:

sql
-- Before
SELECT avg_leaf_density, leaf_fragmentation FROM pgstatindex('idx_vehicle_tracks_position_gist');
SELECT free_percent FROM pgstattuple('idx_vehicle_tracks_position_gist');

-- Execute the plan
REINDEX INDEX CONCURRENTLY idx_vehicle_tracks_position_gist;

-- After: density should climb toward 90, fragmentation and free_percent should collapse
SELECT avg_leaf_density, leaf_fragmentation FROM pgstatindex('idx_vehicle_tracks_position_gist');
SELECT free_percent FROM pgstattuple('idx_vehicle_tracks_position_gist');

If the post-rebuild free_percent is close to the flagger’s approx_reclaimable estimate expressed as a fraction of the old size, your thresholds and estimate are calibrated correctly.

Gotchas Checklist

  • Never interpolate index names with f-strings into the SQL text. Here the names come from a trusted catalog query and are passed as parameters to pgstatindex(%s); a name sourced from anywhere else must still be passed as a parameter, never concatenated.
  • pgstattuple on the primary during peak hours can cause a latency spike by evicting hot buffers. Point the flagger at a read replica, or restrict full scans to off-peak windows.
  • leaf_fragmentation alone is a noisy signal. A recently built index can show moderate fragmentation for benign reasons; always gate on low avg_leaf_density as well before flagging.
  • The emitted plan is not auto-executed on purpose. REINDEX INDEX CONCURRENTLY transiently doubles the index’s disk footprint; confirm free space before running it on a 30 GB index.
  • A hot standby can measure but cannot rebuild. The REINDEX statements must run on the primary and will replicate; do not expect the replica to self-heal.