Identify Insert-Only Tables in PostgreSQL
Identify Insert-Only Tables in PostgreSQL Using pg_stat_user_tables
Not all PostgreSQL tables behave the same way. Most application tables have a mix of inserts, updates, and deletes. A minority of tables receive only inserts. These append-only tables — event logs, audit trails, sensor readings, message queues — have fundamentally different storage and maintenance characteristics.
A DBA who treats an insert-only table the same as a general-purpose table will misconfigure autovacuum, miss partitioning opportunities, and misread bloat signals. The starting point is knowing which tables are insert-only. PostgreSQL tracks per-table DML counts in pg_stat_user_tables, and a simple query against that view identifies them.
SQL Script
1SELECT
2 relname,
3 n_tup_ins,
4 n_tup_upd,
5 n_tup_del
6FROM pg_stat_user_tables
7WHERE relname = 'trip_positions';
Notes: Statistics in pg_stat_user_tables accumulate since the last pg_stat_reset() call or since the statistics collector was started. Run as any role with SELECT access to the view. To find all insert-only tables across the database, remove the WHERE clause and filter with WHERE n_tup_upd = 0 AND n_tup_del = 0 AND n_tup_ins > 0.
Code Breakdown
SELECT relname, n_tup_ins, n_tup_upd, n_tup_del
relname is the table name. The three counter columns are:
n_tup_ins— total rows inserted since last stats resetn_tup_upd— total rows updatedn_tup_del— total rows deleted
These are cumulative counters. A COPY command counts toward n_tup_ins. A VACUUM that removes dead tuples does not change these counters.
FROM pg_stat_user_tables
pg_stat_user_tables returns one row per user-defined table in the current database, excluding catalog and system tables. The view also contains vacuum and analyse timing columns (last_autovacuum, last_autoanalyze) that are useful for follow-up investigation.
WHERE relname = 'trip_positions'
The original script targets a specific table. In practice, remove this and add:
1WHERE n_tup_upd = 0
2 AND n_tup_del = 0
3 AND n_tup_ins > 1000
4ORDER BY n_tup_ins DESC;
This finds all insert-only tables with meaningful traffic, sorted by busiest first.
Key Points
- A table with
n_tup_upd = 0andn_tup_del = 0is a candidate insert-only table. - Insert-only tables produce no dead tuples from
DELETEorUPDATE. Autovacuum's default thresholds are calibrated for mixed workloads and are often poorly matched to append-only tables. - The absence of dead tuples does not mean autovacuum can be disabled. The visibility map still needs updating, and the freeze counter must be advanced to prevent transaction ID wraparound.
- Large insert-only tables that grow without bound are strong candidates for time-based partitioning.
- If
pg_stat_reset()has been called recently, counters return to zero. A table that looks insert-only may simply have had its counters cleared.
Insights and Explanations
Insert-only tables appear in nearly every production PostgreSQL database. Common examples:
- Audit logs: Events recorded by triggers or application logic. Rows are written once and read for compliance or debugging. Never updated, rarely deleted.
- Event streams: Click events, page views, API request logs. Very high insert rate. Queries are time-bounded aggregations.
- Sensor or telemetry data: IoT devices writing readings at fixed intervals. Inserts only. Queries aggregate over time windows.
- Job queues (simpler implementations): New jobs inserted, completed jobs left in place or status-updated.
The trip_positions table in the script example — GPS position data written as a vehicle moves — is a clean example of this pattern.
Autovacuum Behaviour for Insert-Only Tables
Autovacuum has two main triggers: dead tuple accumulation and freeze age. Insert-only tables accumulate no dead tuples, so the dead-tuple trigger never fires. The freeze trigger does fire eventually, but for a table that is only growing, the oldest unfrozen rows are always the earliest inserts.
PostgreSQL 13 introduced autovacuum_vacuum_insert_threshold and autovacuum_vacuum_insert_scale_factor. These parameters trigger autovacuum on insert-only tables based on the number of new inserts, ensuring the visibility map stays current and freeze work is done incrementally. For PostgreSQL 12 and earlier, insert-only tables were essentially invisible to autovacuum's vacuum trigger.
Bloat vs. Unbounded Growth
Insert-only tables do not bloat in the traditional sense. Traditional bloat comes from dead tuples that autovacuum has not reclaimed. Insert-only tables have none of that.
What they do have is unbounded growth. If rows are never deleted, the table grows until it hits disk capacity or a manual archival process runs. The distinction matters because the remediation is different. Bloat is fixed with VACUUM. Unbounded growth is fixed with a data lifecycle policy: archival, partitioned drop, or time-to-live deletion.
Follow-Up Queries
Once you have a list of insert-only tables, check their size and vacuum history:
1SELECT
2 relname,
3 pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
4 n_tup_ins,
5 last_autovacuum,
6 last_autoanalyze
7FROM pg_stat_user_tables
8WHERE n_tup_upd = 0
9 AND n_tup_del = 0
10 AND n_tup_ins > 0
11ORDER BY pg_total_relation_size(relid) DESC;
Additional Considerations
- Time-based partitioning: Insert-only tables are ideal candidates for range partitioning by time. Old partitions can be detached and dropped instantly — no
DELETE, no bloat, no long lock. - Autovacuum tuning for PG13+: Lower
autovacuum_vacuum_insert_scale_factorper table to keep the visibility map current on high-insert-rate tables. - Statistics reset warning: Check
SELECT stats_reset FROM pg_stat_bgwriterbefore drawing conclusions. If reset was recent, the insert-only identification is unreliable until counters accumulate meaningful history. - Retention policy: Confirm with the application team whether a data retention policy exists before taking any action on large insert-only tables.
References
Posts in this series
- How Many Connections Can Your PostgreSQL Database Handle?
- PostgreSQL Backend Connections via pg_stat_database
- Identifying Blocking PostgreSQL Queries using pg_stat_activity
- List PostgreSQL Databases by Size with Access Check
- Assess PostgreSQL Database Sizes Quickly and Easily
- Unveiling Your PostgreSQL Server - A Diagnostic Powerhouse
- Keep Your PostgreSQL Database Clean, Identify Idle Connections
- Query the PostgreSQL Configuration
- PostgreSQL Recovery Monitoring: Essential SQL Insights
- Restart All PostgreSQL Sequences with ALTER SEQUENCE
- Monitor Running Queries in PostgreSQL using pg_stat_activity
- Monitor PostgreSQL Active Sessions with pg_stat_activity
- PostgreSQL Error Handling Settings via pg_settings
- PostgreSQL File Location Settings Query via pg_settings
- PostgreSQL Lock Management Settings via pg_settings
- PostgreSQL Logging Configuration Query via pg_settings
- Monitor PostgreSQL Memory Settings with pg_settings
- PostgreSQL Table Row Count Estimates with SQL
- List PostgreSQL Tables by Size with SQL
- PostgreSQL WAL Settings Query Guide
- PostgreSQL SSL Settings Query Guide
- PostgreSQL Statistics Settings Query Guide
- PostgreSQL Resource Settings Query Guide
- PostgreSQL Replication Settings Query Guide
- PostgreSQL Query Planning Settings Query Guide
- PostgreSQL Preset Options Settings Query Guide
- PostgreSQL Miscellaneous Settings Query Guide
- Count PostgreSQL Sessions by State with SQL
- Kill Idle PostgreSQL Sessions with SQL
- Grant SELECT on All Tables in PostgreSQL
- Identify Insert-Only Tables in PostgreSQL
- Detect Soft Delete Patterns in PostgreSQL
- List PostgreSQL Object Comments with SQL
- List Foreign Key Constraints in PostgreSQL
- List PostgreSQL Enum Types and Their Values with SQL
- List All Views in a PostgreSQL Database with SQL
- Find PostgreSQL Tables Without a Primary Key
- List PostgreSQL Partitioned Tables with SQL