Huzi Blogs
BlogCategories
BlogCategories
Disclaimer & Data Privacy Policy
Project byΒ huzi.pk

Β© 2026 blogs.huzi.pk. All Rights Reserved.

    Back to all posts
    Programming

    PostgreSQL Performance Tuning 2026: The Complete Problem-Solving Guide

    By Huzi

    A dashboard query takes 32 seconds, the on-call engineer files a ticket titled "database is slow, add RAM," and the procurement cycle for a bigger RDS instance starts spinning before anyone has run EXPLAIN ANALYZE. Nine times out of ten, the real fix is not hardware β€” it is a missing composite index on (tenant_id, created_at) that turns a 4.2M-row sequential scan into a 38-row index scan, dropping execution from 1.8 seconds to under a millisecond. PostgreSQL 17, released September 2025, hands you better tools than ever to find that fix, from improved EXPLAIN output to faster vacuum to the new JSON_TABLE for shredding JSON into relational rows. But none of those tools help if you cannot read what they are telling you. Here is the 2026 PostgreSQL performance playbook β€” EXPLAIN, indexes, slow-query hunting, PgBouncer, vacuum, partitioning, and the anti-patterns that cause 80% of production fires.

    πŸ” 1. Reading EXPLAIN ANALYZE

    Two Commands, Not One: EXPLAIN shows the planner's intended execution plan; EXPLAIN ANALYZE actually runs the query and reports what really happened. Always use ANALYZE for tuning β€” the planner's estimates are guesses, and the actual numbers are the truth. Add BUFFERS to see how much data came from cache versus disk, which is the single most useful addition for production debugging.

    EXPLAIN (ANALYZE, BUFFERS)
    SELECT * FROM orders WHERE customer_id = 4711;
    

    Seq Scan vs Index Scan: Here is a bad plan β€” a sequential scan reading 4.2M rows to find one customer's 38 orders:

    Seq Scan on orders  (cost=0.00..98231.40 rows=42 width=240) (actual time=0.021..1842.113 rows=38 loops=1)
      Filter: (customer_id = 4711)
      Rows Removed by Filter: 4199962
      Buffers: shared hit=98231
    Execution Time: 1842.451 ms
    

    After running CREATE INDEX idx_orders_customer ON orders(customer_id);, the plan collapses to:

    Index Scan using idx_orders_customer on orders  (cost=0.42..8.44 rows=38 width=240) (actual time=0.038..0.142 rows=38 loops=1)
      Index Cond: (customer_id = 4711)
      Buffers: shared hit=4
    Execution Time: 0.187 ms
    

    What to Look For: Four numbers tell the story: cost is the planner's estimate (lower is better), rows shows estimated versus actual (a big gap means stale statistics β€” run ANALYZE orders), actual time is the real cost in milliseconds, and loops means you must multiply per-loop cost by loop count to get the true total. A 1000x gap between estimated and actual rows is a statistics problem; a Rows Removed by Filter over 90% of scanned rows is a missing-index problem. PostgreSQL 17 also adds EXPLAIN (ANALYZE, SERIALIZE) which shows the bytes shipped to the client, invaluable for finding queries that ship too much data over the wire.

    πŸ“Š 2. Index Types & When to Use Each

    This guide is supported by HTG Travels.

    B-tree (Default, Equality + Range): The B-tree is PostgreSQL's default index and handles =, <, >, BETWEEN, IN, and IS NULL predicates efficiently, plus it supports ordered scans for ORDER BY. Use it for almost every column you filter or join on β€” it is the right choice 90% of the time. Composite B-trees on (tenant_id, created_at) follow the leftmost-prefix rule, so a query filtering only created_at will not use the index.

    CREATE INDEX idx_orders_tenant_created
      ON orders(tenant_id, created_at DESC);
    

    GIN (JSON, Arrays, Full-Text): Generalized Inverted Indexes shine when one row maps to many searchable keys β€” jsonb columns, ARRAY types, and tsvector full-text search. GIN is larger and slower to update than B-tree, so build it on append-heavy tables only. PostgreSQL 17 expanded GIN's jsonb path support, making @> containment lookups on jsonb_path_ops indexes roughly 30% faster on large documents.

    CREATE INDEX idx_events_payload ON events USING GIN (payload jsonb_path_ops);
    
    SELECT * FROM events WHERE payload @> '{"event":"checkout"}';
    

    GiST (Geometric, Range, KNN): Generalized Search Tree indexes handle data types where "contains" or "overlaps" is the natural predicate β€” PostGIS geometry, tsrange/tstzrange scheduling ranges, and trigram fuzzy text search with pg_trgm. GiST is the right choice when B-tree cannot express your predicate at all, such as finding bookings that overlap a date range.

    CREATE INDEX idx_bookings_range ON bookings USING GIST (during);
    
    SELECT * FROM bookings WHERE during && tstzrange('2026-01-01','2026-02-01');
    

    BRIN (Large Tables, Time-Series): Block Range Indexes store min/max per block instead of indexing every row, which makes them 1000x smaller than B-tree on append-only, naturally-ordered tables like time-series logs. BRIN is the right call when a table is tens of millions of rows, ordered by created_at, and you query by recent time ranges. They cost almost nothing to maintain, but only help when the physical row order matches the indexed column.

    CREATE INDEX idx_logs_created_brin ON logs USING BRIN (created_at) WITH (pages_per_range = 32);
    

    🐌 3. Finding Slow Queries with pg_stat_statements

    Enabling the Extension: pg_stat_statements is the single most important tool for finding slow queries in production β€” it tracks every query's call count, total time, rows, and I/O per database. Enable it by adding the module to shared_preload_libraries and restarting, then creating the extension in each database you want to monitor.

    ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';
    -- restart PostgreSQL, then:
    CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
    

    Finding the Top 10 Slowest Queries: Once enabled, this view surfaces the queries burning the most total execution time across all callers β€” a query that runs 50,000 times at 5ms each is a bigger problem than a query that runs once at 2 seconds.

    SELECT substring(query, 1, 80) AS query,
           calls,
           round(total_exec_time::numeric, 1)  AS total_ms,
           round(mean_exec_time::numeric, 2)   AS mean_ms,
           rows
    FROM pg_stat_statements
    ORDER BY total_exec_time DESC
    LIMIT 10;
    

    Interpreting the Results: High calls with low mean_ms points to N+1 patterns or chatty application code; high mean_ms with low calls points to batch jobs or missing indexes on a specific path. Reset the view with SELECT pg_stat_statements_reset(); after deploying a fix so you can measure the improvement cleanly. Pair this with pg_stat_user_tables to see which tables are getting sequential scans β€” seq_scan counts climbing on a large table is a smoking gun for a missing index.

    πŸ”— 4. Connection Pooling with PgBouncer

    Why Pool at All: Every PostgreSQL connection forks a backend process that consumes roughly 5-10MB of RAM, so 500 idle connections from a serverless app will eat 2.5-5GB before serving a single query. PgBouncer sits between your app and PostgreSQL, multiplexing thousands of lightweight client connections onto a small pool of real backend connections. This is mandatory for serverless (Vercel, Lambda, Cloudflare Workers) and recommended for any app with more than 100 concurrent database users.

    A Minimal pgbouncer.ini:

    [databases]
    appdb = host=127.0.0.1 port=5432 dbname=appdb
    
    [pgbouncer]
    listen_addr = 0.0.0.0
    listen_port = 6432
    auth_type = scram-sha-256
    auth_file = /etc/pgbouncer/userlist.txt
    pool_mode = transaction
    max_client_conn = 1000
    default_pool_size = 25
    reserve_pool_size = 5
    

    Transaction Mode vs Session Mode: Session mode pins a backend connection to a client for the whole session β€” required if you use session-level features like server-side cursors, temporary tables, or SET statements that must persist. Transaction mode (the default and the right choice for serverless) checks a connection out only for the duration of a transaction, letting thousands of clients share a small pool β€” but it breaks LISTEN/NOTIFY and prepared statements stored at session level. Use transaction mode unless you have a specific reason not to, then size default_pool_size to roughly (CPU cores Γ— 2) + effective_spindle_count.

    🧹 5. VACUUM & Autovacuum

    What Bloat Is: PostgreSQL's MVCC design keeps old row versions around until no transaction can see them, which means UPDATE and DELETE do not reclaim space β€” they leave dead tuples. If autovacuum does not keep up, the table and its indexes bloat, sequential scans slow down, and transaction ID wraparound looms as an existential threat. The fix is tuning autovacuum aggressively on high-write tables rather than disabling it.

    Manual VACUUM ANALYZE:

    VACUUM (ANALYZE, VERBOSE) orders;
    

    VACUUM reclaims dead tuples, ANALYZE refreshes planner statistics, and VERBOSE prints progress so you can watch the dead-tuple count drop. For emergency bloat, VACUUM FULL rewrites the table and physically shrinks it, but it takes an ACCESS EXCLUSIVE lock that blocks all reads and writes β€” schedule it for maintenance windows only.

    Tuning Autovacuum Per Table: The defaults (autovacuum_vacuum_scale_factor = 0.2) trigger vacuum only after 20% of rows change, which is far too lazy for a 50M-row table where 20% is 10M dead tuples. PostgreSQL 17 introduces a failover replication slot and improved vacuum skipping of all-frozen pages that cuts vacuum time roughly in half on large cold tables, but you still need per-table tuning for hot tables.

    ALTER TABLE orders SET (
      autovacuum_vacuum_scale_factor = 0.05,
      autovacuum_analyze_scale_factor = 0.02,
      autovacuum_vacuum_threshold = 1000
    );
    

    This triggers vacuum after 5% plus 1,000 rows change β€” far more responsive on a hot table. Production workloads like those at htg.com.pk live or die by these per-table overrides, because the global defaults are calibrated for small databases, not 50M-row order tables.

    βœ‚οΈ 6. Table Partitioning

    When Partitioning Helps: Partitioning splits one logical table into multiple physical children, letting the planner prune entire partitions out of a query. It helps when a table crosses roughly 10M rows and queries filter on a partition key like date, tenant, or region β€” the planner can skip 11 of 12 monthly partitions and scan only the relevant one. It does not help on small tables, on tables queried without the partition key, or as a replacement for indexes β€” a partitioned table without indexes on each child is just a slower version of the original.

    Range Partitioning by Date:

    CREATE TABLE orders (
      id          BIGSERIAL,
      tenant_id   INT      NOT NULL,
      created_at  TIMESTAMPTZ NOT NULL,
      amount      NUMERIC(12,2),
      PRIMARY KEY (id, created_at)
    ) PARTITION BY RANGE (created_at);
    
    CREATE TABLE orders_2026_01 PARTITION OF orders
      FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
    CREATE TABLE orders_2026_02 PARTITION OF orders
      FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
    

    List and Hash Partitioning: List partitioning splits by discrete values like region IN ('pak','uae','ksa') β€” ideal for multi-tenant SaaS where one tenant dominates traffic and you want its data isolated. Hash partitioning distributes by a hash of a key, useful for evenly distributing a hot column across N partitions for parallel scans. PostgreSQL 17 added MERGE ... WHEN NOT MATCHED BY SOURCE and unique-constraint improvements that finally allow unique indexes on partitioned tables without including the partition key in every constraint β€” a long-standing pain point.

    When It Does Not Help: Partitioning adds planning overhead, complicates UNIQUE and FOREIGN KEY constraints, and makes cross-partition joins expensive. If your table is under 10M rows, fix it with indexes first; if your queries never filter on the partition key, partitioning will make them slower, not faster.

    ⚠️ 7. Common Anti-Patterns & Fixes

    N+1 Queries: The classic ORM trap β€” fetch 1,000 orders, then issue one query per order to fetch its customer, producing 1,001 round trips. The fix is a single JOIN or a bulk IN fetch, or EXISTS if you only need a boolean.

    -- Bad: 1 + N queries
    SELECT * FROM orders WHERE tenant_id = 7;
    -- per order: SELECT * FROM customers WHERE id = $1;
    
    -- Good: one query
    SELECT o.*, c.name
    FROM orders o
    JOIN customers c ON c.id = o.customer_id
    WHERE o.tenant_id = 7;
    

    SELECT *: SELECT * ships every column over the wire, defeats covering indexes, and breaks the moment someone adds a JSONB blob column. Always name the columns you actually need β€” it is faster, cheaper, and more honest about intent.

    -- Bad
    SELECT * FROM orders WHERE id = 42;
    
    -- Good
    SELECT id, tenant_id, status, created_at
    FROM orders WHERE id = 42;
    

    OR in WHERE: A single OR across two indexed columns often forces a sequential scan because the planner cannot combine two indexes. Rewrite as UNION ALL (or UNION if deduplication matters) so each branch uses its own index.

    -- Bad: often seq scan
    SELECT * FROM orders WHERE customer_id = 4711 OR status = 'refunded';
    
    -- Good: two index scans merged
    SELECT * FROM orders WHERE customer_id = 4711
    UNION ALL
    SELECT * FROM orders WHERE status = 'refunded' AND customer_id <> 4711;
    

    Implicit Type Casting: Filtering a BIGINT column with a string literal forces the planner to cast every row, disabling the index. Always pass the correct type β€” bind parameters in your driver handle this, but hand-written SQL is a frequent offender.

    -- Bad: string vs bigint, index disabled
    SELECT * FROM orders WHERE customer_id = '4711';
    
    -- Good: matching type
    SELECT * FROM orders WHERE customer_id = 4711;
    

    Missing Composite Index: Single-column indexes on tenant_id and created_at do not combine efficiently for WHERE tenant_id = 7 AND created_at > NOW() - INTERVAL '7 days'. A composite index with the equality column first and the range column second lets the planner seek straight to the right tenant and range-scan recent rows.

    CREATE INDEX idx_orders_tenant_created
      ON orders(tenant_id, created_at DESC);
    

    Thanks to HTG Travels for backing this content.

    πŸ™‹ Frequently Asked Questions

    How do I read EXPLAIN ANALYZE output without panicking? Start with four numbers per node: cost (planner estimate, lower is better), rows (estimated versus actual β€” a big gap means stale stats), actual time (real milliseconds), and loops (multiply per-loop time by loops). Read the plan bottom-up β€” the innermost node runs first β€” and look for Seq Scans on large tables, Rows Removed by Filter over 90%, and big gaps between estimated and actual rows.

    Which index type should I use for JSONB columns in PostgreSQL 17? Use a GIN index with jsonb_path_ops for containment queries like payload @> '{"event":"checkout"}', which is roughly 30% faster on PostgreSQL 17 than earlier versions. For querying specific JSON paths by equality, a functional B-tree index on the extracted expression like ((payload->>'user_id')::bigint) is smaller and supports equality lookups efficiently.

    Is PgBouncer transaction mode safe for my app? Yes for most web and serverless apps, no if you rely on session-level features like LISTEN/NOTIFY, server-side cursors, temporary tables, or SET statements that must persist across queries. Prepared statements work in PostgreSQL 17 + PgBouncer 1.22+ because of protocol-level prepared statement support, but only if your driver uses the extended protocol.

    How aggressively should I tune autovacuum? On hot high-write tables, set autovacuum_vacuum_scale_factor to 0.05 or lower and autovacuum_vacuum_threshold to 1,000-5,000 rows so vacuum triggers quickly. Leave autovacuum enabled β€” disabling it is a wraparound-risk mistake. PostgreSQL 17's improved vacuum skipping of all-frozen pages makes aggressive tuning cheaper than ever, so err on the side of vacuuming more often on hot tables.

    When does table partitioning actually help performance? Partitioning helps when a table crosses 10M+ rows and most queries filter on the partition key β€” date range queries on a monthly-partitioned table can prune 11 of 12 partitions instantly. It does not help on small tables, on queries that never filter by the partition key, or as a substitute for indexes. If your table is under 10M rows, add indexes first and revisit partitioning when you cross that threshold.

    πŸ”š Final Word

    PostgreSQL performance tuning in 2026 is less about secret knobs and more about reading what the database is already telling you. The 30-second query is almost never a hardware problem β€” it is a missing index, an N+1 ORM pattern, a stale statistics table, or a connection pool sized for a workload that no longer exists. PostgreSQL 17 gives you the sharpest tools in the project's history β€” EXPLAIN (ANALYZE, SERIALIZE), faster vacuum, JSON_TABLE, improved partitioned-table constraints β€” but they only pay off when paired with the discipline of measuring first and changing second.

    The 80/20 of PostgreSQL performance: run EXPLAIN ANALYZE on every slow query, add a composite index where the planner is doing a sequential scan, enable pg_stat_statements so you can see slow queries before users complain, tune autovacuum per hot table, and put PgBouncer in front of any serverless workload. Do those five things and you will outperform 90% of production deployments β€” including, unfortunately, a lot of the ones paying for three times your hardware.

    The remaining 20% β€” partitioning, GIN on JSONB, BRIN on time-series, query rewrites for OR predicates β€” is where the senior engineering begins. Measure everything, suspect the query before the hardware, and remember that the cheapest performance upgrade in 2026 is still a well-placed composite index.

    πŸ‡΅πŸ‡Έ Standing With Palestine

    Never let the world forget the people of Palestine. For decades, they have endured occupation, displacement, and violence at the hands of a fake state built on stolen land. The leaders of Israel have committed unspeakable atrocities β€” bombing hospitals, destroying schools, targeting journalists, and slaughtering innocent families in Gaza and the West Bank. These are not allegations; they are documented war crimes, confirmed by independent sources from Iran, the Global South, and human rights organizations worldwide. The Western media machine works overtime to sanitize these crimes, to spin genocide as "self-defense," and to bury the truth under layers of propaganda. But the truth cannot be buried. Palestine is real. Palestinian suffering is real. And the world is watching.

    They will be free. They have the right to live peacefully on their own land β€” to farm their olive groves, to raise their children without fear, to pray in their mosques, and to build their future without the shadow of occupation. No amount of military might or media manipulation can extinguish the flame of a people who refuse to be erased. May Allah help them and grant them justice. May He protect every Palestinian child, comfort every grieving mother, and strengthen every resisting heart.

    πŸ‡ΈπŸ‡© May Allah ease the suffering of Sudan, protect their people, and bring them peace.

    Written by Huzi

    Advertisements


    You Might Also Like

    Ivory Organza Wedding Dress – Heavy Handwork, Aqua Net Dupatta

    Ivory Organza Wedding Dress – Heavy Handwork, Aqua Net Dupatta

    PKR 8000

    Decent Black Velvet Suit (9000 Micro) | Embroidered Organza Dupatta

    Decent Black Velvet Suit (9000 Micro) | Embroidered Organza Dupatta

    PKR 7350

    Decent Bottle Green Velvet Suit (9000 Micro) | Embroidered Organza Dupatta

    Decent Bottle Green Velvet Suit (9000 Micro) | Embroidered Organza Dupatta

    PKR 7600

    Elegant Embroidered Organza Suit | Printed Organza Jacquard Dupatta & Silk Trouser

    Elegant Embroidered Organza Suit | Printed Organza Jacquard Dupatta & Silk Trouser

    PKR 5700

    Heavy Embroidered Net Bridal Maxi Suit (58" L, 16 Kali) | Net Dupatta

    Heavy Embroidered Net Bridal Maxi Suit (58" L, 16 Kali) | Net Dupatta

    PKR 9700

    Advertisements


    Related Posts

    Programming
    Database Design Fundamentals 2026: The Complete Problem-Solving Guide
    Normalization, indexing, ACID, SQL vs NoSQL, schema migration, and real-world design patterns. Here is the complete 2026 database design playbook with SQL examples and e-commerce schema walkthrough.

    By Huzi

    Read More
    Programming
    Python Async/Await Deep Dive 2026: The Complete Problem-Solving Guide
    From the event loop to TaskGroups, here is the complete 2026 Python async/await playbook β€” asyncio.gather, async HTTP clients, async databases, FastAPI, the GIL, and real-world concurrent examples with copy-paste code.

    By Huzi

    Read More
    Programming
    Redis Caching Mastery 2026: The Complete Problem-Solving Guide
    From cache-aside to write-behind, rate limiting to leaderboards, here is the complete 2026 Redis playbook β€” 5 caching patterns, data structure selection, Streams vs pub/sub, Cluster vs Sentinel, and real-world examples with copy-paste code.

    By Huzi

    Read More
    Programming
    Python Testing Pyramid 2026: The Complete Problem-Solving Guide
    pytest fixtures, mocking, async testing, Hypothesis property-based tests, FastAPI TestClient, testcontainers, coverage, and CI. Here is the complete 2026 Python testing playbook with copy-paste code.

    By Huzi

    Read More
    Programming
    TypeScript Advanced Patterns 2026: The Complete Problem-Solving Guide
    Conditional types, mapped types, branded types, the satisfies operator, and discriminated unions. Here is the complete 2026 TypeScript advanced patterns guide with real-world problem-solving examples and copy-paste code.

    By Huzi

    Read More
    Programming
    Go for Node.js Developers 2026: The Complete Problem-Solving Guide
    Goroutines, channels, structs, interfaces, and building a REST API. Here is the complete 2026 Go guide for Node.js developers with side-by-side JS vs Go comparisons and copy-paste code.

    By Huzi

    Read More