Skip to content

How to Track Partition Row Counts Without Lock Contention Using pg_stat_user_tables on Aurora PostgreSQL

7 minute read
Content level: Advanced
3

When using LIST-partitioned tables on Aurora PostgreSQL or RDS for PostgreSQL, teams often track per-partition row counts for load balancing decisions. A common approach — row-level triggers updating a counter table on every INSERT/DELETE — causes the Hot Row Problem at scale: all writers serialize through a single row lock, degrading throughput linearly. This article shows how to replace trigger-based tracking with PostgreSQL's built-in pg_stat_user_tables, which provides real-time row count

The Problem When using LIST-partitioned tables on Aurora PostgreSQL, a common pattern for load balancing is to track per-partition row counts in a metadata table. Many implementations use a row-level trigger that updates a counter row on every INSERT or DELETE. At scale, this creates the Hot Row Problem: every concurrent writer acquires a row lock on the same counter row. Writers serialize through that single lock, and throughput degrades linearly with load. Alternative trigger-based approaches (delta tables, application-level counters) either shift the lock contention to a rollup phase or add operational complexity without fully eliminating the problem.

The Solution: pg_stat_user_tables

PostgreSQL already tracks row-level statistics for every table and partition internally — no triggers or additional tables required. The pg_stat_user_tables system view exposes these statistics, providing two methods to derive partition row counts with zero write-path overhead.

Key Columns

ColumnUpdate FrequencyDescription
n_live_tupAfter ANALYZEApproximate live row count. Refreshed when ANALYZE runs
n_tup_insReal-timeTotal rows inserted since last instance restart (cumulative). Updated in near-real-time
n_tup_delReal-timeTotal rows inserted since last instance restart (cumulative). Updated in near-real-time
n_mod_since_analyzePer DMLRows modified since last ANALYZE. Resets to 0 after ANALYZE
last_autoanalyzeAfter ANALYZETimestamp of last automatic ANALYZE

Two Ways to Get Row Counts


-- Method 1: ANALYZE-dependent (may be stale between ANALYZE runs)
SELECT relname, n_live_tup
FROM pg_stat_user_tables
WHERE relname LIKE 'my_table_part_%';

-- Method 2: Always real-time (updated on every committed DML)
SELECT relname,
       n_tup_ins - n_tup_del AS live_row_count
FROM pg_stat_user_tables
WHERE relname LIKE 'my_table_part_%';

Use Method 2 (n_tup_ins - n_tup_del) as your primary metric for monitoring and rebalancing. It requires no ANALYZE and is always current. Use n_live_tup as the authoritative, ANALYZE-verified confirmation.

The Challenge: Keeping n_live_tup Fresh on Large Tables

While n_tup_ins - n_tup_del is always current, n_live_tup is only refreshed when ANALYZE runs. PostgreSQL's autovacuum decides when to trigger ANALYZE using this formula:

Trigger ANALYZE when:
  n_mod_since_analyze > autovacuum_analyze_threshold + (autovacuum_analyze_scale_factor × n_live_tup)

Why Defaults Fail at Scale

Table SizeDefault Threshold (scale_factor=0.10)Time to Trigger at 2,000 txn/hr
100,000 rows10,050~5 hours
500,000 rows50,050~25 hours
10,000,000 rows1,000,050~500 hours

At millions of rows, the default 10% scale factor produces thresholds so high that ANALYZE effectively never triggers under normal transaction volume. PostgreSQL does not alert on stale statistics — the outdated values are served silently.

Fix: Per-Partition Autovacuum Tuning

Apply aggressive ANALYZE settings to each partition individually. This is surgical — it does not affect other tables.

ALTER TABLE my_table_part_001 SET (
    autovacuum_analyze_threshold    = 50,
    autovacuum_analyze_scale_factor = 0.001
);

With scale_factor = 0.001 on a 500,000-row partition:

Threshold = 50 + (0.001 × 500,000) = 550

At 2,000 txn/hr, ANALYZE now triggers in ~16 minutes instead of ~25 hours — a 94% improvement.

Apply to All Partitions Dynamically


DO $$
DECLARE
    tbl TEXT;
BEGIN
    FOR tbl IN
        SELECT inhrelid::regclass::text
        FROM pg_inherits
        WHERE inhparent = 'my_partitioned_table'::regclass
          AND inhrelid::regclass::text NOT LIKE '%default%'
    LOOP
        EXECUTE format(
            'ALTER TABLE %s SET (
                autovacuum_analyze_threshold    = 50,
                autovacuum_analyze_scale_factor = 0.001
            )', tbl
        );
    END LOOP;
