Database Technical Debt: How to Identify and Fix It
Quick answer: Database debt is the gap between the schema and access patterns you have and the ones your workload now needs: missing and unused indexes, wrong types, dead columns, untuned autovacuum. Inventory it from live catalog and statistics data rather than from memory, rank by cost times risk, and repay it on a schedule instead of during an incident.
Database technical debt is the accumulation of suboptimal schema designs, missing indexes, unoptimized queries, stale configurations, and deferred maintenance that degrades performance and increases operational costs over time. It compounds like financial debt: the longer it is ignored, the more expensive it becomes to fix. Common causes include schema decisions that were correct two years ago but no longer fit the workload, ORM defaults that were never tuned, indexes that were never created (or never removed), and configurations that were set during initial setup and never revisited. Whatever debt a database carries tends to stay invisible until it causes an incident.
Types of database technical debt
Section titled “Types of database technical debt”Schema debt
Section titled “Schema debt”Schema decisions that were reasonable at one point but no longer fit the workload:
- Wrong data types. Using
textfor fields that should beinteger,varchar(255)everywhere because the ORM defaults to it,timestamp without time zonewhen you need time zone awareness. - Missing constraints. Foreign keys, NOT NULL constraints, and CHECK constraints that were skipped “to move fast” and now allow data integrity issues.
- Unnecessary normalization. Excessive JOINs for data that is always accessed together.
- Unnecessary denormalization. Duplicate data that gets out of sync.
- Missing partitioning. Tables that grew to hundreds of millions of rows without partition strategies.
- Unused columns. Columns added for features that were abandoned, still consuming storage and cluttering the schema.
Query debt
Section titled “Query debt”Queries that work but waste resources:
- N+1 patterns. ORM-generated query storms that fire hundreds of queries when a few would suffice.
- SELECT * everywhere. Loading all columns when only a few are needed.
- Unoptimized pagination. OFFSET-based pagination that degrades as page numbers increase.
- Missing eager loading. Lazy loading defaults that were never addressed.
- Application-side filtering. Fetching large result sets and filtering in code instead of in SQL.
- Redundant queries. The same data fetched multiple times per request.
Index debt
Section titled “Index debt”Indexes that are missing, unused, or redundant:
- Missing indexes. Columns used in WHERE, JOIN, and ORDER BY clauses without corresponding indexes.
- Unused indexes. Indexes created during development that no query uses in production. Each one costs storage, adds work to every INSERT, and adds work to VACUUM. Indexing an extra column also makes HOT updates less likely, which turns cheap in-page updates into full index maintenance. DELETE is not slowed directly, index entries for deleted rows are cleaned up later by VACUUM, not at DELETE time.
- Redundant indexes. An index on
(a)is usually redundant when(a, b)exists, because a multicolumn B-tree serves queries constrained on its leading columns. Confirm before dropping:(a)is not redundant if it is UNIQUE and(a, b)is not, if(a, b)is partial, if the two use different access methods, operator classes, or collations, or if(a)is much narrower and backs hot index-only scans that read onlya. - Bloated indexes. Indexes that have grown much larger than necessary due to accumulated dead tuples.
Configuration debt
Section titled “Configuration debt”Settings that do not match the current workload:
- Default PostgreSQL settings.
shared_buffers = 128MBon a server with 64 GB of RAM. - Untuned
random_page_cost. 4.0 is not a legacy spinning-disk number; the docs describe it as an already-reduced default that assumes most random reads are served from cache. Lower it when your working set genuinely fits in memory and storage latency is low; raise it for storage with a high random-read penalty. - Conservative autovacuum. Default settings on tables with millions of writes per day.
- Undersized
work_mem. Causing sorts and hashes to spill to disk.
Operational debt
Section titled “Operational debt”Deferred maintenance that accumulates over time:
- Table bloat. Dead tuples consuming storage and slowing scans.
- Index bloat. Indexes growing beyond their efficient size.
- Stale statistics.
ANALYZEnot running frequently enough on rapidly changing tables. - Outdated extensions. Running old versions of
pg_stat_statements,PostGIS, or other extensions. - Missing monitoring. No visibility into what is happening inside the database.
How to identify database technical debt
Section titled “How to identify database technical debt”Query-Level assessment
Section titled “Query-Level assessment”These queries require the pg_stat_statements extension. It must be listed in shared_preload_libraries and the server restarted, then CREATE EXTENSION pg_stat_statements; run in the target database. Reading other users’ query text requires superuser or membership in pg_read_all_stats. Columns are total_exec_time/mean_exec_time on PostgreSQL 13 and later; on 12 and earlier they are total_time/mean_time.
-- Top resource-consuming queriesSELECT query, calls, total_exec_time, mean_exec_time, shared_blks_hit + shared_blks_read as total_buffersFROM pg_stat_statementsORDER BY total_exec_time DESCLIMIT 20;
-- Queries with high buffer reads (inefficient I/O)SELECT query, calls, shared_blks_read as disk_reads, shared_blks_hit as cache_hits, round(100.0 * shared_blks_read / greatest(shared_blks_read + shared_blks_hit, 1), 2) as miss_pctFROM pg_stat_statementsWHERE shared_blks_read > 1000ORDER BY shared_blks_read DESCLIMIT 20;Index assessment
Section titled “Index assessment”-- Unused indexes (wasting write performance and storage)-- Requires PostgreSQL 16+ for last_idx_scan. On 15 and earlier, drop that one-- column, the rest of the query is unchanged and still works.SELECT s.schemaname, s.relname, s.indexrelname, s.idx_scan, s.last_idx_scan, pg_size_pretty(pg_relation_size(s.indexrelid)) AS sizeFROM pg_stat_user_indexes sJOIN pg_index i ON i.indexrelid = s.indexrelidWHERE s.idx_scan = 0 AND i.indisvalid -- skip failed CREATE INDEX CONCURRENTLY leftovers AND NOT i.indisunique -- unique indexes enforce uniqueness (implies NOT indisprimary) AND NOT i.indisexclusion -- backs an EXCLUDE constraint; cannot be dropped AND NOT i.indisreplident -- serves REPLICA IDENTITY USING INDEXORDER BY pg_relation_size(s.indexrelid) DESC;idx_scan is a cumulative counter, not a verdict. It resets on pg_stat_reset() and on an unclean shutdown or restore from base backup; it is per-server, so an index used only by read replicas shows idx_scan = 0 on the primary; and it will not reflect a quarterly report that has not run yet. On PostgreSQL 16 and later, check last_idx_scan alongside idx_scan, a NULL there plus a stats_reset far in the past is much stronger evidence than a zero count.
-- Tables with disproportionate sequential scans (missing indexes)SELECT relname, seq_scan, idx_scan, round(100.0 * seq_scan / greatest(seq_scan + idx_scan, 1), 2) as seq_pct, pg_size_pretty(pg_table_size(relid)) as sizeFROM pg_stat_user_tablesWHERE pg_table_size(relid) > 10485760 -- Tables > 10 MBORDER BY seq_scan DESCLIMIT 20;Table health assessment
Section titled “Table health assessment”-- Tables with most dead tuples (bloat indicator)SELECT relname, n_live_tup, n_dead_tup, round(100.0 * n_dead_tup / greatest(n_live_tup, 1), 2) as dead_pct, last_autovacuum, last_autoanalyzeFROM pg_stat_user_tablesWHERE n_dead_tup > 10000ORDER BY n_dead_tup DESC;
-- Largest tables by total size (indexes + TOAST included).-- NOTE: this is raw size, NOT bloat. For actual bloat use the pgstattuple-- extension: pgstattuple('tablename') is exact but scans the whole relation;-- pgstattuple_approx() is cheaper. Size alone says nothing about dead space.SELECT n.nspname, c.relname, pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size, pg_size_pretty(pg_table_size(c.oid)) AS table_size, pg_size_pretty(pg_indexes_size(c.oid)) AS index_sizeFROM pg_class cJOIN pg_namespace n ON n.oid = c.relnamespaceWHERE c.relkind IN ('r','p') AND n.nspname = 'public'ORDER BY pg_total_relation_size(c.oid) DESCLIMIT 20;Configuration assessment
Section titled “Configuration assessment”-- Key settings to checkSELECT name, setting, unit, boot_val, reset_valFROM pg_settingsWHERE name IN ( 'shared_buffers', 'effective_cache_size', 'work_mem', 'maintenance_work_mem', 'random_page_cost', 'effective_io_concurrency', 'max_connections', 'autovacuum_vacuum_scale_factor', 'autovacuum_analyze_scale_factor');Prioritizing debt repayment
Section titled “Prioritizing debt repayment”Not all database technical debt is equally costly. Prioritize by impact:
Critical (Fix immediately)
Section titled “Critical (Fix immediately)”- Missing indexes on frequently queried large tables
- N+1 patterns on high-traffic endpoints
shared_buffersat default on production
High priority (Fix within weeks)
Section titled “High priority (Fix within weeks)”- SELECT * on wide tables with frequent queries
random_page_costleft at 4.0 on storage whose behavior clearly differs from the default’s assumptions (an all-in-memory working set, or a high random-read penalty)- Unused indexes on write-heavy tables
- Autovacuum configuration on large, active tables
- Stale statistics on tables with rapid data changes
Medium priority (Fix within quarter)
Section titled “Medium priority (Fix within quarter)”- Schema type improvements
- OFFSET-based pagination replacement
- Redundant index cleanup
- Extension upgrades
Low priority (Track for opportunistic fixes)
Section titled “Low priority (Track for opportunistic fixes)”- Unused columns
- Minor schema normalization adjustments
- Cosmetic naming inconsistencies
Building a debt reduction process
Section titled “Building a debt reduction process”1. Inventory
Section titled “1. Inventory”Run the assessment queries above. Document what you find. This is your technical debt ledger.
2. Quantify impact
Section titled “2. Quantify impact”For each item, estimate:
- Performance impact. How much database time does this waste?
- Cost impact. How much extra compute does this require?
- Risk. Could this cause an outage under load?
3. Prioritize
Section titled “3. Prioritize”Rank by impact and effort. Quick wins (adding a missing index, dropping a confirmed-unused index) should be done immediately.
4. Validate
Section titled “4. Validate”Test every fix on a database clone with production-like data. Measure before and after. Database technical debt fixes sometimes introduce new problems.
5. Track
Section titled “5. Track”Keep a running list of known debt items. Allocate a percentage of engineering time to debt reduction. Track progress quarterly.
Frequently asked questions
Section titled “Frequently asked questions”How do I estimate the cost of database technical debt?
Start withpg_stat_statementsto find the top resource-consuming queries. Sum theirtotal_exec_timeas a share of alltotal_exec_timeto get the fraction of database time spent on inefficient work. That fraction is an upper bound on what fixing them could save, not a realized saving: on a provisioned instance you bank nothing until you downsize a tier, and on serverless or consumption billing the saving tracks actual CPU-seconds much more directly. State which billing model you are on before converting a percentage to dollars.How does database technical debt accumulate?
Gradually. Schema decisions made under deadline pressure, ORM defaults left in place, indexes created for one-time queries and never removed, configuration set during initial deployment and forgotten. Each item is small. The aggregate impact is large.Should I fix database debt or application debt first?
Database debt often has a higher per-item impact because it affects every query and every user. A single missing index can slow an entire feature, and a single N+1 pattern can dominate a database’s workload. Start with the database items that have the highest measurable impact.Can I automate database debt detection?
Yes. Run assessment queries on a schedule, track results over time, and alert on regressions. For continuous detection that surfaces and explains each item as it appears, platforms like DBGorilla provide this without building custom tooling, you still decide and apply the fixes.How much time should we allocate to database debt reduction?
Pick a standing share of database-adjacent engineering time and defend it, rather than fitting debt work into whatever is left over. However, if you have critical debt (missing indexes on high-traffic tables, production N+1 patterns), treat those as urgent fixes, not scheduled debt work.