AWS Builder Center: Learn, Build and Connect with builders in the AWS community
AWS Builder Center is the official home for builders on AWS. Share and read what others are working on, follow people who inspire you, explore training and workshops, and find tools to support what you're building.
How to Track Partition Row Counts Without Lock Contention Using pg_stat_user_tables on Aurora PostgreSQL
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
| Column | Update Frequency | Description |
|---|---|---|
| n_live_tup | After ANALYZE | Approximate live row count. Refreshed when ANALYZE runs |
| n_tup_ins | Real-time | Total rows inserted since last instance restart (cumulative). Updated in near-real-time |
| n_tup_del | Real-time | Total rows inserted since last instance restart (cumulative). Updated in near-real-time |
| n_mod_since_analyze | Per DML | Rows modified since last ANALYZE. Resets to 0 after ANALYZE |
| last_autoanalyze | After ANALYZE | Timestamp 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 Size | Default Threshold (scale_factor=0.10) | Time to Trigger at 2,000 txn/hr |
|---|---|---|
| 100,000 rows | 10,050 | ~5 hours |
| 500,000 rows | 50,050 | ~25 hours |
| 10,000,000 rows | 1,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_factor | Threshold (500K rows) | @ 2,000 txn/hr | @ 10,000 txn/hr |
|---|---|---|---|
| 0.10 (default) | 50,050 | ~25 hrs | ~5 hrs |
| 0.005 | 2,600 | ~78 min | ~16 min |
| 0.001 (recommended) | 550 | ~16 min | ~3 min |
Global Settings (RDS Parameter Group)
| Parameter | Default | Recommended | Why |
|---|---|---|---|
| autovacuum_naptime | 60s | 15–30s | Autovacuum checks tables more frequently |
| autovacuum_max_workers | 3 | 5–8 | More 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
| Scenario | Impact | Mitigation |
|---|---|---|
| Transaction ROLLBACK | n_tup_ins temporarily | overcounts |
| TRUNCATE | n_tup_ins - n_tup_del becomes inaccurate | Use n_live_tup after TRUNCATE operations |
| Long-running transactions | Block autovacuum from running ANALYZE | Monitor 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
| Aspect | Result |
|---|---|
| Write-path overhead | Zero — no triggers, no extra tables |
| Application code changes | None — entirely DB-side |
| Accuracy | Real-time via n_tup_ins - n_tup_del; verified by tuned ANALYZE |
| Self-healing | Yes — each ANALYZE overwrites with ground truth |
| Worst-case staleness | Bounded by pg_cron interval (e.g., 5 minutes) |
| Lock contention | None 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.
- 语言
- English