END $$;

Refresh Timing Comparison

scale_factorThreshold (500K rows)@ 2,000 txn/hr@ 10,000 txn/hr
0.10 (default)50,050~25 hrs~5 hrs
0.0052,600~78 min~16 min
0.001 (recommended)550~16 min~3 min

Global Settings (RDS Parameter Group)

ParameterDefaultRecommendedWhy
autovacuum_naptime60s15–30sAutovacuum checks tables more frequently
autovacuum_max_workers35–8More partitions analyzed in parallel

Safety Net: Scheduled ANALYZE via pg_cron

To cap maximum staleness regardless of autovacuum behavior:

CREATE EXTENSION IF NOT EXISTS pg_cron;

SELECT cron.schedule(
    'analyze_partitions',
    '*/5 * * * *',
    $$ANALYZE my_partitioned_table;$$
);

This guarantees stats are never more than 5 minutes stale, even if autovacuum is delayed by long-running transactions.

Monitoring Stats Freshness

SELECT
    relname AS partition,
    n_live_tup AS reported_rows,
    n_tup_ins - n_tup_del AS realtime_rows,
    (n_tup_ins - n_tup_del) - n_live_tup AS drift,
    n_mod_since_analyze AS mods_pending,
    now() - GREATEST(last_analyze, last_autoanalyze) AS time_since_refresh,
    CASE
        WHEN n_mod_since_analyze = 0 THEN 'VERIFIED'
        WHEN n_mod_since_analyze < 1000 THEN 'SLIGHTLY STALE'
        ELSE 'STALE — CONSIDER MANUAL ANALYZE'
    END AS status
FROM pg_stat_user_tables
WHERE relname LIKE 'my_table_part_%'
ORDER BY n_mod_since_analyze DESC;

Key signal: n_mod_since_analyze = 0 means ANALYZE ran recently and stats are verified. A growing value means changes are accumulating without verification.

Edge Cases to Monitor

ScenarioImpactMitigation
Transaction ROLLBACKn_tup_ins temporarilyovercounts
TRUNCATEn_tup_ins - n_tup_del becomes inaccurateUse n_live_tup after TRUNCATE operations
Long-running transactionsBlock autovacuum from running ANALYZEMonitor pg_stat_activity. Set idle_in_transaction_session_timeout
Many partitions (100+)Limited parallel ANALYZE (max_workers=3)Increase autovacuum_max_workers to 5–8

Summary

AspectResult
Write-path overheadZero — no triggers, no extra tables
Application code changesNone — entirely DB-side
AccuracyReal-time via n_tup_ins - n_tup_del; verified by tuned ANALYZE
Self-healingYes — each ANALYZE overwrites with ground truth
Worst-case stalenessBounded by pg_cron interval (e.g., 5 minutes)
Lock contentionNone at any scale

Conclusion

For LIST-partitioned tables on Aurora PostgreSQL or RDS for PostgreSQL that require per-partition row counts for load balancing or rebalancing decisions, pg_stat_user_tables provides a production-ready solution with zero write-path overhead. By combining real-time row counts (n_tup_ins - n_tup_del) with aggressively tuned autovacuum ANALYZE settings (autovacuum_analyze_scale_factor = 0.001) and a pg_cron safety net, you can achieve near-real-time partition statistics accuracy without triggers, additional tables, or application code changes. The key takeaways:

  • Consider replacing triggers for row counting — at scale, they can create the Hot Row Problem, and PostgreSQL already tracks this data internally through its statistics infrastructure.
  • Tune autovacuum per partition — the default scale_factor of 0.10 may produce thresholds too high for large tables, which can lead to silently stale statistics. Adjusting this per partition helps ensure timely ANALYZE runs.
  • Monitor n_mod_since_analyze — this column serves as your primary signal for stats freshness. If it is consistently growing large on any partition, it may be worth tightening that partition's autovacuum settings.
  • Use pg_cron as a safety net — scheduling ANALYZE every 2–5 minutes helps bound worst-case staleness, providing protection against autovacuum delays or long-running transactions.

This approach leverages infrastructure PostgreSQL already maintains — requiring only configuration knowledge to unlock it.

AWS
SUPPORT ENGINEER

published 12 days ago89 views