Indexes in PostgreSQL reduce query latency by avoiding full table scans on large tables, which keeps application response time stable as row counts grow and workloads scale.

PostgreSQL chooses execution plans based on table and index statistics, selecting between Seq Scan, Index Scan, and Bitmap Index Scan paths depending on estimated cost. The default B-tree index supports equality and range predicates and can satisfy ordering on the indexed columns, while other index types (such as GIN, GiST, and BRIN) target specific operators and data types.

Each index consumes disk space and adds overhead to INSERT, UPDATE, and DELETE because index entries must be maintained alongside table rows. Creating an index without CONCURRENTLY blocks writes to the target table until the build completes, while CONCURRENTLY reduces write blocking at the cost of longer runtime and extra scans; CREATE INDEX CONCURRENTLY also cannot run inside an explicit transaction block. EXPLAIN (ANALYZE) executes the query, so running it against expensive statements during peak load can cause noticeable I/O and CPU pressure.

Steps to create an index in PostgreSQL:

  1. Inspect the target query plan for a Seq Scan, heavy buffer reads, or expensive Sort nodes.
    $ sudo -u postgres psql -d appdb -c "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 42;"
                                                  QUERY PLAN
    -------------------------------------------------------------------------------------------------------
     Seq Scan on orders  (cost=0.00..1678.43 rows=338 width=72) (actual time=0.170..7.043 rows=94 loops=1)
       Filter: (customer_id = 42)
       Rows Removed by Filter: 99906
       Buffers: shared hit=834
     Planning:
       Buffers: shared hit=73
     Planning Time: 0.144 ms
     Execution Time: 7.066 ms
    (8 rows)

    A Seq Scan is normal when a large fraction of rows match the predicate, even when an index exists.

  2. Select index columns and order to match the most common WHERE, JOIN, and ORDER BY clauses.

    For multi-column B-tree indexes, the leftmost columns matter most; place high-selectivity equality predicates first, then range or ordering columns.

  3. Create the index using CREATE INDEX with CONCURRENTLY on busy tables.
    $ sudo -u postgres psql -d appdb -c "CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id);"
    CREATE INDEX

    Omitting CONCURRENTLY blocks writes to the table for the duration of the build.

  4. Refresh table statistics after the index is created so the planner can cost the new path accurately.
    $ sudo -u postgres psql -d appdb -c "ANALYZE orders;"
    ANALYZE
  5. Verify the planner selects an Index Scan or Bitmap Index Scan for the target query.
    $ sudo -u postgres psql -d appdb -c "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 42;"
                                                               QUERY PLAN
    ---------------------------------------------------------------------------------------------------------------------------------
     Bitmap Heap Scan on orders  (cost=5.05..285.11 rows=98 width=36) (actual time=0.019..0.255 rows=94 loops=1)
       Recheck Cond: (customer_id = 42)
       Heap Blocks: exact=86
       Buffers: shared hit=91
       ->  Bitmap Index Scan on idx_orders_customer_id  (cost=0.00..5.03 rows=98 width=0) (actual time=0.010..0.010 rows=94 loops=1)
             Index Cond: (customer_id = 42)
             Buffers: shared hit=5
     Planning:
       Buffers: shared hit=88 read=1
     Planning Time: 0.149 ms
     Execution Time: 0.282 ms
    (11 rows)
  6. Review index size and usage counters to catch unused or low-value indexes early.
    $ sudo -u postgres psql -d appdb -c "SELECT s.indexrelname AS index, pg_size_pretty(pg_relation_size(s.indexrelid)) AS size, s.idx_scan, s.idx_tup_read, s.idx_tup_fetch FROM pg_stat_user_indexes s WHERE s.schemaname = 'public' AND s.relname = 'orders' ORDER BY s.idx_scan DESC;"
             index          |  size   | idx_scan | idx_tup_read | idx_tup_fetch
    ------------------------+---------+----------+--------------+---------------
     idx_orders_customer_id | 712 kB  |        1 |           94 |             0
     orders_pkey            | 2208 kB |        0 |            0 |             0
    (2 rows)

    Statistics reset on server restart or manual reset, so compare trends over a meaningful window instead of treating idx_scan as a lifetime counter.