[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-deep-evm-27-postgresql-performance-indexes-vacuum":3},{"article":4,"author":55},{"id":5,"category_id":6,"title":7,"slug":8,"excerpt":9,"content_md":10,"content_html":11,"locale":12,"author_id":13,"published":14,"published_at":15,"meta_title":16,"meta_description":17,"focus_keyword":18,"og_image":19,"canonical_url":19,"robots_meta":20,"created_at":15,"updated_at":15,"tags":21,"category_name":24,"related_articles":35},"d0000000-0000-0000-0000-000000000127","a0000000-0000-0000-0000-000000000005","Deep EVM #27: PostgreSQL Performance at Scale — Indexes, VACUUM, and Query Optimization","deep-evm-27-postgresql-performance-indexes-vacuum","Master PostgreSQL performance tuning with partial indexes, covering indexes, BRIN for time-series, autovacuum configuration, and EXPLAIN ANALYZE interpretation.","## The Performance Toolkit\n\nPostgreSQL is remarkably fast out of the box, but at scale — tens of millions of rows, thousands of queries per second — you need to understand its internals to maintain performance. This article covers the three pillars of PostgreSQL performance: indexes, VACUUM, and query optimization.\n\n## Index Types and When to Use Them\n\n### B-Tree (Default)\n\nThe default index type. Supports equality and range queries. If you do not specify a type, you get B-Tree:\n\n```sql\nCREATE INDEX idx_transactions_block\n    ON transactions (block_number);\n\n-- Supports:\n-- WHERE block_number = 18000000\n-- WHERE block_number > 18000000\n-- WHERE block_number BETWEEN 18000000 AND 18100000\n-- ORDER BY block_number\n```\n\n### Partial Indexes\n\nIndex only rows matching a condition. Dramatically smaller than full indexes:\n\n```sql\n-- Only index pending transactions (0.1% of table)\nCREATE INDEX idx_transactions_pending\n    ON transactions (created_at)\n    WHERE status = 'pending';\n\n-- Index only recent high-value transfers\nCREATE INDEX idx_high_value_recent\n    ON transactions (from_addr, to_addr)\n    WHERE value_wei > 1000000000000000000  -- > 1 ETH\n    AND block_number > 18000000;\n```\n\nA partial index on 0.1% of a 34M-row table is 170KB instead of 800MB. Queries that match the WHERE condition use the tiny index; queries that do not match fall back to other indexes or sequential scan.\n\n### Covering Indexes (INCLUDE)\n\nInclude non-key columns in the index to enable index-only scans:\n\n```sql\n-- Without INCLUDE: index lookup + heap fetch for each row\nCREATE INDEX idx_tx_from ON transactions (from_addr);\n-- Query: SELECT from_addr, value_wei FROM transactions WHERE from_addr = $1\n-- Plan: Index Scan -> Heap Fetch (slow for many rows)\n\n-- With INCLUDE: index-only scan (no heap fetch)\nCREATE INDEX idx_tx_from_covering\n    ON transactions (from_addr)\n    INCLUDE (value_wei, block_number, tx_hash);\n-- Plan: Index Only Scan (fast, no heap access)\n```\n\nThe trade-off: covering indexes are larger because they store extra columns. Use them for frequently queried column combinations.\n\n### BRIN Indexes (Block Range INdex)\n\nFor time-series or monotonically increasing data, BRIN indexes are tiny and effective:\n\n```sql\n-- BRIN index: ~100KB for 34M rows (vs ~800MB B-Tree)\nCREATE INDEX idx_transactions_block_brin\n    ON transactions USING BRIN (block_number)\n    WITH (pages_per_range = 128);\n```\n\nBRIN works by storing min\u002Fmax values per range of physical pages. If your data is inserted in order (block_number increases monotonically), BRIN can skip entire page ranges efficiently. If data is randomly ordered, BRIN is useless.\n\n```sql\n-- Perfect for BRIN: data inserted in block_number order\nSELECT * FROM transactions WHERE block_number BETWEEN 18000000 AND 18100000;\n-- BRIN eliminates 95% of pages in ~0.1ms\n\n-- Bad for BRIN: random access pattern\nSELECT * FROM transactions WHERE from_addr = $1;\n-- BRIN cannot help here\n```\n\n### GIN Indexes (Generalized Inverted Index)\n\nFor JSONB columns and full-text search:\n\n```sql\n-- Index JSONB fields for containment queries\nCREATE INDEX idx_events_data_gin\n    ON events USING GIN (data jsonb_path_ops);\n\n-- Supports:\n-- WHERE data @> '{\"event\": \"Transfer\"}'\n-- WHERE data @> '{\"args\": {\"from\": \"0x...\"}}'\n\n-- Full-text search\nCREATE INDEX idx_articles_fts\n    ON articles USING GIN (to_tsvector('english', title || ' ' || content));\n```\n\n## VACUUM: The Silent Performance Killer\n\nPostgreSQL uses MVCC (Multi-Version Concurrency Control). When you UPDATE or DELETE a row, the old version is not removed — it is marked as dead. VACUUM reclaims space from dead tuples.\n\n### Why VACUUM Matters\n\nWithout VACUUM:\n- Dead tuples accumulate, bloating the table\n- Index scans must skip dead tuples (slower)\n- Transaction ID wraparound can halt the entire database\n\n### Autovacuum Configuration\n\nDefault autovacuum settings are conservative. For high-write tables, tune aggressively:\n\n```sql\n-- Per-table autovacuum tuning\nALTER TABLE transactions SET (\n    autovacuum_vacuum_threshold = 10000,\n    autovacuum_vacuum_scale_factor = 0.01,\n    autovacuum_analyze_threshold = 5000,\n    autovacuum_analyze_scale_factor = 0.005,\n    autovacuum_vacuum_cost_delay = 2\n);\n```\n\nExplanation:\n- `vacuum_threshold = 10000`: Start VACUUM after 10,000 dead tuples (default: 50)\n- `vacuum_scale_factor = 0.01`: Plus 1% of table size (default: 20%!)\n- `vacuum_cost_delay = 2`: Reduce sleep between vacuum operations (default: 2ms)\n\nFor a 34M-row table, the default scale factor of 20% means autovacuum waits until 6.8 million dead tuples accumulate. That is catastrophic. Set it to 1% (340,000 dead tuples) or lower.\n\n### Monitoring Vacuum\n\n```sql\n-- Check dead tuple counts\nSELECT\n    schemaname,\n    relname,\n    n_live_tup,\n    n_dead_tup,\n    ROUND(n_dead_tup::numeric \u002F NULLIF(n_live_tup, 0) * 100, 2) AS dead_pct,\n    last_vacuum,\n    last_autovacuum,\n    last_analyze\nFROM pg_stat_user_tables\nWHERE n_dead_tup > 10000\nORDER BY n_dead_tup DESC;\n```\n\n### Table Bloat\n\nEven after VACUUM, space is not returned to the OS — it is only marked as reusable. To actually shrink the table:\n\n```sql\n-- VACUUM FULL: rewrites entire table (locks it exclusively)\nVACUUM FULL transactions;  -- DO NOT run in production\n\n-- Better: pg_repack (no exclusive lock)\n-- Install extension first\npg_repack --table transactions --no-kill-backend --no-superuser-check\n```\n\n`pg_repack` creates a new copy of the table, copies data, then swaps — all without an exclusive lock. Essential for production systems.\n\n## EXPLAIN ANALYZE: Reading Query Plans\n\nThe most important PostgreSQL debugging tool:\n\n```sql\nEXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)\nSELECT t.*, a.balance_wei\nFROM transactions t\nJOIN addresses a ON t.from_addr = a.address\nWHERE t.block_number BETWEEN 18000000 AND 18100000\nORDER BY t.value_wei DESC\nLIMIT 100;\n```\n\nReading the output:\n\n```\nLimit (actual time=12.3..12.5 rows=100 loops=1)\n  -> Sort (actual time=12.3..12.4 rows=100 loops=1)\n        Sort Key: t.value_wei DESC\n        Sort Method: top-N heapsort  Memory: 42kB\n        -> Nested Loop (actual time=0.2..10.1 rows=50234 loops=1)\n              -> Index Scan using idx_transactions_block on transactions t\n                    (actual time=0.1..3.2 rows=50234 loops=1)\n                    Index Cond: block_number >= 18000000 AND block_number \u003C= 18100000\n                    Buffers: shared hit=1234\n              -> Index Scan using addresses_pkey on addresses a\n                    (actual time=0.001..0.001 rows=1 loops=50234)\n                    Index Cond: address = t.from_addr\n                    Buffers: shared hit=150702\nPlanning Time: 0.5ms\nExecution Time: 12.8ms\n```\n\nKey metrics to watch:\n- **actual time**: Real execution time (start..end) in milliseconds\n- **rows**: Actual rows processed (compare with estimate for accuracy)\n- **Buffers: shared hit**: Pages read from cache (good)\n- **Buffers: shared read**: Pages read from disk (slow)\n- **loops**: How many times this node executed\n\n### Common Anti-Patterns\n\n#### 1. Sequential Scan on Large Table\n\n```sql\n-- Missing index: Seq Scan on 34M rows\nSELECT * FROM transactions WHERE tx_hash = $1;\n-- Fix: CREATE INDEX idx_transactions_hash ON transactions (tx_hash);\n```\n\n#### 2. Nested Loop with No Index\n\n```sql\n-- N+1 query pattern: 50K index lookups\n-- Fix: use covering index or batch the lookup\n```\n\n#### 3. Excessive Sort Memory\n\n```sql\n-- Sort Method: external merge  Disk: 1234MB\n-- Fix: increase work_mem for this query\nSET work_mem = '256MB';\n-- Or add an index that provides pre-sorted output\n```\n\n#### 4. Inaccurate Row Estimates\n\n```sql\n-- rows=50234 (estimated) vs rows=1 (actual)\n-- Stale statistics. Fix:\nANALYZE transactions;\n```\n\n## Query Optimization Patterns\n\n### Use EXISTS Instead of IN for Subqueries\n\n```sql\n-- Slow: materializes entire subquery\nSELECT * FROM transactions\nWHERE from_addr IN (\n    SELECT address FROM flagged_addresses\n);\n\n-- Fast: stops at first match\nSELECT * FROM transactions t\nWHERE EXISTS (\n    SELECT 1 FROM flagged_addresses f\n    WHERE f.address = t.from_addr\n);\n```\n\n### Use CTEs Wisely\n\nIn PostgreSQL 12+, CTEs are inlined by default (optimization fence removed). But if you need to materialize:\n\n```sql\n-- Force materialization (useful when CTE is used multiple times)\nWITH expensive_cte AS MATERIALIZED (\n    SELECT from_addr, SUM(value_wei) AS total\n    FROM transactions\n    WHERE block_number > 18000000\n    GROUP BY from_addr\n)\nSELECT * FROM expensive_cte WHERE total > 1000000000000000000;\n```\n\n### Batch Operations\n\n```sql\n-- Slow: 10,000 individual queries\nFOR addr IN addresses LOOP\n    SELECT balance FROM balances WHERE address = addr;\nEND LOOP;\n\n-- Fast: single query with ANY\nSELECT address, balance FROM balances\nWHERE address = ANY($1::bytea[]);\n```\n\n## Configuration for Performance\n\nKey `postgresql.conf` settings for high-performance workloads:\n\n```ini\n# Memory\nshared_buffers = 8GB              # 25% of RAM\neffective_cache_size = 24GB       # 75% of RAM\nwork_mem = 64MB                   # Per-sort\u002Fhash operation\nmaintenance_work_mem = 2GB        # For VACUUM, CREATE INDEX\n\n# WAL\nwal_buffers = 64MB\ncheckpoint_completion_target = 0.9\nmax_wal_size = 4GB\n\n# Planner\nrandom_page_cost = 1.1            # SSD (default 4.0 is for HDD)\neffective_io_concurrency = 200    # SSD\n\n# Parallel Queries\nmax_parallel_workers_per_gather = 4\nmax_parallel_workers = 8\n```\n\n## Conclusion\n\nPostgreSQL performance at scale rests on three pillars: the right indexes for your query patterns (partial, covering, BRIN for time-series), aggressive autovacuum tuning to prevent dead tuple accumulation, and systematic query optimization using EXPLAIN ANALYZE. Do not guess — measure. Every slow query has a story in its execution plan, and every bloated table has a VACUUM configuration that needs attention.","\u003Ch2 id=\"the-performance-toolkit\">The Performance Toolkit\u003C\u002Fh2>\n\u003Cp>PostgreSQL is remarkably fast out of the box, but at scale — tens of millions of rows, thousands of queries per second — you need to understand its internals to maintain performance. This article covers the three pillars of PostgreSQL performance: indexes, VACUUM, and query optimization.\u003C\u002Fp>\n\u003Ch2 id=\"index-types-and-when-to-use-them\">Index Types and When to Use Them\u003C\u002Fh2>\n\u003Ch3>B-Tree (Default)\u003C\u002Fh3>\n\u003Cp>The default index type. Supports equality and range queries. If you do not specify a type, you get B-Tree:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sql\">CREATE INDEX idx_transactions_block\n    ON transactions (block_number);\n\n-- Supports:\n-- WHERE block_number = 18000000\n-- WHERE block_number &gt; 18000000\n-- WHERE block_number BETWEEN 18000000 AND 18100000\n-- ORDER BY block_number\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Partial Indexes\u003C\u002Fh3>\n\u003Cp>Index only rows matching a condition. Dramatically smaller than full indexes:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Only index pending transactions (0.1% of table)\nCREATE INDEX idx_transactions_pending\n    ON transactions (created_at)\n    WHERE status = 'pending';\n\n-- Index only recent high-value transfers\nCREATE INDEX idx_high_value_recent\n    ON transactions (from_addr, to_addr)\n    WHERE value_wei &gt; 1000000000000000000  -- &gt; 1 ETH\n    AND block_number &gt; 18000000;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>A partial index on 0.1% of a 34M-row table is 170KB instead of 800MB. Queries that match the WHERE condition use the tiny index; queries that do not match fall back to other indexes or sequential scan.\u003C\u002Fp>\n\u003Ch3>Covering Indexes (INCLUDE)\u003C\u002Fh3>\n\u003Cp>Include non-key columns in the index to enable index-only scans:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Without INCLUDE: index lookup + heap fetch for each row\nCREATE INDEX idx_tx_from ON transactions (from_addr);\n-- Query: SELECT from_addr, value_wei FROM transactions WHERE from_addr = $1\n-- Plan: Index Scan -&gt; Heap Fetch (slow for many rows)\n\n-- With INCLUDE: index-only scan (no heap fetch)\nCREATE INDEX idx_tx_from_covering\n    ON transactions (from_addr)\n    INCLUDE (value_wei, block_number, tx_hash);\n-- Plan: Index Only Scan (fast, no heap access)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The trade-off: covering indexes are larger because they store extra columns. Use them for frequently queried column combinations.\u003C\u002Fp>\n\u003Ch3>BRIN Indexes (Block Range INdex)\u003C\u002Fh3>\n\u003Cp>For time-series or monotonically increasing data, BRIN indexes are tiny and effective:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- BRIN index: ~100KB for 34M rows (vs ~800MB B-Tree)\nCREATE INDEX idx_transactions_block_brin\n    ON transactions USING BRIN (block_number)\n    WITH (pages_per_range = 128);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>BRIN works by storing min\u002Fmax values per range of physical pages. If your data is inserted in order (block_number increases monotonically), BRIN can skip entire page ranges efficiently. If data is randomly ordered, BRIN is useless.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Perfect for BRIN: data inserted in block_number order\nSELECT * FROM transactions WHERE block_number BETWEEN 18000000 AND 18100000;\n-- BRIN eliminates 95% of pages in ~0.1ms\n\n-- Bad for BRIN: random access pattern\nSELECT * FROM transactions WHERE from_addr = $1;\n-- BRIN cannot help here\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>GIN Indexes (Generalized Inverted Index)\u003C\u002Fh3>\n\u003Cp>For JSONB columns and full-text search:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Index JSONB fields for containment queries\nCREATE INDEX idx_events_data_gin\n    ON events USING GIN (data jsonb_path_ops);\n\n-- Supports:\n-- WHERE data @&gt; '{\"event\": \"Transfer\"}'\n-- WHERE data @&gt; '{\"args\": {\"from\": \"0x...\"}}'\n\n-- Full-text search\nCREATE INDEX idx_articles_fts\n    ON articles USING GIN (to_tsvector('english', title || ' ' || content));\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"vacuum-the-silent-performance-killer\">VACUUM: The Silent Performance Killer\u003C\u002Fh2>\n\u003Cp>PostgreSQL uses MVCC (Multi-Version Concurrency Control). When you UPDATE or DELETE a row, the old version is not removed — it is marked as dead. VACUUM reclaims space from dead tuples.\u003C\u002Fp>\n\u003Ch3>Why VACUUM Matters\u003C\u002Fh3>\n\u003Cp>Without VACUUM:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Dead tuples accumulate, bloating the table\u003C\u002Fli>\n\u003Cli>Index scans must skip dead tuples (slower)\u003C\u002Fli>\n\u003Cli>Transaction ID wraparound can halt the entire database\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Autovacuum Configuration\u003C\u002Fh3>\n\u003Cp>Default autovacuum settings are conservative. For high-write tables, tune aggressively:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Per-table autovacuum tuning\nALTER TABLE transactions SET (\n    autovacuum_vacuum_threshold = 10000,\n    autovacuum_vacuum_scale_factor = 0.01,\n    autovacuum_analyze_threshold = 5000,\n    autovacuum_analyze_scale_factor = 0.005,\n    autovacuum_vacuum_cost_delay = 2\n);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Explanation:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Ccode>vacuum_threshold = 10000\u003C\u002Fcode>: Start VACUUM after 10,000 dead tuples (default: 50)\u003C\u002Fli>\n\u003Cli>\u003Ccode>vacuum_scale_factor = 0.01\u003C\u002Fcode>: Plus 1% of table size (default: 20%!)\u003C\u002Fli>\n\u003Cli>\u003Ccode>vacuum_cost_delay = 2\u003C\u002Fcode>: Reduce sleep between vacuum operations (default: 2ms)\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>For a 34M-row table, the default scale factor of 20% means autovacuum waits until 6.8 million dead tuples accumulate. That is catastrophic. Set it to 1% (340,000 dead tuples) or lower.\u003C\u002Fp>\n\u003Ch3>Monitoring Vacuum\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Check dead tuple counts\nSELECT\n    schemaname,\n    relname,\n    n_live_tup,\n    n_dead_tup,\n    ROUND(n_dead_tup::numeric \u002F NULLIF(n_live_tup, 0) * 100, 2) AS dead_pct,\n    last_vacuum,\n    last_autovacuum,\n    last_analyze\nFROM pg_stat_user_tables\nWHERE n_dead_tup &gt; 10000\nORDER BY n_dead_tup DESC;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Table Bloat\u003C\u002Fh3>\n\u003Cp>Even after VACUUM, space is not returned to the OS — it is only marked as reusable. To actually shrink the table:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- VACUUM FULL: rewrites entire table (locks it exclusively)\nVACUUM FULL transactions;  -- DO NOT run in production\n\n-- Better: pg_repack (no exclusive lock)\n-- Install extension first\npg_repack --table transactions --no-kill-backend --no-superuser-check\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>pg_repack\u003C\u002Fcode> creates a new copy of the table, copies data, then swaps — all without an exclusive lock. Essential for production systems.\u003C\u002Fp>\n\u003Ch2 id=\"explain-analyze-reading-query-plans\">EXPLAIN ANALYZE: Reading Query Plans\u003C\u002Fh2>\n\u003Cp>The most important PostgreSQL debugging tool:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sql\">EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)\nSELECT t.*, a.balance_wei\nFROM transactions t\nJOIN addresses a ON t.from_addr = a.address\nWHERE t.block_number BETWEEN 18000000 AND 18100000\nORDER BY t.value_wei DESC\nLIMIT 100;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Reading the output:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>Limit (actual time=12.3..12.5 rows=100 loops=1)\n  -&gt; Sort (actual time=12.3..12.4 rows=100 loops=1)\n        Sort Key: t.value_wei DESC\n        Sort Method: top-N heapsort  Memory: 42kB\n        -&gt; Nested Loop (actual time=0.2..10.1 rows=50234 loops=1)\n              -&gt; Index Scan using idx_transactions_block on transactions t\n                    (actual time=0.1..3.2 rows=50234 loops=1)\n                    Index Cond: block_number &gt;= 18000000 AND block_number &lt;= 18100000\n                    Buffers: shared hit=1234\n              -&gt; Index Scan using addresses_pkey on addresses a\n                    (actual time=0.001..0.001 rows=1 loops=50234)\n                    Index Cond: address = t.from_addr\n                    Buffers: shared hit=150702\nPlanning Time: 0.5ms\nExecution Time: 12.8ms\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Key metrics to watch:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>actual time\u003C\u002Fstrong>: Real execution time (start..end) in milliseconds\u003C\u002Fli>\n\u003Cli>\u003Cstrong>rows\u003C\u002Fstrong>: Actual rows processed (compare with estimate for accuracy)\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Buffers: shared hit\u003C\u002Fstrong>: Pages read from cache (good)\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Buffers: shared read\u003C\u002Fstrong>: Pages read from disk (slow)\u003C\u002Fli>\n\u003Cli>\u003Cstrong>loops\u003C\u002Fstrong>: How many times this node executed\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Common Anti-Patterns\u003C\u002Fh3>\n\u003Ch4>1. Sequential Scan on Large Table\u003C\u002Fh4>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Missing index: Seq Scan on 34M rows\nSELECT * FROM transactions WHERE tx_hash = $1;\n-- Fix: CREATE INDEX idx_transactions_hash ON transactions (tx_hash);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch4>2. Nested Loop with No Index\u003C\u002Fh4>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- N+1 query pattern: 50K index lookups\n-- Fix: use covering index or batch the lookup\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch4>3. Excessive Sort Memory\u003C\u002Fh4>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Sort Method: external merge  Disk: 1234MB\n-- Fix: increase work_mem for this query\nSET work_mem = '256MB';\n-- Or add an index that provides pre-sorted output\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch4>4. Inaccurate Row Estimates\u003C\u002Fh4>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- rows=50234 (estimated) vs rows=1 (actual)\n-- Stale statistics. Fix:\nANALYZE transactions;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"query-optimization-patterns\">Query Optimization Patterns\u003C\u002Fh2>\n\u003Ch3>Use EXISTS Instead of IN for Subqueries\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Slow: materializes entire subquery\nSELECT * FROM transactions\nWHERE from_addr IN (\n    SELECT address FROM flagged_addresses\n);\n\n-- Fast: stops at first match\nSELECT * FROM transactions t\nWHERE EXISTS (\n    SELECT 1 FROM flagged_addresses f\n    WHERE f.address = t.from_addr\n);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Use CTEs Wisely\u003C\u002Fh3>\n\u003Cp>In PostgreSQL 12+, CTEs are inlined by default (optimization fence removed). But if you need to materialize:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Force materialization (useful when CTE is used multiple times)\nWITH expensive_cte AS MATERIALIZED (\n    SELECT from_addr, SUM(value_wei) AS total\n    FROM transactions\n    WHERE block_number &gt; 18000000\n    GROUP BY from_addr\n)\nSELECT * FROM expensive_cte WHERE total &gt; 1000000000000000000;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Batch Operations\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Slow: 10,000 individual queries\nFOR addr IN addresses LOOP\n    SELECT balance FROM balances WHERE address = addr;\nEND LOOP;\n\n-- Fast: single query with ANY\nSELECT address, balance FROM balances\nWHERE address = ANY($1::bytea[]);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"configuration-for-performance\">Configuration for Performance\u003C\u002Fh2>\n\u003Cp>Key \u003Ccode>postgresql.conf\u003C\u002Fcode> settings for high-performance workloads:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-ini\"># Memory\nshared_buffers = 8GB              # 25% of RAM\neffective_cache_size = 24GB       # 75% of RAM\nwork_mem = 64MB                   # Per-sort\u002Fhash operation\nmaintenance_work_mem = 2GB        # For VACUUM, CREATE INDEX\n\n# WAL\nwal_buffers = 64MB\ncheckpoint_completion_target = 0.9\nmax_wal_size = 4GB\n\n# Planner\nrandom_page_cost = 1.1            # SSD (default 4.0 is for HDD)\neffective_io_concurrency = 200    # SSD\n\n# Parallel Queries\nmax_parallel_workers_per_gather = 4\nmax_parallel_workers = 8\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"conclusion\">Conclusion\u003C\u002Fh2>\n\u003Cp>PostgreSQL performance at scale rests on three pillars: the right indexes for your query patterns (partial, covering, BRIN for time-series), aggressive autovacuum tuning to prevent dead tuple accumulation, and systematic query optimization using EXPLAIN ANALYZE. Do not guess — measure. Every slow query has a story in its execution plan, and every bloated table has a VACUUM configuration that needs attention.\u003C\u002Fp>\n","en","b0000000-0000-0000-0000-000000000001",true,"2026-03-28T10:44:23.186535Z","PostgreSQL Performance at Scale — Indexes, VACUUM, and Query Optimization","Master PostgreSQL performance with partial indexes, BRIN for time-series, autovacuum tuning, EXPLAIN ANALYZE reading, and common anti-patterns.","postgresql performance optimization",null,"index, follow",[22,27,31],{"id":23,"name":24,"slug":25,"created_at":26},"c0000000-0000-0000-0000-000000000012","DevOps","devops","2026-03-28T10:44:21.513630Z",{"id":28,"name":29,"slug":30,"created_at":26},"c0000000-0000-0000-0000-000000000022","Performance","performance",{"id":32,"name":33,"slug":34,"created_at":26},"c0000000-0000-0000-0000-000000000005","PostgreSQL","postgresql",[36,43,49],{"id":37,"title":38,"slug":39,"excerpt":40,"locale":12,"category_name":41,"published_at":42},"d0200000-0000-0000-0000-000000000003","Why Bali Is Becoming Southeast Asia's Impact-Tech Hub in 2026","why-bali-becoming-southeast-asia-impact-tech-hub-2026","Bali ranks #16 among Southeast Asian startup ecosystems. With a growing concentration of Web3 builders, AI sustainability startups, and eco-travel tech companies, the island is carving a niche as the region's impact-tech capital.","Engineering","2026-03-28T10:44:37.748283Z",{"id":44,"title":45,"slug":46,"excerpt":47,"locale":12,"category_name":41,"published_at":48},"d0200000-0000-0000-0000-000000000002","ASEAN Data Protection Patchwork: A Developer's Compliance Checklist","asean-data-protection-patchwork-developer-compliance-checklist","Seven ASEAN countries now have comprehensive data protection laws, each with different consent models, localization requirements, and penalty structures. Here is a practical compliance checklist for developers building multi-country applications.","2026-03-28T10:44:37.374741Z",{"id":50,"title":51,"slug":52,"excerpt":53,"locale":12,"category_name":41,"published_at":54},"d0200000-0000-0000-0000-000000000001","Indonesia's $29 Billion Digital Transformation: Opportunities for Software Companies","indonesia-29-billion-digital-transformation-opportunities-software-companies","Indonesia's IT services market is projected to reach $29.03 billion in 2026, up from $24.37 billion in 2025. Cloud infrastructure, AI, e-commerce, and data centers are driving the fastest growth in Southeast Asia.","2026-03-28T10:44:37.349311Z",{"id":13,"name":56,"slug":57,"bio":58,"photo_url":19,"linkedin":19,"role":59,"created_at":60,"updated_at":60},"Open Soft Team","open-soft-team","The engineering team at Open Soft, building premium software solutions from Bali, Indonesia.","Engineering Team","2026-03-28T08:31:22.226811Z"]