PostgreSQL Performance Tuning Deep Dive

๐Ÿฆ Throughout this series, we will use a custom-built creditcards database to explore real-world PostgreSQL performance tuning scenarios, covering performance extensions, execution plans, indexing, statistics, memory tuning, locking and vacuum through hands-on demonstrations.

โš ๏ธ Disclaimer: This guide is provided for educational purposes only and is not intended for production use. These steps were documented and tested in a controlled sandbox environment. Please exercise caution before applying any changes to a production system; the author assumes no responsibility for discrepancies or issues. Use at your own risk.

Pausing/Resuming the Labs:

๐Ÿ›‘ Pausing the Labs: If you need to take a break or safely shut down your environment at any point during the course, you can stop both the database and the sandbox environment by running these commands:

# Inside the Linux machine, stop the PostgreSQL instance:
/usr/pgsql-16/bin/pg_ctl stop -D /var/lib/pgsql/postgres-lab01

From your host machine (Windows Command Prompt/PowerShell), navigate to your directory path and shut down the VM:

# Change the directory to our labs
cd D:\sandboxes\postgres\postgres-lab01
D:
# halt the environment
vagrant halt

๐Ÿ–ฅ๏ธ To Start the Machine again.

From your host machine (Windows Command Prompt/PowerShell), navigate to your directory path and Start up the VM:

# Start the environment
vagrant up
# Inside the Linux machine, Start the PostgreSQL instance:
/usr/pgsql-16/bin/pg_ctl start -D /var/lib/pgsql/postgres-lab01

๐Ÿš€ Chapter 1 โ€“ Build the PostgreSQL Sandbox

๐Ÿš€ Chapter 1 โ€“ Build the PostgreSQL Sandbox

๐Ÿ“‹ System Requirements for the Sandbox Lab

a) Memory (RAM): 16 GB
b) CPU: 6 vCPUs

โš ๏ธ Note:
The recommended configuration above is intended to support all demonstrations in this series, including large datasets, execution plan analysis, indexing, vacuum operations, and performance tuning exercises.

If your system has limited resources, you may allocate fewer CPU cores and less memory. Most examples in this series can still be followed successfully with a smaller configuration.

๐Ÿ› ๏ธ 1.1 Install the Prerequisite Software

1 Install Oracle virtual box โ€“ Virtualbox
2 Install Vagrant โ€“ vagrant
3 Install putty โ€“ putty

๐Ÿ“ 1.2 Create the Lab Directory

Open Windows powershell prompt : and create a directory

windows powershell
# create a directory for our vagrant config
mkdir  D:\sandboxes\postgres\postgres-lab01
# Change the directory to our labs
cd D:\sandboxes\postgres\postgres-lab01
# switch to the directory
D:
# check if you are in right directory
echo %cd%
๐Ÿš€ 1.3 Bootstrap the Vagrant Environment
# Initiatlize the environment
vagrant init
PS D:\sandboxes\postgres\postgres-lab01> # Initiatlize the environment
PS D:\sandboxes\postgres\postgres-lab01> vagrant init
A `Vagrantfile` has been placed in this directory. You are now
ready to `vagrant up` your first virtual environment! Please read
the comments in the Vagrantfile as well as documentation on
`vagrantup.com` for more information on using Vagrant.
PS D:\sandboxes\postgres\postgres-lab01>
๐Ÿ”„ 1.4 Replace the Default Vagrantfile
  1. Go to the directory : D:\sandboxes\postgres\postgres-lab01 –
  2. open your favourite editor (notepad / notepad ++)
  3. copy the contents of the below code by expanding and replace it with the Vagrantfile
    Note: Fine tune the parameters: 'mem' => 3072, 'cpu' => 2 – Incase you are running with limited configuration
expand_for_code (file to be edited in notepad / notepad++ )
# -*- mode: ruby -*-
# vi: set ft=ruby :

# --- Configuration Data ---
# Merged directly into the script for single-file portability
conf = {
  'shared' => {
    'box' => "oraclebase/oracle-9"
  },
  'nodes' => [
    { 'name' => 'postgres-lab01',    'ip' => '192.168.56.160', 'mem' => 16384, 'cpu' => 6 },
  ]
}

Vagrant.configure("2") do |config|
  # Loop through each node defined in the Hash above
  conf['nodes'].each do |node|
    config.vm.define node['name'] do |node_config|
      
      node_config.vm.box = conf['shared']['box']
      node_config.vm.hostname = node['name']
      node_config.vm.network "private_network", ip: node['ip']

      node_config.vm.provider "virtualbox" do |vb|
        vb.name   = node['name']
        vb.memory = node['mem']
        vb.cpus   = node['cpu']
        
        # Recommended for OEL9: Ensure high-resolution timers and IO APIC are enabled
        vb.customize ["modifyvm", :id, "--ioapic", "on"]
      end

      # Optional: Add a provisioner if you want to automate the pg_bindir fix
      # node_config.vm.provision "shell", inline: "echo 'Postgres nodes ready!'"
    end
  end
end
โšก 1.5 Power Up the Sandbox

In the powershell prompt ,
in the path : D:\sandboxes\postgres\postgres-lab01 ,
Issue below command.

# Start the sandbox machine
vagrant up

โณ Estimated startup time: ~10 minutes

1.6 : ๐Ÿ–ฅ๏ธ Login to the Server

๐Ÿš€ Launch PuTTY, connect to 192.168.56.160, and save the session as postgres-lab01 for future use.

  • ๐Ÿ‘ค Username : vagrant
  • ๐Ÿ”‘ Password : vagrant
๐Ÿ˜ 1.7 Install PostgreSQL and Create the Database

๐Ÿ‘ค Execute as the vagrant User

# Add postgresql RPM repository - approx time 05 mins for installation 
sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
# Disable the default PostgreSQL Application Stream module
sudo dnf -qy module disable postgresql
# Install PostgreSQL 16 server and useful extras
sudo dnf install -y postgresql16-server postgresql16-contrib postgresql16
# Force the SSH client to match the new libraries
sudo dnf reinstall openssh-clients openssh-server -y
sudo dnf update openssh-clients openssh-server -y

we will create a cluster database named as โ€œpostgres-lab01โ€œ

# Initialize the database cluster
sudo mkdir -p /var/lib/pgsql/postgres-lab01
sudo chown postgres:postgres /var/lib/pgsql/postgres-lab01
sudo -u postgres /usr/pgsql-16/bin/initdb -D /var/lib/pgsql/postgres-lab01

we will switch to the systemโ€™s postgres user and access the database using the psql utility.

# switch to postgres user
sudo -i -u postgres
# Start the Cluster onboarding
/usr/pgsql-16/bin/pg_ctl -D /var/lib/pgsql/postgres-lab01 -l logfile start
1.8 Create modular config files
# check the config file name
psql --port=5432 --username=postgres -c "SHOW config_file;"
# Update the config file to include directories
# 1. Copy the postgresql.conf 
cp /var/lib/pgsql/postgres-lab01/postgresql.conf /var/lib/pgsql/postgres-lab01/postgresql.conf.bak_$(date +%Y%m%d)

# 2. Create the Configuration Directory
mkdir -p /var/lib/pgsql/postgres-lab01/conf.d

# 3. Append the include_dir Statement
echo "include_dir = '/var/lib/pgsql/postgres-lab01/conf.d'" >> /var/lib/pgsql/postgres-lab01/postgresql.conf

# 4. Verify the Change
tail -n 1 /var/lib/pgsql/postgres-lab01/postgresql.conf

# 5. Create the respective config files 
touch /var/lib/pgsql/postgres-lab01/conf.d/00-extensions.conf
touch /var/lib/pgsql/postgres-lab01/conf.d/01-performance.conf
touch /var/lib/pgsql/postgres-lab01/conf.d/02-logging.conf
touch /var/lib/pgsql/postgres-lab01/conf.d/03-vacuum.conf
touch /var/lib/pgsql/postgres-lab01/conf.d/04-wal.conf

Congratulations:
You have completed a sandbox cluster named – postgres-lab01 running in port 5432.

๐Ÿ˜ Chapter 2 โ€“ Build the Performance Tuning Lab

๐Ÿ˜ Chapter 2 โ€“ Build the Performance Tuning Lab

๐Ÿ“Œ Goal
  • ๐Ÿฆ Create database creditcards
  • ๐Ÿ“‚ Create schema consumer
  • ๐Ÿงฑ Create core tables
    • ๐Ÿ‘ค customers
    • ๐Ÿ’ณ credit_cards
    • ๐Ÿ’ธ transactions
    • ๐Ÿ’ฐ payments
  • ๐ŸŽฒ Generate realistic sample data
  • ๐Ÿ“ˆ Load millions of rows
  • โœ… Verify row counts
2.1 ๐Ÿฆ Create the Credit Cards Database
psql --port=5432 --username=postgres -c "
CREATE DATABASE creditcards;
"
2.2 ๐Ÿ“‚ Create the Consumer Schema
psql --port=5432 --username=postgres -d creditcards -c "
CREATE SCHEMA consumer;
"
2.3 ๐Ÿ‘ค Create Customers Table
psql --port=5432 --username=postgres -d creditcards -c "
CREATE TABLE consumer.customers
(
    customer_id        BIGSERIAL PRIMARY KEY,
    first_name         VARCHAR(50),
    last_name          VARCHAR(50),
    email              VARCHAR(100),
    phone_number       VARCHAR(20),
    city               VARCHAR(50),
    state              VARCHAR(50),
    created_date       TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"
2.4 ๐Ÿ’ณ Create Credit Cards Table
psql --port=5432 --username=postgres -d creditcards -c "
CREATE TABLE consumer.credit_cards
(
    card_id            BIGSERIAL PRIMARY KEY,
    customer_id        BIGINT NOT NULL,
    card_number        VARCHAR(20),
    card_type          VARCHAR(20),
    credit_limit       NUMERIC(12,2),
    issue_date         DATE,
    expiry_date        DATE,
    status             VARCHAR(20),
    CONSTRAINT fk_cc_customer
    FOREIGN KEY (customer_id)
    REFERENCES consumer.customers(customer_id)
);
"
2.5 ๐Ÿ’ธ Create Transactions Table
psql --port=5432 --username=postgres -d creditcards -c "
CREATE TABLE consumer.transactions
(
    transaction_id     BIGSERIAL PRIMARY KEY,
    card_id            BIGINT NOT NULL,
    merchant_name      VARCHAR(100),
    transaction_amount NUMERIC(12,2),
    transaction_date   TIMESTAMP,
    transaction_type   VARCHAR(20),
    city               VARCHAR(50),
    status             VARCHAR(20),
    CONSTRAINT fk_txn_card
    FOREIGN KEY (card_id)
    REFERENCES consumer.credit_cards(card_id)
);
"
2.6 ๐Ÿ’ฐ Create Payments Table
psql --port=5432 --username=postgres -d creditcards -c "
CREATE TABLE consumer.payments
(
    payment_id         BIGSERIAL PRIMARY KEY,
    customer_id        BIGINT NOT NULL,
    payment_amount     NUMERIC(12,2),
    payment_method     VARCHAR(20),
    payment_date       TIMESTAMP,
    CONSTRAINT fk_payment_customer
    FOREIGN KEY (customer_id)
    REFERENCES consumer.customers(customer_id)
);
"
2.7 ๐Ÿ” Verify the Objects
psql --port=5432 --username=postgres -d creditcards -c "
SELECT
    schemaname,
    tablename
FROM pg_tables
WHERE schemaname='consumer';
"
2.8 ๐Ÿ‘ค Generate Customer Data
psql --port=5432 --username=postgres -d creditcards -c "
INSERT INTO consumer.customers
(
    first_name,
    last_name,
    email,
    phone_number,
    city,
    state
)
SELECT
    'Customer_' || g,
    'Lastname_' || g,
    'customer' || g || '@example.com',
    '555-' || lpad(g::text,6,'0'),
    'City_' || (g % 100),
    'State_' || (g % 20)
FROM generate_series(1,1000000) g;
"
2.9 ๐Ÿ’ณ Generate Credit Card Data
psql --port=5432 --username=postgres -d creditcards -c "
INSERT INTO consumer.credit_cards
(
    customer_id,
    card_number,
    card_type,
    credit_limit,
    issue_date,
    expiry_date,
    status
)
SELECT
    customer_id,
    '4000' || lpad(customer_id::text,12,'0'),
    CASE
        WHEN customer_id % 3 = 0 THEN 'VISA'
        WHEN customer_id % 3 = 1 THEN 'MASTERCARD'
        ELSE 'AMEX'
    END,
    (random()*9000+1000)::numeric(12,2),
    CURRENT_DATE - ((random()*1000)::int),
    CURRENT_DATE + ((random()*1000)::int),
    'ACTIVE'
FROM consumer.customers;
"
2.10 ๐Ÿ’ธ Generate Transaction Data (10 Million Rows)
psql --port=5432 --username=postgres -d creditcards -c "
INSERT INTO consumer.transactions
(
    card_id,
    merchant_name,
    transaction_amount,
    transaction_date,
    transaction_type,
    city,
    status
)
SELECT
    floor(random()*1000000 + 1)::bigint,
    'Merchant_' || (g % 5000),
    (random()*5000)::numeric(12,2),
    CURRENT_TIMESTAMP - ((random()*365)::int || ' days')::interval,
    CASE
        WHEN g % 2 = 0 THEN 'PURCHASE'
        ELSE 'REFUND'
    END,
    'City_' || (g % 100),
    'COMPLETED'
FROM generate_series(1,10000000) g;
"
2.11 ๐Ÿ’ฐ Generate Payment Data
psql --port=5432 --username=postgres -d creditcards -c "
INSERT INTO consumer.payments
(
    customer_id,
    payment_amount,
    payment_method,
    payment_date
)
SELECT
    floor(random() * 1000000 + 1)::bigint,
    (random()*3000)::numeric(12,2),
    CASE
        WHEN g % 3 = 0 THEN 'ACH'
        WHEN g % 3 = 1 THEN 'CARD'
        ELSE 'WIRE'
    END,
    CURRENT_TIMESTAMP - ((random()*365)::int || ' days')::interval
FROM generate_series(1,3000000) g;
"
2.12 ๐Ÿ“Š Verify Row Counts
psql --port=5432 --username=postgres -d creditcards -c "
SELECT 'customers' table_name, count(*) FROM consumer.customers
UNION ALL
SELECT 'credit_cards', count(*) FROM consumer.credit_cards
UNION ALL
SELECT 'transactions', count(*) FROM consumer.transactions
UNION ALL
SELECT 'payments', count(*) FROM consumer.payments;
"
  table_name  |  count
--------------+----------
credit_cards | 1000000
customers | 1000000
payments | 3000000
transactions | 10000000
(4 rows)
2.13 ๐Ÿ“Š Verify Table size
 psql --port=5432 --username=postgres -d creditcards -c "
SELECT
    schemaname,
    relname AS table_name,
    pg_size_pretty(pg_relation_size(relid)) AS table_size,
    pg_size_pretty(pg_indexes_size(relid)) AS index_size,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
WHERE schemaname='consumer'
ORDER BY pg_total_relation_size(relid) DESC;
"
 schemaname |  table_name  | table_size | index_size | total_size
------------+--------------+------------+------------+------------
consumer | transactions | 1032 MB | 214 MB | 1247 MB
consumer | payments | 195 MB | 64 MB | 260 MB
consumer | customers | 128 MB | 21 MB | 150 MB
consumer | credit_cards | 91 MB | 21 MB | 112 MB
(4 rows)

Enable the logging

cat <<EOF > /var/lib/pgsql/postgres-lab01/conf.d/02-logging.conf
# ============================================================
# PostgreSQL Logging Configuration
# Purpose: Production-grade performance troubleshooting logs
# ============================================================


# ------------------------------------------------------------
# Log Storage and Destination
# ------------------------------------------------------------
logging_collector = on
log_destination = 'stderr,csvlog'
log_directory = 'log'


# ------------------------------------------------------------
# Naming and Rotation
# Format: postgresql-YYYY-MM-DD_HHMMSS.log
# ------------------------------------------------------------
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'
log_rotation_age = 1d
log_rotation_size = 1000MB
log_truncate_on_rotation = off


# ------------------------------------------------------------
# Log Content Identity
# Helps identify who ran what, from where, and through which app along with queryid
# ------------------------------------------------------------
log_line_prefix = '%m [%p] %q[query_id=%Q] user=%u db=%d app=%a client=%h '

# log the queryid
compute_query_id = on

# ------------------------------------------------------------
# Slow Query Logging
# Logs queries running longer than 1 second
# ------------------------------------------------------------
log_min_duration_statement = 1000ms


# ------------------------------------------------------------
# Lock, Wait, and Deadlock Auditing
# Logs lock waits longer than deadlock_timeout
# ------------------------------------------------------------
log_lock_waits = on
deadlock_timeout = '1s'


# ------------------------------------------------------------
# Temporary File / Spill Logging
# Captures large sort/hash spills to disk
# Use 0 temporarily for deep troubleshooting
# ------------------------------------------------------------
log_temp_files = 64MB


# ------------------------------------------------------------
# Checkpoint Logging
# Useful for identifying checkpoint-related I/O spikes
# ------------------------------------------------------------
log_checkpoints = on


# ------------------------------------------------------------
# Autovacuum Logging
# Logs autovacuum actions taking longer than 1 second
# Use 0 temporarily for full autovacuum visibility
# ------------------------------------------------------------
log_autovacuum_min_duration = 1000ms


# ------------------------------------------------------------
# Error Statement Logging
# Logs SQL statement that caused an error
# ------------------------------------------------------------
log_min_error_statement = error


# ------------------------------------------------------------
# General Message Level
# Keeps normal server logs useful without being too noisy
# ------------------------------------------------------------
log_min_messages = warning


# ------------------------------------------------------------
# Connection Logging
# Keep these off by default in busy production systems.
# Turn on temporarily if investigating connection storms.
# ------------------------------------------------------------
log_connections = off
log_disconnections = off


# ------------------------------------------------------------
# Statement Logging
# Keep this off in production to avoid huge logs and sensitive data exposure.
# Prefer log_min_duration_statement instead.
# ------------------------------------------------------------
log_statement = 'none'
log_duration = off
EOF
๐Ÿ”ง Chapter 3 โ€“ Essential Extensions for Performance Tuning

๐Ÿ”ง Chapter 3 โ€“ Essential Extensions for Performance Tuning

๐Ÿ“Œ Goal

Install and understand the key PostgreSQL extensions used in performance work:

  • ๐Ÿ” pg_stat_statements – Track SQL performance
  • ๐Ÿ“ auto_explain – Log execution plans automatically
  • ๐Ÿ’พ pg_buffercache – Inspect shared buffer cache
  • ๐Ÿ“Š pgstattuple – Measure table/index bloat
  • ๐Ÿงช hypopg – Create hypothetical indexes
  • โšก pg_prewarm – Preload data into memory
  • ๐Ÿงพ pg_profile + dblink + pg_cron Setup -Similar to Oracle AWR functionality
Why Extensions Matter

๐Ÿ—‚๏ธ 3.1 Configure the Extensions Configuration File

In our lab environment, we follow a modular configuration approach. Instead of modifying postgresql.conf directly, extension-related settings will be maintained in a dedicated file.

๐Ÿ“„ Configuration File:

/var/lib/pgsql/postgres-lab01/conf.d/00-extensions.conf

๐Ÿง  3.2 Why Do Some Extensions Require a Restart?

Extensions such as pg_stat_statements and auto_explain integrate directly with the PostgreSQL engine and must be loaded during server startup. Extensions that simply provide SQL functions can be installed using CREATE EXTENSION without restarting PostgreSQL.

๐Ÿ” 3.3 pg_stat_statements

๐ŸŽฏ Why It Matters

This is one of the most useful tuning extensions. It shows which SQL statements are consuming the most time and resources.
โš™๏ธ Configure It

# update the extensions.conf file - 
cat >> /var/lib/pgsql/postgres-lab01/conf.d/00-extensions.conf <<EOF
# Shared libraries loaded during PostgreSQL startup
shared_preload_libraries = 'pg_stat_statements'
#
EOF
# Put the pg_stat_statements in 01-performance.conf:
cat >> /var/lib/pgsql/postgres-lab01/conf.d/01-performance.conf <<EOF
# ------------------------------------------------------------
# pg_stat_statements
# Tracks query execution statistics across the database
# ------------------------------------------------------------
pg_stat_statements.max = 10000
pg_stat_statements.track = all
pg_stat_statements.track_planning = on
pg_stat_statements.save = on	
#
EOF
# Restart PostgreSQL after updating the preload setting.
/usr/pgsql-16/bin/pg_ctl restart -D /var/lib/pgsql/postgres-lab01
# Create the extension:
psql -p 5432 -U postgres -d creditcards -c "
CREATE EXTENSION pg_stat_statements;
"
๐Ÿงช Practical Example

Run this query a few times:

psql -p 5432 -U postgres -d creditcards -c "
SELECT *
FROM consumer.transactions
WHERE card_id  = 100;
"
# check the collected stats:
psql -p 5432 -U postgres -d creditcards -c "
SELECT
    calls,
    total_exec_time,
    mean_exec_time,
    query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
"
[postgres@postgres-lab01 ~]$ psql -p 5432 -U postgres -d creditcards -c "
SELECT
calls,
total_exec_time,
mean_exec_time,
query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
"
calls | total_exec_time | mean_exec_time | query
-------+--------------------+--------------------+----------------------------------------------------------------------------
3 | 3569.50865 | 1189.8362166666666 | SELECT * +
| | | FROM consumer.transactions +
| | | WHERE card_id = $1
1 | 87.175479 | 87.175479 | CREATE EXTENSION pg_stat_statements
4 | 19.770034000000003 | 4.9425085 | -- Register a view on the function for ease of use. +
| | | CREATE VIEW pg_stat_statements AS +

๐Ÿ“ 3.4 auto_explain

๐ŸŽฏ Why It Matters

Sometimes a slow query happens once, and by the time you look at it, the plan is gone. auto_explain captures execution plans automatically.

# update the extensions.conf file
sed -i "s/shared_preload_libraries = 'pg_stat_statements'/shared_preload_libraries = 'pg_stat_statements,auto_explain'/" \
/var/lib/pgsql/postgres-lab01/conf.d/00-extensions.conf
# verify the update
cat /var/lib/pgsql/postgres-lab01/conf.d/00-extensions.conf
# update the 01-performance.conf file
cat >> /var/lib/pgsql/postgres-lab01/conf.d/01-performance.conf <<EOF
# ------------------------------------------------------------
# auto_explain
# Logs execution plans for slow queries
# Good for lab and targeted troubleshooting
# Be careful with log_analyze in very busy production
# ------------------------------------------------------------
auto_explain.log_min_duration = '500ms'
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.log_timing = on
auto_explain.log_nested_statements = on
auto_explain.sample_rate = 1.0
#
EOF
๐Ÿ”„ Restart PostgreSQL after updating the auto explain setting.
# Restart PostgreSQL after updating the preload setting.
/usr/pgsql-16/bin/pg_ctl restart -D /var/lib/pgsql/postgres-lab01

๐Ÿ“Œ Unlike most extensions in this chapter, auto_explain does not require a CREATE EXTENSION command. It is activated by loading the library through shared_preload_libraries and configuring its logging parameters.

๐Ÿงช Practical Example
# execute an select statement on the transactions table
psql -p 5432 -U postgres -d creditcards -c "
SELECT *
FROM consumer.transactions
ORDER BY transaction_amount DESC
LIMIT 40;
"
# review the latest PostgreSQL log file
tail -n 100 $(ls -1t /var/lib/pgsql/postgres-lab01/log/*.log | head -1)
2026-06-11 21:03:38.904 UTC [8957] LOG:  duration: 1487.198 ms  plan:
Query Text:
SELECT *
FROM consumer.transactions
ORDER BY transaction_amount DESC
LIMIT 40;

Limit (cost=306509.43..306514.09 rows=40 width=67) (actual time=1471.536..1485.967 rows=40 loops=1)
Buffers: shared hit=360 read=131855
-> Gather Merge (cost=306509.43..1278759.71 rows=8332992 width=67) (actual time=1471.533..1485.958 rows=40 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=360 read=131855
-> Sort (cost=305509.40..315925.64 rows=4166496 width=67) (actual time=1462.998..1463.003 rows=31 loops=3)
Sort Key: transaction_amount DESC
Sort Method: top-N heapsort Memory: 33kB
Buffers: shared hit=360 read=131855
Worker 0: Sort Method: top-N heapsort Memory: 34kB
Worker 1: Sort Method: top-N heapsort Memory: 34kB
-> Parallel Seq Scan on transactions (cost=0.00..173807.96 rows=4166496 width=67) (actual time=0.130..525.802 rows=3333333 loops=3)
Buffers: shared hit=288 read=131855
[postgres@postgres-lab01 ~]$
Lab Takeaway

๐Ÿ“Œ With auto_explain.log_min_duration = 500ms, PostgreSQL automatically captures execution plans only for queries whose execution time exceeds 500 milliseconds. This helps identify expensive queries without logging every statement executed by the database.

๐Ÿ’พ 3.5 pg_buffercache

๐ŸŽฏ Why It Matters

Shows what is currently occupying PostgreSQL shared memory.

# Create the extension
psql -p 5432 -U postgres -d creditcards -c "
CREATE EXTENSION pg_buffercache;
"

๐Ÿงช Practical Example

# Identify the hottest tables in memory.
psql -p 5432 -U postgres -d creditcards -c "
SELECT
    c.relname,
    count(*) AS buffers
FROM pg_buffercache b
JOIN pg_class c
ON b.relfilenode = pg_relation_filenode(c.oid)
GROUP BY c.relname
ORDER BY buffers DESC
LIMIT 10;
"
[postgres@postgres-lab01 ~]$ # Create the extension
psql -p 5432 -U postgres -d creditcards -c "
CREATE EXTENSION pg_buffercache;
"
CREATE EXTENSION
[postgres@postgres-lab01 ~]$
# Identify the hottest tables in memory.
psql -p 5432 -U postgres -d creditcards -c "
SELECT
c.relname,
count(*) AS buffers
FROM pg_buffercache b
JOIN pg_class c
ON b.relfilenode = pg_relation_filenode(c.oid)
GROUP BY c.relname
ORDER BY buffers DESC
LIMIT 10;
"
relname | buffers
---------------------------------+---------
transactions | 384
pg_attribute | 90
pg_class | 45
pg_attribute_relid_attnum_index | 22

๐Ÿ“Œ This helps you see which tables are hot in memory.

๐Ÿ“Š 3.6 pgstattuple

๐ŸŽฏ Why It Matters

Updates and deletes create dead space.
pgstattuple helps answer:
โ“ How much table bloat exists?

# create the extension:
psql -p 5432 -U postgres -d creditcards -c "
CREATE EXTENSION pgstattuple;
"
# Check for bloating:
psql -p 5432 -U postgres -d creditcards -x -c "
SELECT *
FROM pgstattuple('consumer.transactions');
"
[postgres@postgres-lab01 ~]$ # create the extension:
psql -p 5432 -U postgres -d creditcards -c "
CREATE EXTENSION pgstattuple;
"
CREATE EXTENSION
[postgres@postgres-lab01 ~]$ # Check for bloating:
psql -p 5432 -U postgres -d creditcards -x -c "
SELECT *
FROM pgstattuple('consumer.transactions');
"
-[ RECORD 1 ]------+-----------
table_len | 1082515456
tuple_count | 10000000
tuple_len | 978998376
tuple_percent | 90.44
dead_tuple_count | 0
dead_tuple_len | 0
dead_tuple_percent | 0

free_space | 3173692
free_percent | 0.29

[postgres@postgres-lab01 ~]$

๐Ÿ“ŒFocus on values like dead_tuple_percent and free_percent.

๐Ÿงช 3.7 hypopg

๐ŸŽฏ Why It Matters

This extension lets you test a hypothetical index without actually creating it.
Switch back to "vagrant" user and run the below commands

# Install the rpm as vagrant / root user 
sudo dnf install -y hypopg_16
# Switch to postgres account
sudo su - postgres
# create the extension
psql -p 5432 -U postgres -d creditcards -c "
CREATE EXTENSION hypopg;
"

Capture the current execution plan, create a hypothetical index, and capture the execution plan again.

psql -p 5432 -U postgres -d creditcards <<'SQL'
EXPLAIN (COSTS OFF)
SELECT transaction_id, card_id, transaction_date
FROM consumer.transactions
WHERE status = 'COMPLETED'
ORDER BY transaction_date DESC
LIMIT 20;

SELECT *
FROM hypopg_create_index(
'CREATE INDEX ON consumer.transactions(status, transaction_date DESC)'
);

EXPLAIN (COSTS OFF)
SELECT transaction_id, card_id, transaction_date
FROM consumer.transactions
WHERE status = 'COMPLETED'
ORDER BY transaction_date DESC
LIMIT 20;
SQL
                            QUERY PLAN
------------------------------------------------------------------
Limit
-> Gather Merge
Workers Planned: 2
-> Sort
Sort Key: transaction_date DESC
-> Parallel Seq Scan on transactions
Filter: ((status)::text = 'COMPLETED'::text)
(7 rows)

indexrelid | indexname
------------+------------------------------------------------------------
13587 | <13587>btree_consumer_transactions_status_transaction_date
(1 row)

QUERY PLAN
-----------------------------------------------------------------------------------------------------
Limit
-> Index Scan using "<13587>btree_consumer_transactions_status_transaction_date" on transactions
Index Cond: ((status)::text = 'COMPLETED'::text)
(3 rows)

๐ŸŽฏ HypoPG Benefit
The hypothetical index demonstrated that PostgreSQL would choose an Index Scan instead of a Sequential Scan. This allows DBAs to evaluate the potential benefit of an index before creating it in the database.

โšก 3.9 pg_prewarm

๐ŸŽฏ Why It Matters

After a restart, cache is cold. pg_prewarm helps load frequently used data into memory ahead of time.

# Create the extension:
psql -p 5432 -U postgres -d creditcards -c "
CREATE EXTENSION pg_prewarm;
"
# Preload the table into PostgreSQL shared buffers:
psql -p 5432 -U postgres -d creditcards -c "
SELECT pg_prewarm('consumer.customers');
"
[postgres@postgres-lab01 ~]$ # pre warm the table:
psql -p 5432 -U postgres -d creditcards -c "
SELECT pg_prewarm('consumer.customers');
"
pg_prewarm
------------
16388
(1 row)

๐Ÿ“Œ Prewarming is useful for small, frequently used tables and indexes that applications access immediately after startup, helping avoid cold-cache performance delays.

3.10 ๐Ÿงพ pg_profile + dblink + pg_cron Setup

  • ๐Ÿงพ pg_profile โ†’ Generate AWR-like historical performance reports
  • ๐Ÿ”— dblink โ†’ Required by pg_profile to connect and collect statistics
  • โฐ pg_cron โ†’ Schedule pg_profile snapshots automatically

Steps

# Install the rpm as vagrant / root user 
sudo dnf install -y pg_cron_16
# Move to temp directory
cd /tmp
# Download pg_profile package
wget https://github.com/zubkov-andrei/pg_profile/releases/download/4.11/pg_profile--4.11.tar.gz
# Extract pg_profile into PostgreSQL 16 extension directory
sudo tar xzf pg_profile--4.11.tar.gz --directory /usr/pgsql-16/share/extension
# update the extensions.conf file
sed -i "s/shared_preload_libraries = 'pg_stat_statements,auto_explain'/shared_preload_libraries = 'pg_stat_statements,auto_explain,pg_cron'/" /var/lib/pgsql/postgres-lab01/conf.d/00-extensions.conf
# update the 01-performance.conf file
cat >> /var/lib/pgsql/postgres-lab01/conf.d/01-performance.conf <<EOF

# ------------------------------------------------------------
# Captures SQL execution statistics
# Required for query-level performance analysis
# Useful for finding expensive SQL statements
# ------------------------------------------------------------
track_activities = on
track_counts = on
track_io_timing = on
track_wal_io_timing = on

# ------------------------------------------------------------
# pg_cron
# Runs scheduled jobs inside PostgreSQL
# Used here to schedule pg_profile snapshots
# cron.database_name tells pg_cron where its metadata lives
# ------------------------------------------------------------
cron.database_name = 'postgres'
cron.timezone = 'Europe/Amsterdam'

EOF
# Restart PostgreSQL after updating the preload setting.
/usr/pgsql-16/bin/pg_ctl restart -D /var/lib/pgsql/postgres-lab01
# Create required extensions
psql -p 5432 -U postgres -d postgres -c "
CREATE EXTENSION IF NOT EXISTS dblink;
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS pg_cron;
"
# Create pg_profile extension in profile schema
psql -p 5432 -U postgres -d postgres -c "
CREATE SCHEMA IF NOT EXISTS profile;
CREATE EXTENSION IF NOT EXISTS pg_profile SCHEMA profile;
"
# Verify installed extensions
psql -p 5432 -U postgres -d postgres -c "
SELECT extname, extversion
FROM pg_extension
WHERE extname IN ('dblink', 'pg_stat_statements', 'pg_cron', 'pg_profile')
ORDER BY extname;
"
# Update the Connection String to creditcards
psql -d postgres -c "SELECT profile.set_server_connstr('creditcards', 'dbname=creditcards port=5432');"
# Take first pg_profile snapshot
psql -p 5432 -U postgres -d postgres -c "
SELECT profile.take_sample();
"
# Run workload in creditcards database
psql -p 5432 -U postgres -d creditcards -c "
SELECT state, COUNT(*)
FROM consumer.customers
GROUP BY state
ORDER BY COUNT(*) DESC;
"
# Take second pg_profile snapshot
psql -p 5432 -U postgres -d postgres -c "
SELECT profile.take_sample();
"

Intentionally commented out all the below lines. Below is an example of how to set the cron job for pg_profile.

# Schedule pg_profile snapshot every 30 minutes
#psql -p 5432 -U postgres -d postgres -c "
#SELECT cron.schedule(
#  'pg_profile_snapshot_every_30_min',
#  '*/30 * * * *',
#  'SELECT profile.take_sample();'
#);
#"
# Check scheduled pg_cron jobs
psql -p 5432 -U postgres -d postgres -c "
SELECT jobid, jobname, schedule, command, active
FROM cron.job
ORDER BY jobid;
"

๐Ÿ Chapter Summary

In this chapter, we configured PostgreSQL extensions using a modular configuration approach and explored the tools commonly used during performance investigations.

We learned how to:

  • โœ… Capture expensive SQL statements using pg_stat_statements
  • โœ… Automatically log execution plans using auto_explain
  • โœ… Inspect buffer cache usage with pg_buffercache
  • โœ… Measure table bloat using pgstattuple
  • โœ… Evaluate indexing strategies using hypopg
  • โœ… Warm the PostgreSQL cache using pg_prewarm
  • ๐Ÿงพ pg_profile gives us AWR-like historical performance reports.
  • ๐Ÿ”— dblink is required by pg_profile to collect database statistics.
  • โฐ pg_cron helps us schedule regular pg_profile snapshots.

๐Ÿ“Š Chapter 4 โ€“ Reading Execution Plans

๐Ÿ“Š Chapter 4 โ€“ Reading Execution Plans

๐Ÿ“Œ Goal

Learn how PostgreSQL executes queries and how to interpret execution plans using:

  • ๐Ÿ”Ž EXPLAIN
  • ๐Ÿ”Ž EXPLAIN ANALYZE
  • ๐Ÿ”Ž EXPLAIN (ANALYZE, BUFFERS)

Understand common plan nodes:

  • ๐Ÿ“– Seq Scan
  • ๐Ÿ“– Index Scan
  • ๐Ÿ“– Bitmap Scan
  • ๐Ÿ“– Sort
  • ๐Ÿ“– Aggregate

๐ŸŽฏ 4.1 Why Execution Plans Matter

Before tuning a query, we must understand how PostgreSQL executes it.

An execution plan answers questions such as:

  • Which tables are being scanned?
  • Is PostgreSQL using an index?
  • Is data being sorted?
  • How much work is being performed?

๐Ÿ“Œ Performance tuning starts with reading execution plans.

๐Ÿ”Ž 4.2 Understanding EXPLAIN

What is EXPLAIN?

EXPLAIN shows the execution plan PostgreSQL intends to use.

# Execute the explain plan
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN
SELECT *
FROM consumer.transactions
WHERE card_id = 100;
"
                                   QUERY PLAN
---------------------------------------------------------------------------------
Gather (cost=1000.00..185225.30 rows=11 width=67)
Workers Planned: 2
-> Parallel Seq Scan on transactions (cost=0.00..184224.20 rows=5 width=67)
Filter: (card_id = 100)
(4 rows)

Look closely at the query plan. To fetch just 11 rows, PostgreSQL is forced to do an immense amount of heavy lifting:

  • A massive cost of 185,225 units is incurred just to find those 11 matching records.
  • 2 Parallel Workers are launched to scan the entire table simultaneously because there is no index to guide them.

๐Ÿ”Ž 4.3 Understanding EXPLAIN ANALYZE

What is EXPLAIN ANALYZE?
Unlike EXPLAIN, this actually executes the query.

# Execute the explain and analyze plan
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN ANALYZE
SELECT *
FROM consumer.transactions
WHERE card_id = 100;
"
                                       QUERY PLAN

Gather (cost=1000.00..185225.30 rows=11 width=67) (actual time=169.761..929.702 rows=11 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Parallel Seq Scan on transactions (cost=0.00..184224.20 rows=5 width=67) (actual time=329.132..913.810 rows=4 loops=3)
Filter: (card_id = 100)
Rows Removed by Filter: 3333330
Planning Time: 0.988 ms
Execution Time: 930.582 ms
(8 rows)

The takeaway: Doing a full table scan and reading over 3 million rows to find 11 items is a massive waste of CPU. This confirms we absolutely need to add an index on card_id.

๐Ÿ“Œ EXPLAIN ANALYZE shows what actually happened where as in EXPLAIN query is not executed.

EXPLAINEXPLAIN ANALYZE
Estimated rowsActual rows
Estimated costActual runtime
Query not executedQuery executed

๐Ÿ’พ 4.4 Understanding BUFFERS

Why Buffers Matter

Performance problems often come from excessive disk I/O.

# Execute the explain with analyze and buffers
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM consumer.transactions
WHERE card_id = 100;
"
                                                          QUERY PLAN
Gather (cost=1000.00..185225.30 rows=11 width=67) (actual time=80.468..873.164 rows=11 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=96 read=132047
-> Parallel Seq Scan on transactions (cost=0.00..184224.20 rows=5 width=67) (actual time=132.177..857.839 rows=4 loops=3)
Filter: (card_id = 100)
Rows Removed by Filter: 3333330
Buffers: shared hit=96 read=132047
Planning:
Buffers: shared hit=78
Planning Time: 0.682 ms
Execution Time: 874.136 ms
(12 rows)
What the numbers mean for our query:
  • shared hit=96: Postgres found 96 pages of data already cached in RAM. It read these instantly.
  • read=132047: Postgres had to go to the slow disk to pull 132,047 pages. Because it’s doing a full table scan, it has to load the entire table from the disk into memory just to check for card_id = 100.
MetricMeaning
shared hitFound in memory
shared readRead from disk

๐Ÿ“Œ More hits and fewer reads generally indicate better performance.

๐Ÿ“– 4.5 Seq Scan

What is a Seq Scan?

PostgreSQL reads every row in the table.

# execute the explain plan 
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN
SELECT *
FROM consumer.transactions
WHERE card_id = 100;
"
 ->  Parallel Seq Scan on transactions  (cost=0.00..184224.20 rows=5 width=67)

๐Ÿ“Œ Equivalent to a full table scan.

When is it good?
  • Small tables
  • Large result sets
When is it bad?
  • Highly selective queries
  • Large tables

๐Ÿ“– 4.6 Index Scan

What is an Index Scan?

PostgreSQL uses an index to locate rows.

# Create demo index:
psql -p 5432 -U postgres -d creditcards -c "
CREATE INDEX idx_transactions_card_id
ON consumer.transactions(card_id);
"
# execute the analyze plan
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN
SELECT *
FROM consumer.transactions
WHERE card_id = 100;
"
                                           QUERY PLAN
Index Scan using idx_transactions_card_id on transactions (cost=0.43..48.63 rows=11 width=67)
Index Cond: (card_id = 100)
(2 rows)

๐Ÿ“Œ Reads only matching rows.
After creating the index, PostgreSQL immediately switched from a heavy full table scan to a lightning-fast Index Scan, which dropped the estimated query cost from 185,225 down to just 48. Instead of reading millions of unrelated records, the database now uses the index condition to pinpoint and grab the exact 11 rows we need instantly.

๐Ÿ“– 4.7 Bitmap Scan

๐ŸŽฏ What is a Bitmap Scan?

A Bitmap Scan is an access strategy PostgreSQL uses when many rows match an indexed condition.

Instead of repeatedly jumping between the index and table, PostgreSQL:

  1. Uses the index to identify matching rows
  2. Builds a bitmap of row locations
  3. Reads the required table pages efficiently

๐Ÿ“Œ Bitmap Scans are commonly used when an Index Scan would return a large number of rows.

# Capture the current execution plan before creating the index:
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (COSTS OFF)
SELECT *
FROM consumer.transactions
WHERE merchant_name = 'Merchant_1';
"

๐Ÿ” Check Data Distribution

psql -p 5432 -U postgres -d creditcards -c "
SELECT
    status,
    count(*)
FROM consumer.transactions
GROUP BY status;
"
# create the index:
psql -p 5432 -U postgres -d creditcards -c "
CREATE INDEX idx_transactions_merchant
ON consumer.transactions(merchant_name);
"
# Capture the current execution plan after creating the index:
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (COSTS OFF)
SELECT *
FROM consumer.transactions
WHERE merchant_name = 'Merchant_1';
"
                            QUERY PLAN
------------------------------------------------------------------
 Bitmap Heap Scan on transactions
   Recheck Cond: ((merchant_name)::text = 'Merchant_1'::text)
   ->  Bitmap Index Scan on idx_transactions_merchant
         Index Cond: ((merchant_name)::text = 'Merchant_1'::text)
(4 rows)

๐Ÿ“Œ PostgreSQL uses the same B-tree index, but because many rows match merchant_name = 'Merchant_1', it chooses a Bitmap Scan strategy instead of a regular Index Scan. This allows PostgreSQL to collect all matching row locations first and then read the required table pages more efficiently.

Unlike a normal index scan that effortlessly zips straight to the exact row you need, a bitmap scan forces Postgres to pause and construct a massive search map directly inside the server’s RAM (work_mem).

๐Ÿ“– 4.8 Sort

๐ŸŽฏ What is a Sort?

A Sort operation occurs when PostgreSQL needs to return rows in a specific order, typically when an ORDER BY clause is used.

# Check how PostgreSQL sorts the result set:
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (COSTS OFF)
SELECT transaction_id,
       merchant_name,
       transaction_amount
FROM consumer.transactions
ORDER BY transaction_amount DESC
LIMIT 20;
"
-----------------------------------------------------
Limit
-> Gather Merge
Workers Planned: 2
-> Sort
Sort Key: transaction_amount DESC

-> Parallel Seq Scan on transactions
(6 rows)
๐ŸŽฏ What Did We Learn?

๐Ÿ“Œ PostgreSQL performs a Sort operation whenever rows must be returned in a specific order. Large sorts may require additional memory and can become a performance bottleneck.

๐Ÿ“– 4.9 Aggregate

๐ŸŽฏ What is an Aggregate?

An Aggregate operation combines multiple rows into a single result using functions such as:

  • COUNT()
  • SUM()
  • AVG()
  • MIN()
  • MAX()
# Check how PostgreSQL performs aggregation:
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (COSTS OFF)
SELECT count(*)
FROM consumer.transactions;
"
--------------------------------------------------------------------------------------------
Finalize Aggregate
-> Gather
Workers Planned: 2
-> Partial Aggregate
-> Parallel Index Only Scan using idx_transactions_merchant on transactions

(5 rows)
๐ŸŽฏ What Did We Learn?

๐Ÿ“Œ PostgreSQL uses Aggregate operations to calculate summary values such as COUNT(), SUM(), and AVG(). For large tables, PostgreSQL may use parallel processing to split the work across multiple workers and combine the results efficiently.

๐Ÿ“– 4.10 Sort and Aggregate Together

๐ŸŽฏ Why Is This Important?

Real-world queries often contain multiple plan nodes working together.

The following query performs:

  • Aggregation using COUNT(*)
  • Sorting using ORDER BY
# Check how PostgreSQL combines Aggregate and Sort operations:
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (COSTS OFF)
SELECT
    merchant_name,
    count(*)
FROM consumer.transactions
GROUP BY merchant_name
ORDER BY count(*) DESC
LIMIT 10;
"
                                               QUERY PLAN
Limit
-> Sort
Sort Key: (count(*)) DESC
-> Finalize GroupAggregate
Group Key: merchant_name
-> Gather Merge
Workers Planned: 2
-> Partial GroupAggregate
Group Key: merchant_name
-> Parallel Index Only Scan using idx_transactions_merchant on transactions
(10 rows)

๐ŸŽฏ What Did We Learn?
๐Ÿ“Œ Execution plans are made up of multiple plan nodes working together. In this example, PostgreSQL used an Index Only Scan to read data, performed an Aggregate operation to calculate counts per merchant, and then sorted the results before returning the top 10 merchants.


๐Ÿงช 4.11 Lab Exercise

# Analyze a complete execution plan:
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT
    merchant_name,
    count(*)
FROM consumer.transactions
GROUP BY merchant_name
ORDER BY count(*) DESC
LIMIT 10;
"
 Limit  (cost=150701.32..150701.35 rows=10 width=21) (actual time=2806.104..2812.678 rows=10 loops=1)
Buffers: shared hit=31933 read=8891
-> Sort (cost=150701.32..150713.83 rows=5002 width=21) (actual time=2806.101..2812.672 rows=10 loops=1)
Sort Key: (count(*)) DESC
Sort Method: top-N heapsort Memory: 25kB
Buffers: shared hit=31933 read=8891
-> Finalize GroupAggregate (cost=1000.46..150593.23 rows=5002 width=21) (actual time=261.674..2808.781 rows=5000 loops=1)
Group Key: merchant_name
Buffers: shared hit=31930 read=8891
-> Gather Merge (cost=1000.46..150493.19 rows=10004 width=21) (actual time=261.049..2799.936 rows=9769 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=31930 read=8891
-> Partial GroupAggregate (cost=0.43..148338.46 rows=5002 width=21) (actual time=1.019..1864.360 rows=3256 loops=3)
Group Key: merchant_name
Buffers: shared hit=31930 read=8891
-> Parallel Index Only Scan using idx_transactions_merchant on transactions (cost=0.43..127455.10 rows=4166667 width=13) (actual
time=0.180..905.835 rows=3333333 loops=3)
Heap Fetches: 0
Buffers: shared hit=31930 read=8891
Planning:
Buffers: shared hit=112
Planning Time: 1.184 ms
Execution Time: 2813.582 ms
(23 rows)
๐Ÿ” How to Analyze an Execution Plan

๐Ÿ“Œ A useful approach is to read the execution plan from bottom to top, since PostgreSQL executes the lower operations first and then passes the results upward.


StepPlan NodeStartup Cost โ†’ Total CostStartup Time โ†’ Completion TimeObservation
1Parallel Index Only Scan0.43 โ†’ 127455.100.180 ms โ†’ 905.835 msPostgreSQL reads data from the index without accessing the table.
2Partial GroupAggregateIncluded in parent node1.019 ms โ†’ 1864.360 msEach worker calculates partial counts independently.
3Gather Merge1000.46 โ†’ 150493.19261.049 ms โ†’ 2799.936 msPostgreSQL merges results from all parallel workers.
4Finalize GroupAggregate1000.46 โ†’ 150593.23261.674 ms โ†’ 2808.781 msPartial aggregates are combined into final counts.
5Sort150701.32 โ†’ 150713.832806.101 ms โ†’ 2812.672 msResults are sorted by transaction count.
6Limit150701.32 โ†’ 150701.352806.104 ms โ†’ 2812.678 msOnly the top 10 rows are returned.

๐Ÿ“Š Buffer Statistics
MetricValueMeaning
Shared Hit31,933Data found in memory
Shared Read8,891Data read from disk
Planning Buffers112Buffers used during plan generation

๐Ÿ“Œ Execution plans should be read from bottom to top. By examining the scan operation, aggregation, sorting, buffer activity, costs, and execution times, we can understand how PostgreSQL executes a query and identify potential performance bottlenecks.


๐Ÿ“ˆ Chapter 5 โ€“ Join Strategies Deep Dive

๐Ÿ“ˆ Chapter 5 โ€“ Join Strategies Deep Dive

๐ŸŽฏ Lab Goal
  • ๐Ÿ”— Understand Nested Loop Join
  • ๐Ÿ”— Understand Hash Join
  • ๐Ÿ”— Understand Merge Join
  • ๐Ÿงช Force each join method
  • โฑ๏ธ Compare execution times

๐Ÿงฉ Step 1 โ€“ Check the table sizes

#Check the size of the join tables:
psql -p 5432 -U postgres -d creditcards -c "
SELECT
    schemaname,
    tablename,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size
FROM pg_tables
WHERE schemaname = 'consumer'
  AND tablename IN ('customers', 'transactions','credit_cards');
"
 schemaname |  tablename   | total_size
------------+--------------+------------
consumer | credit_cards | 112 MB
consumer | customers | 150 MB
consumer | transactions | 1405 MB
(3 rows)

Step 2 โ€“ Refresh planner statistics
๐Ÿง  This helps PostgreSQL choose realistic join plans.

# Refresh statistics for both tables:
psql -p 5432 -U postgres -d creditcards -c "
ANALYZE consumer.customers;
ANALYZE consumer.credit_cards;
ANALYZE consumer.transactions;
"

๐Ÿงช Step 3 โ€“ Observe PostgreSQL’s default join strategy
๐Ÿ” This is the same query you will use for all three join methods.
๐Ÿ“Œ Keep the query identical so the comparison is fair.

# Run the baseline join query with the default planner choice:
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT
    c.customer_id,
    c.first_name,
    c.last_name,
    COUNT(t.transaction_id) AS transaction_count
FROM consumer.customers c
JOIN consumer.credit_cards cc
    ON c.customer_id = cc.customer_id
JOIN consumer.transactions t
    ON cc.card_id = t.card_id
GROUP BY
    c.customer_id,
    c.first_name,
    c.last_name
ORDER BY transaction_count DESC
LIMIT 10;
"
๐Ÿง  Key Takeaways
  • ๐Ÿ”— PostgreSQL chose a combination of Merge Join and Nested Loop Join to process the query.
  • โš™๏ธ The planner used parallel execution (2 workers) to speed up processing.
  • ๐Ÿ”„ The Nested Loop Join resulted in 1 million index lookups against the transactions table.
  • ๐Ÿ“– More than 8.8 million pages were read during execution, indicating significant I/O activity.
  • โฑ๏ธ The query completed in approximately 54 seconds, making it a good candidate for join strategy analysis.
๐ŸŽ“ What to Remember

When analyzing execution plans, focus on the operators consuming the most work. In this plan, the Nested Loop Join drove one million index lookups against the transactions table, making it the most expensive part of the execution.


๐Ÿ”— Step 4 โ€“ Force a Nested Loop Join

๐Ÿ” In this step, we will force PostgreSQL to use a Nested Loop Join and compare its behavior with the default plan observed earlier.
๐Ÿ“š Understanding Nested Loop Join
A Nested Loop Join works by taking each row from one table and finding matching rows from another table.

๐Ÿงช Force PostgreSQL to Use a Nested Loop Join
To focus on Nested Loop behavior, disable Hash Join and Merge Join.

# Force PostgreSQL to use Nested Loop Join:
psql -p 5432 -U postgres -d creditcards -c "
SET enable_hashjoin = off;
SET enable_mergejoin = off;
SET enable_nestloop = on;

EXPLAIN (ANALYZE, BUFFERS)
SELECT
    c.customer_id,
    c.first_name,
    c.last_name,
    COUNT(t.transaction_id) AS transaction_count
FROM consumer.customers c
JOIN consumer.credit_cards cc
    ON c.customer_id = cc.customer_id
JOIN consumer.transactions t
    ON cc.card_id = t.card_id
GROUP BY
    c.customer_id,
    c.first_name,
    c.last_name
ORDER BY transaction_count DESC
LIMIT 10;
"
๐Ÿง  Key Takeaways
  • PostgreSQL was forced to use Nested Loop Join for all table joins by disabling Merge Join and Hash Join.
  • The join between customers and credit_cards switched from a Merge Join in the baseline plan to a Nested Loop Join.

๐Ÿ”ฅ Step 5 โ€“ Force a Hash Join

๐ŸŽฏ Goal
In this step, we will force PostgreSQL to use a Hash Join and compare its behavior with the Nested Loop Join and the baseline plan.
๐Ÿ“š Understanding Hash Join
A Hash Join works by building an in-memory hash table from one side of the join and then probing it with rows from the other side.

Instead of repeatedly searching for matching rows, PostgreSQL performs fast hash lookups.

โœ… Strengths
  • โšก Excellent for large equality joins
  • โšก Reduces repeated lookups
  • โšก Often the preferred choice for large datasets
โš ๏ธ Limitations
  • ๐Ÿง  Requires memory to build hash tables
  • ๐Ÿ“ฆ Large hash tables may spill to disk if memory is insufficient
  • ๐Ÿšซ Only useful for equality-based joins

๐Ÿงช Force PostgreSQL to Use a Hash Join

# To focus on Hash Join behavior, disable Nested Loop Join and Merge Join.
psql -p 5432 -U postgres -d creditcards -c "
SET enable_nestloop = off;
SET enable_mergejoin = off;
SET enable_hashjoin = on;

EXPLAIN (ANALYZE, BUFFERS)
SELECT
    c.customer_id,
    c.first_name,
    c.last_name,
    COUNT(t.transaction_id) AS transaction_count
FROM consumer.customers c
JOIN consumer.credit_cards cc
    ON c.customer_id = cc.customer_id
JOIN consumer.transactions t
    ON cc.card_id = t.card_id
GROUP BY
    c.customer_id,
    c.first_name,
    c.last_name
ORDER BY transaction_count DESC
LIMIT 10;
"
 Planning:
Buffers: shared hit=159 read=34
Planning Time: 4.226 ms
Execution Time: 28417.503 ms
(47 rows)
๐Ÿง  Key Takeaways
  • ๐Ÿ”ฅ PostgreSQL used a Parallel Hash Join to join transactions with the result of customers -> credit_cards.
  • ๐Ÿงฑ The join between customers and credit_cards also used a Parallel Hash Join.
  • โš™๏ธ PostgreSQL used 2 workers, so the work was split across 3 processes total including the leader.
  • ๐Ÿ“ฆ Both hash steps spilled to disk (Batches, Disk Usage, and temp read/write are visible), which means memory was not enough to keep everything fully in memory.
  • โฑ๏ธ Even with disk spill, the query finished in about 28.4 seconds, which is much faster than the 54 second Nested Loop plan.

PostgreSQL chose a Parallel Hash Join for this query, and the plan was significantly faster than the Nested Loop version. Both join steps used hash tables, but the hash operations spilled to disk because of memory limits. Even so, the query completed in about 28.4 seconds, showing that Hash Join was a much better fit for this workload.

๐Ÿงญ Step 6 โ€“ Prepare for and Force a Merge Join

๐ŸŽฏ Goal
In this step, we will prepare the tables for a Merge Join and then force PostgreSQL to use it.

๐Ÿ“š Understanding Merge Join

A Merge Join works best when both inputs are already sorted on the join key.

Think of it like merging two ordered lists:

1, 2, 3, 4
1, 2, 3, 4

PostgreSQL compares rows in order and moves forward together.

โœ… Strengths
  • ๐Ÿ“š Very efficient for sorted inputs
  • ๐Ÿš€ Works well when indexes support the join keys
  • โšก Can be fast for large equality joins
โš ๏ธ Limitations
  • ๐Ÿงฑ Usually needs sorted input
  • ๐Ÿ“ฆ Sorting can add extra cost if indexes are missing
  • ๐Ÿ” Not always the best choice for unsorted large tables

๐Ÿงช Force PostgreSQL to Use Merge Join

# Force PostgreSQL to use Merge Join:
psql -p 5432 -U postgres -d creditcards -c "
SET enable_hashjoin = off;
SET enable_nestloop = off;
SET enable_mergejoin = on;

EXPLAIN (ANALYZE, BUFFERS)
SELECT
    c.customer_id,
    c.first_name,
    c.last_name,
    COUNT(t.transaction_id) AS transaction_count
FROM consumer.customers c
JOIN consumer.credit_cards cc
    ON c.customer_id = cc.customer_id
JOIN consumer.transactions t
    ON cc.card_id = t.card_id
GROUP BY
    c.customer_id,
    c.first_name,
    c.last_name
ORDER BY transaction_count DESC
LIMIT 10;
"
๐Ÿง  Key Takeaways
  • ๐ŸŸข Merge Join Operations
  • PostgreSQL used a Merge Join to combine the transactions and credit_cards tables using the card_id column.
  • PostgreSQL then used another Merge Join to combine the result with the customers table using the customer_id column.
  • A Merge Join works best when both inputs are sorted on the join columns.
  • ๐ŸŸก Sort Operations
  • Before the Merge Joins could occur, PostgreSQL had to sort the data on the join columns (card_id and customer_id).
  • The sorted data was larger than the available memory, so PostgreSQL used an External Merge Sort, which means part of the sort was written to disk.
  • The disk usage shown beside each sort operation indicates how much temporary disk space was used during the sorting process.

๐ŸŽ“ Key Takeaway
Join StrategyJoin Operations UsedAdditional Work RequiredExecution Time
Baseline PlanMerge Join (customers โ†” credit_cards) + Nested Loop (credit_cards โ†” transactions)Sort required for Merge Join44.5 sec
Forced Nested LoopNested Loop (customers โ†” credit_cards) + Nested Loop (credit_cards โ†” transactions)No sorting required, but 1M customer index lookups performed47.8 sec
Forced Merge JoinMerge Join (transactions โ†” credit_cards) + Merge Join (customers โ†” credit_cards)Multiple large sorts spilled to disk42.7 sec
Forced Hash JoinHash Join (customers โ†” credit_cards) + Hash Join (credit_cards โ†” transactions)Hash tables built in memory28 sec
๐Ÿง  Overall Observation
RankJoin StrategyExecution Time
๐Ÿฅ‡ 1Hash Join~28 sec
๐Ÿฅˆ 2Merge Join~42.7 sec
๐Ÿฅ‰ 3Baseline Plan~44.5 sec
4Nested Loop~47.8 sec
Key Learning
  • Hash Join was the fastest strategy for this workload.
  • Merge Join performed better than Nested Loop Join.
  • The PostgreSQL optimizer’s default plan was good, but not the fastest in this specific scenario.
  • Different join strategies can produce significantly different execution times even when returning the same result set.

๐Ÿน Chapter 6 โ€“ Index Tuning Deep Dive

๐Ÿน Chapter 6 โ€“ Index Tuning Deep Dive

๐Ÿ“Œ Goal
  • ๐ŸŽฏ Understand single-column indexes
  • ๐ŸŽฏ Understand composite indexes
  • ๐ŸŽฏ Understand partial indexes
  • ๐ŸŽฏ Understand expression indexes
  • ๐ŸŽฏ Understand covering indexes
  • ๐Ÿ” See the before index plan
  • ๐Ÿš€ See the after index plan
  • ๐Ÿ“ˆ Observe how the execution plan changes
๐Ÿ“Œ How PostgreSQL Decides to Use an Index
  • ๐Ÿง  PostgreSQL uses a Query Planner before executing a query.
  • ๐Ÿ“Š The planner estimates how many rows will be returned.
  • ๐Ÿ’ฐ It compares the cost of an Index Scan versus a Sequential Scan.
  • ๐ŸŽฏ PostgreSQL chooses the lowest-cost execution plan.

๐Ÿ”ฌ 6.1 Baseline Query Performance (Before Indexes)

๐ŸŽฏ Goal
  • ๐Ÿ” Observe the execution plan
  • ๐Ÿ“Š Capture baseline performance
  • ๐Ÿšจ Identify the bottleneck
  • ๐Ÿน Establish a before-and-after comparison

๐Ÿ’ป Execute the Query

# Display the execution plan for first_name = 'Customer_594734'
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT
    customer_id,
    first_name,
    last_name
FROM consumer.customers
WHERE first_name = 'Customer_594734';
"
 Gather  (cost=1000.00..22596.43 rows=1 width=38) (actual time=5473.842..9087.951 rows=1 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared read=16388
-> Parallel Seq Scan on customers (cost=0.00..21596.33 rows=1 width=38) (actual time=7835.074..9033.938 rows=0 loops=3)
Filter: ((first_name)::text = 'Customer_594734'::text)
Rows Removed by Filter: 333333
Buffers: shared read=16388
Planning:
Buffers: shared hit=46 read=20
Planning Time: 50.881 ms
Execution Time: 9094.326 ms
(12 rows)
ItemValueWhy It Matters
๐Ÿ“– Access MethodParallel Seq ScanPostgreSQL scanned the entire customers table.
โฑ๏ธ Execution Time9,094.326 msTotal query execution time (~9.1 seconds).
๐ŸŽฏ Rows Returned1Only one row matched the filter condition.
๐Ÿšซ Rows Removed by Filter333,333 per workerMost scanned rows were discarded.
โš™๏ธ Workers Launched2PostgreSQL used parallel execution.
๐Ÿ’พ Shared Buffer Hits0No data pages were served from cache.
๐Ÿ“ฅ Shared Buffer Reads16,388PostgreSQL had to read all required pages from disk.
๐Ÿ“ Planning Time50.881 msTime spent generating the execution plan.

PostgreSQL performed a Parallel Seq Scan on the entire table, read 16,388 pages from disk, discarded most rows, and returned only 1 matching row.

๐Ÿน 6.2 Single-Column Index

๐Ÿ’ป Create the Index

# Create a single-column index
psql -p 5432 -U postgres -d creditcards -c "
CREATE INDEX idx_customers_state
ON consumer.customers(first_name);
"

๐Ÿ’ป Rerun the Same Query

# Run the same query again
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT
    customer_id,
    first_name,
    last_name
FROM consumer.customers
WHERE first_name = 'Customer_594734';
"
 Index Scan using idx_customers_state on customers  (cost=0.42..8.44 rows=1 width=38) (actual time=0.298..0.301 rows=1 loops=1)
Index Cond: ((first_name)::text = 'Customer_594734'::text)
Buffers: shared read=4
Planning:
Buffers: shared hit=84 read=4
Planning Time: 12.249 ms
Execution Time: 0.458 ms
(7 rows)
ItemValueWhy It Matters
๐Ÿ“– Access MethodIndex ScanPostgreSQL used the index instead of scanning the entire table.
๐ŸŽฏ Index Usedidx_customers_state (consider renaming if created on first_name)The index was used to locate the matching row directly.
โฑ๏ธ Execution Time0.458 msQuery completed almost instantly.
๐ŸŽฏ Rows Returned1Exactly one matching row was found.
๐Ÿ’พ Shared Buffer Reads4Only 4 pages were read from disk.
๐Ÿ” Index Conditionfirst_name = 'Customer_594734'PostgreSQL used the index to satisfy the filter.
๐Ÿ“ Planning Time12.249 msTime spent generating the execution plan.
# Drop the single-column index
psql -p 5432 -U postgres -d creditcards -c "
DROP INDEX consumer.idx_customers_state;
"
๐Ÿš€ When a Single-Column Index Helps
  • ๐ŸŽฏ The query filters on one column
  • ๐Ÿ”Ž The column is used often in WHERE
  • ๐Ÿ“‰ The filter is selective enough
  • โšก You want faster lookups on that column

๐Ÿน 6.3 Composite Index

๐ŸŽฏ Goal
  • ๐Ÿ”จ Create an index on multiple columns
  • ๐Ÿงญ Understand why column order matters
  • โ–ถ๏ธ Rerun the query
  • ๐Ÿ“ˆ Observe the execution plan improvement
๐Ÿ“Œ Why Composite Indexes?
  • ๐ŸŽฏ Optimize queries that filter on multiple columns
  • ๐Ÿ” Help PostgreSQL narrow down matching rows faster
  • โšก Reduce unnecessary table reads
  • ๐Ÿ“Š Improve performance for multi-column search conditions

๐Ÿ’ป Create the Composite Index

Create a composite index
# Create a composite index
psql -p 5432 -U postgres -d creditcards -c "
CREATE INDEX idx_customers_state_city
ON consumer.customers(state, city);
"

โœ… Demo 1: Filter on Both Columns

# Query uses both columns from the index
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM consumer.customers
WHERE state = 'State_1'
  AND city = 'City_1';
"
 Bitmap Heap Scan on customers  (cost=9.81..1810.20 rows=525 width=98) (actual time=7.800..162.009 rows=10000 loops=1)
Recheck Cond: (((state)::text = 'State_1'::text) AND ((city)::text = 'City_1'::text))
Heap Blocks: exact=10000
Buffers: shared hit=137 read=9875
-> Bitmap Index Scan on idx_customers_state_city (cost=0.00..9.68 rows=525 width=0) (actual time=3.838..3.839 rows=10000 loops=1)
Index Cond: (((state)::text = 'State_1'::text) AND ((city)::text = 'City_1'::text))
Buffers: shared read=12
Planning:
Buffers: shared hit=105 read=1 dirtied=2
Planning Time: 3.313 ms
Execution Time: 164.768 ms
(11 rows)
๐Ÿ” What do we observe?
  • PostgreSQL used the composite index correctly.
  • The query filtered on both indexed columns, so the index matched the condition well.
  • The query returned 10,000 rows, which is a fairly large result set.
  • Because many rows matched, the query still had to do a lot of work to return them.
  • This is a good example of a composite index helping with a multi-column filter.

โœ… Demo 2: Filter on Leading Column Only

# Query uses only the first column
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM consumer.customers
WHERE state = 'State_1';
"
 Bitmap Heap Scan on customers  (cost=580.84..17614.68 rows=51667 width=98) (actual time=33.176..925.892 rows=50000 loops=1)
Recheck Cond: ((state)::text = 'State_1'::text)
Heap Blocks: exact=16388
Buffers: shared hit=9760 read=6674
-> Bitmap Index Scan on idx_customers_state_city (cost=0.00..567.93 rows=51667 width=0) (actual time=14.401..14.402 rows=50000 loops=1)
Index Cond: ((state)::text = 'State_1'::text)
Buffers: shared hit=12 read=34
Planning:
Buffers: shared hit=106
Planning Time: 3.644 ms
Execution Time: 941.263 ms
(11 rows)
๐Ÿ” What Do We Observe?
  • PostgreSQL used the composite index even though only one column was specified in the filter.
  • The query used the leading column (state), allowing the index to be used efficiently.
  • Composite indexes can support queries that filter on the leftmost column of the index definition.
  • This demonstrates the leftmost-prefix rule in PostgreSQL indexes.

โš ๏ธ Demo 3: Filter on Second Column Only

# Query skips the leading column
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM consumer.customers
WHERE city = 'City_1';
"
 Gather  (cost=1000.00..23613.03 rows=10167 width=98) (actual time=4.568..189.111 rows=10000 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=16076 read=312
-> Parallel Seq Scan on customers (cost=0.00..21596.33 rows=4236 width=98) (actual time=0.052..151.464 rows=3333 loops=3)
Filter: ((city)::text = 'City_1'::text)
Rows Removed by Filter: 330000
Buffers: shared hit=16076 read=312
Planning:
Buffers: shared hit=106
Planning Time: 7.318 ms
Execution Time: 190.859 ms
(12 rows)
๐Ÿ” What do we observe?
  • PostgreSQL did not use the (state, city) index here.
  • The query skipped the first column of the index.
  • Because of that, PostgreSQL chose a Parallel Seq Scan.
  • This is a good example of why the order of columns in a composite index matters.
๐Ÿง  Simple takeaway

A composite index works best when the query starts with the leftmost column.
If the first column is skipped, PostgreSQL usually cannot use the index efficiently.

# Drop the composite index
psql -p 5432 -U postgres -d creditcards -c "
DROP INDEX consumer.idx_customers_state_city;
"

๐Ÿน 6.4 Partial Index

๐ŸŽฏ Goal
  • ๐Ÿ”จ Create an index on only a subset of rows
  • โ–ถ๏ธ Rerun a filtered query
  • ๐Ÿ“Š Observe the execution plan
  • โšก Understand why smaller indexes can be faster
๐Ÿ“Œ Why Partial Indexes?
  • ๐ŸŽฏ Index only the rows that matter
  • ๐Ÿ’พ Reduce index size
  • โšก Faster index scans
  • ๐Ÿ”ง Lower maintenance overhead

๐Ÿ’ป Create a Partial Index

# Create a partial index
psql -p 5432 -U postgres -d creditcards -c "
CREATE INDEX idx_customers_state1
ON consumer.customers(customer_id)
WHERE state = 'State_1';
"

๐ŸŽฏ Lab Takeaway
We created the index on the customer_id column, but only for rows where state = 'State_1'. PostgreSQL excludes all other states from the index, making the index smaller and more focused than a regular index.

Display the execution plan
# Display the execution plan
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id
FROM consumer.customers
WHERE state = 'State_1';
"
 Index Only Scan using idx_customers_state1 on customers  (cost=0.29..1322.96 rows=51667 width=8) (actual time=0.058..13.603 rows=50000 loops=1)
Heap Fetches: 0
Buffers: shared hit=139
Planning:
Buffers: shared hit=80
Planning Time: 0.739 ms
Execution Time: 19.712 ms
(7 rows)
๐Ÿง  Simple takeaway

A partial index is useful when you often query only a specific subset of rows. In this case, PostgreSQL used a smaller index and returned the results without touching the table heap.


๐Ÿน 6.5 Expression Index

๐ŸŽฏ Goal
  • ๐Ÿ”จ Create an index on an expression
  • ๐Ÿ” Execute a query using the same expression
  • ๐Ÿ“Š Observe the execution plan
  • โšก Understand how PostgreSQL can index computed values
๐Ÿ“Œ Why Expression Indexes?
  • ๐Ÿ” Some queries apply functions to columns
  • ๐Ÿ“– Normal indexes may not help when a function is used
  • โšก An expression index stores the computed result
  • ๐ŸŽฏ PostgreSQL can use the index instead of evaluating every row

๐Ÿ’ป Create an Expression Index

# Create an expression index
psql -p 5432 -U postgres -d creditcards -c "
CREATE INDEX idx_customers_email_lower
ON consumer.customers (LOWER(email));
"

๐Ÿ“‹ Review the Execution Plan

# Display the execution plan
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id
FROM consumer.customers
WHERE LOWER(email) = 'customer100@example.com';
"
 Bitmap Heap Scan on customers  (cost=163.18..10896.21 rows=5000 width=8) (actual time=0.158..0.160 rows=1 loops=1)
   Recheck Cond: (lower((email)::text) = 'customer100@example.com'::text)
   Heap Blocks: exact=1
   Buffers: shared read=4
   ->  Bitmap Index Scan on idx_customers_email_lower  (cost=0.00..161.93 rows=5000 width=0) (actual time=0.115..0.115 rows=1 loops=1)
         Index Cond: (lower((email)::text) = 'customer100@example.com'::text)
         Buffers: shared read=3
 Planning:
   Buffers: shared hit=91 read=1
 Planning Time: 3.628 ms
 Execution Time: 0.922 ms
(11 rows)
  • PostgreSQL used the expression index idx_customers_email_lower.
  • The query filtered on LOWER(email), which matches the indexed expression.
  • Only a few pages were read to locate the matching row.
  • The query completed in less than 1 millisecond, demonstrating the benefit of expression indexes for function-based searches.

๐Ÿ’ป Drop the Expression Index

# Remove the expression index
psql -p 5432 -U postgres -d creditcards -c "
DROP INDEX consumer.idx_customers_email_lower;
"
# Create a regular index on email
psql -p 5432 -U postgres -d creditcards -c "
CREATE INDEX idx_customers_email
ON consumer.customers(email);
"

๐Ÿ’ป Rerun the Same Query

# Execute the same query after dropping the expression index
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id
FROM consumer.customers
WHERE LOWER(email) = 'customer100@example.com';
"
 Gather  (cost=1000.00..24138.00 rows=5000 width=8) (actual time=2.970..533.570 rows=1 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=16051 read=337
-> Parallel Seq Scan on customers (cost=0.00..22638.00 rows=2083 width=8) (actual time=333.827..502.024 rows=0 loops=3)
Filter: (lower((email)::text) = 'customer100@example.com'::text)
Rows Removed by Filter: 333333
Buffers: shared hit=16051 read=337
Planning:
Buffers: shared hit=90 read=1
Planning Time: 1.878 ms
Execution Time: 534.447 ms
(12 rows)

๐Ÿ”ฅ PostgreSQL matches the query expression to the indexed expression. A regular index on email cannot replace an expression index on LOWER(email).

# Remove the index
psql -p 5432 -U postgres -d creditcards -c "
DROP INDEX consumer.idx_customers_email;
"

๐Ÿน 6.6 Covering Index

๐ŸŽฏ Goal
  • ๐Ÿ”จ Create an index with INCLUDE
  • โšก Show an Index Only Scan
  • ๐Ÿ“‰ Reduce heap access
  • ๐Ÿ“Š Improve read performance
๐Ÿ“Œ Why Covering Indexes?
  • ๐ŸŽฏ They store the filter columns plus the columns needed by the query
  • ๐Ÿ” PostgreSQL can satisfy the query from the index itself
  • ๐Ÿ’พ Less heap access means less work
  • ๐Ÿš€ Faster reads for common lookup queries

๐Ÿ’ป Create the Covering Index

# Create a covering index with INCLUDE
psql -p 5432 -U postgres -d creditcards -c "
CREATE INDEX idx_customers_state_city_cover
ON consumer.customers(state, city)
INCLUDE (customer_id, first_name, last_name);
"

๐Ÿ’ป Run the Query

# Query matches the index and only needs included columns
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT
    customer_id,
    first_name,
    last_name
FROM consumer.customers
WHERE state = 'State_1'
  AND city = 'City_1';
"
 Index Only Scan using idx_customers_state_city_cover on customers  (cost=0.42..34.92 rows=525 width=38) (actual time=0.083..4.104 rows=10000 loops=1)
Index Cond: ((state = 'State_1'::text) AND (city = 'City_1'::text))
Heap Fetches: 0
Buffers: shared hit=1 read=96
Planning:
Buffers: shared hit=116 read=7
Planning Time: 0.951 ms
Execution Time: 4.603 ms
(8 rows)
๐ŸŽฏ Takeaway
  • ๐Ÿ”จ A covering index stores the search columns plus extra needed columns
  • ๐Ÿน PostgreSQL can return results without touching the heap
  • ๐Ÿ’พ Heap Fetches: 0 is the proof
  • โšก This is why covering indexes are great for read-heavy queries
# Remove the covering index
psql -p 5432 -U postgres -d creditcards -c "
DROP INDEX consumer.idx_customers_state_city_cover;
"

๐Ÿ“š PostgreSQL Index Selection Guide

Index TypeBest Used ForExample
๐ŸŽฏ B-Tree (Default)Equality and range searchesWHERE customer_id = 100
๐ŸŽฏ Composite IndexMultiple filter columnsWHERE state='CA' AND city='LA'
๐ŸŽฏ Partial IndexSubset of rowsWHERE status='ACTIVE'
๐ŸŽฏ Expression IndexFunction-based searchesWHERE LOWER(email)=...
๐ŸŽฏ Covering IndexIndex-only scansINCLUDE (...)
๐Ÿ” GINArrays, JSONB, Full-Text SearchWHERE tags @> ARRAY['postgres']
๐ŸŒ GiSTGeospatial and range dataPostGIS, date ranges
๐Ÿš€ SP-GiSTHierarchical and partitioned search structuresIP ranges, spatial data
๐Ÿ“ BRINVery large tables with ordered dataTimestamp-based tables
๐Ÿ”’ HashEquality searches onlyWHERE customer_id = 100
๐Ÿง  When Should I Use Which Index?
ScenarioRecommended Index
๐Ÿ” Search by ID๐ŸŽฏ B-Tree
๐Ÿ“… Date ranges๐ŸŽฏ B-Tree
๐Ÿงฉ Multiple filters๐ŸŽฏ Composite
๐Ÿ“Œ Frequently queried subset๐ŸŽฏ Partial
๐Ÿ”ค Function-based predicates๐ŸŽฏ Expression
๐Ÿ“– Read-heavy lookups๐ŸŽฏ Covering
๐Ÿ“ฆ JSONB documents๐Ÿ” GIN
๐Ÿ“ Full-text search๐Ÿ” GIN
๐ŸŒ Geospatial data๐ŸŒ GiST
๐Ÿ•’ Huge time-series tables๐Ÿ“ BRIN
๐ŸŽฏ Rule of Thumb
QuestionIndex Type
One column?๐ŸŽฏ B-Tree
Multiple columns?๐ŸŽฏ Composite
Only some rows matter?๐ŸŽฏ Partial
Function in WHERE clause?๐ŸŽฏ Expression
Need Index Only Scan?๐ŸŽฏ Covering
JSONB / Arrays?๐Ÿ” GIN
Spatial data?๐ŸŒ GiST
Massive append-only table?๐Ÿ“ BRIN
๐Ÿ’ก Chapter Takeaway

PostgreSQL offers many index types, but most OLTP workloads are primarily optimized using B-Tree, Composite, Partial, Expression, and Covering indexes. Specialized indexes such as GIN, GiST, and BRIN are designed for specific data access patterns and should be chosen based on workload requirements.


๐Ÿ“‰ Chapter 7 โ€“ Statistics and Query Planner

๐Ÿ“‰ Chapter 7 โ€“ Statistics and Query Planner

๐Ÿ“Œ Goal

Understand:

  • ๐Ÿ“Š ANALYZE
  • ๐Ÿ“Š Statistics collection
  • ๐Ÿ“Š Cardinality estimates
  • ๐Ÿ“Š Bad plans caused by stale statistics
๐ŸŽฏ 7.1 Chapter Introduction
๐Ÿ“Œ Why Statistics Matter

PostgreSQL’s Query Planner is responsible for choosing the most efficient way to execute a query.

However, there is one important challenge:
๐Ÿง  PostgreSQL does not know exactly how many rows a query will return before it executes the query.

Instead, it makes an educated guess using statistics collected about the data stored in tables and indexes.

๐Ÿง  PostgreSQL Does Not Know the Future

When a query arrives, PostgreSQL must decide:

  • ๐ŸŽฏ Which table should be accessed first?
  • ๐Ÿ”— Which join method should be used?
  • ๐Ÿ“š Should an index be used?
  • ๐Ÿ“ฆ Should the table be scanned sequentially?
  • โšก Which execution plan will be the fastest?

Since the actual result is not yet known, PostgreSQL relies on estimates.

These estimates come from statistical information maintained for each table and column.

๐Ÿ“Š What Are Statistics?

Statistics are metadata that describe the distribution of data inside a table.
Examples include:

  • ๐Ÿ“ˆ Total number of rows
  • ๐Ÿ“Š Number of distinct values
  • ๐ŸŽฏ Most common values
  • ๐Ÿ“‰ Data distribution patterns
  • ๐Ÿ”ข Percentage of NULL values
  • ๐Ÿ“ฆ Average row width

PostgreSQL stores this information internally and uses it during query planning.

๐ŸŽฏ Why Row Estimates Matter

The planner’s most important task is estimating:
๐Ÿ“Š How many rows will be returned by each operation?

For example:

psql -p 5432 -U postgres -d creditcards -c "
SELECT
    *
FROM consumer.transactions
WHERE city = 'City_1' 
LIMIT 10;
"

Before executing the query, PostgreSQL estimates:

  • ๐Ÿ“ˆ How many customers live in City_1
  • ๐ŸŽฏ Whether an index would be beneficial
  • โšก Whether a sequential scan would be cheaper

These estimates directly influence the execution plan selected.

โšก Good Statistics Lead to Good Plans

When statistics accurately represent the data:

  • ๐ŸŽฏ Cardinality estimates are accurate
  • ๐Ÿ“š Appropriate indexes are chosen
  • ๐Ÿ”— Efficient join methods are selected
  • โšก Queries execute faster
  • ๐Ÿ“‰ Resource consumption is reduced

Result:

๐Ÿš€ Better performance and more predictable query execution.

๐Ÿ’ฅ Bad Statistics Lead to Bad Plans

When statistics become stale or inaccurate:

  • โŒ Row estimates become incorrect
  • โŒ Wrong join strategies may be chosen
  • โŒ Indexes may be ignored
  • โŒ Sequential scans may be selected unnecessarily
  • โŒ Query performance can degrade dramatically

Result:

๐Ÿข The planner may choose an execution plan that looks good on paper but performs poorly in reality.

๐Ÿน What You’ll Learn in This Chapter

By the end of this chapter, you’ll understand:

  • ๐Ÿ“Š How PostgreSQL collects statistics
  • ๐Ÿ” How ANALYZE works
  • ๐ŸŽฏ What cardinality estimation means
  • ๐Ÿ“ˆ How the planner predicts row counts
  • ๐Ÿ’ฅ How stale statistics cause bad plans
  • ๐Ÿ› ๏ธ How to identify and fix estimation problems
๐Ÿš€ Key Takeaway

๐Ÿง  PostgreSQL’s Query Planner is only as smart as the statistics it has available.

๐Ÿ”ฌ 7.2 Observe Planner Estimates

๐ŸŽฏ Goal

Understand how PostgreSQL predicts row counts by comparing:

  • ๐Ÿ“Š Estimated rows (rows=)
  • ๐Ÿ“ˆ Actual rows (actual rows=)
  • ๐Ÿง  Planner predictions
  • ๐ŸŽฏ Cardinality estimates
๐Ÿ“Œ Part 1 โ€” Happy Path (Good Statistics)

Let’s start with a healthy database.

# Refresh statistics
psql -p 5432 -U postgres -d creditcards -c "
ANALYZE consumer.transactions;
"
# Observe planner estimates
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM consumer.transactions
WHERE city = 'City_1';
"
 Gather  (cost=1000.00..194892.59 rows=96666 width=68) (actual time=0.994..62247.251 rows=100000 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=44 read=132099
-> Parallel Seq Scan on transactions (cost=0.00..184225.99 rows=40278 width=68) (actual time=1.050..61996.508 rows=33333 loops=3)
Filter: ((city)::text = 'City_1'::text)
Rows Removed by Filter: 3300000
Buffers: shared hit=44 read=132099
Planning:
Buffers: shared hit=116
Planning Time: 2.084 ms
Execution Time: 62395.420 ms
(12 rows)
๐Ÿ“Š Observe the Estimates
MetricValue
๐Ÿ“Š Estimated Rows96,666
๐Ÿ“ˆ Actual Rows100,000
๐Ÿ” Observation

PostgreSQL estimated that the query would return 96,666 rows, while the actual execution returned 100,000 rows. The estimate is very close to reality, indicating that the planner has accurate statistics for this data.

๐Ÿ’ฅ Part 2 โ€” Stale Statistics (Bad Estimates)
# Modify about 1,000,000 rows, just below Auto Analyze threshold
psql -p 5432 -U postgres -d creditcards -c "
UPDATE consumer.transactions
SET city = 'City_99'
WHERE transaction_id <= 11000000;
"
# Observe stale planner estimates
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM consumer.transactions
WHERE city = 'City_99';
"
 Gather  (cost=1000.00..214309.28 rows=104952 width=68) (actual time=974.038..6037.560 rows=1090000 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=516 read=144960 dirtied=26309 written=22567
-> Parallel Seq Scan on transactions (cost=0.00..202814.08 rows=43730 width=68) (actual time=636.810..5657.791 rows=363333 loops=3)
Filter: ((city)::text = 'City_99'::text)
Rows Removed by Filter: 2970000
Buffers: shared hit=516 read=144960 dirtied=26309 written=22567
Planning:
Buffers: shared hit=82 read=30
Planning Time: 4.020 ms
Execution Time: 6275.228 ms
(12 rows)
๐Ÿ“‹ Stale Stats: Record Table
Plan NodeEstimated RowsActual RowsWhat It Shows
๐Ÿ“ฆ Gather104,9521,090,000Final result returned by the query
๐Ÿ” Parallel Seq Scan on transactions43,730363,333 per workerEach worker scanned part of the table

โœ๏ธNote
After updating a large number of rows to City_99 without refreshing statistics, PostgreSQL still expected only about 105k rows, while the actual execution returned 1.09 million rows.

๐Ÿ’ฅ Part 3 โ€” Refresh Statistics
# Refresh statistics
psql -p 5432 -U postgres -d creditcards -c "
ANALYZE consumer.transactions;
"
# Rerun the same query post Stats gather
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM consumer.transactions
WHERE city = 'City_99';
"
 Seq Scan on transactions  (cost=0.00..270766.84 rows=1138309 width=67) (actual time=12.949..3041.343 rows=1090000 loops=1)
Filter: ((city)::text = 'City_99'::text)
Rows Removed by Filter: 8910000
Buffers: shared hit=9865 read=135611
Planning:
Buffers: shared hit=108 read=4
Planning Time: 4.034 ms
Execution Time: 3111.829 ms
(8 rows)

๐Ÿ” Observation
After running ANALYZE, PostgreSQL estimated 1,138,309 rows and returned 1,090,000 rows. The estimate became much more accurate because the planner now had up-to-date statistics.

๐Ÿ“ˆ Before vs After ANALYZE
ScenarioEstimated RowsActual RowsAccuracy
๐Ÿ˜€ Fresh Statistics96,666100,000Excellent
๐Ÿ’ฅ Stale Statistics104,9521,090,000Poor
๐Ÿ”ง After ANALYZE1,138,3091,090,000Excellent
๐Ÿš€ Key Takeaway

PostgreSQL does not know how many rows a query will return. It predicts the result using table statistics. When those statistics become stale, the planner can make poor estimates. Running ANALYZE refreshes the statistics and helps the planner make more accurate decisions.

๐Ÿ“Š 7.3 Understanding ANALYZE

๐ŸŽฏ Goal

Understand how PostgreSQL refreshes planner statistics.

Learn:

  • ๐Ÿ”จ How to run ANALYZE
  • ๐Ÿ“Š How statistics are refreshed
  • ๐Ÿ” How to verify statistics updates
  • โšก Why accurate statistics improve query planning

Before running ANALYZE, let’s inspect the current statistics available to the planner.

# Check last statistics collection time
psql -p 5432 -U postgres -d creditcards -c "
SELECT
    relname,
    last_analyze,
    last_autoanalyze
FROM pg_stat_user_tables
WHERE schemaname = 'consumer'
AND relname = 'customers';
"
# Collect fresh statistics
psql -p 5432 -U postgres -d creditcards -c "
ANALYZE consumer.customers;
"
# Check statistics collection time again
psql -p 5432 -U postgres -d creditcards -c "
SELECT
    relname,
    last_analyze,
    last_autoanalyze
FROM pg_stat_user_tables
WHERE schemaname = 'consumer'
AND relname = 'customers';
"
  relname  |         last_analyze          | last_autoanalyze
-----------+-------------------------------+------------------
customers | 2026-06-15 20:35:34.075644+00 |
(1 row)
๐Ÿ” Observation

The last_analyze timestamp was updated, confirming that PostgreSQL refreshed the planner statistics.

๐Ÿ“‹ 7.4 Inspect Collected Statistics

  • ๐Ÿ” Where statistics are stored
  • ๐Ÿ“Š Distinct value information
  • ๐Ÿ“ˆ Common value information
  • ๐Ÿง  How the planner uses these statistics

๐Ÿ“Œ View Collected Statistics

# Display statistics information
psql -p 5432 -U postgres -d creditcards -c "
SELECT
    attname,
    n_distinct,
    most_common_vals
FROM pg_stats
WHERE schemaname = 'consumer'
AND tablename = 'customers';
"
 created_date |          1 | {"2026-06-11 17:17:11.489875"}
customer_id | -1 |
first_name | -1 |
state | 20 | {State_18,State_0,State_19,State_10,State_12,State_7,State_11,State_14,State_4,State_13,State_17,State_16,State_15,State_2,State
_5,State_3,State_8,State_6,State_9,State_1}
last_name | -1 |
email | -1 |
phone_number | -1 |
city | 100 | {City_35,City_79,City_91,City_93,City_27,City_60,City_10,City_58,City_80,City_54,City_18,City_90,City_36,City_97,City_2,City_25,
City_48,City_67,City_59,City_84,City_24,City_32,City_41,City_42,City_38,City_73,City_3,City_44,City_57,City_82,City_98,City_66,City_72,City_1,City_34,City_63
,City_37,City_39,City_74,City_76,City_40,City_45,City_89,City_52,City_12,City_11,City_19,City_8,City_83,City_85,City_92,City_14,City_50,City_86,City_96,City_
70,City_0,City_51,City_88,City_20,City_15,City_29,City_33,City_69,City_71,City_47,City_6,City_7,City_26,City_4,City_17,City_78,City_53,City_56,City_95,City_2
2,City_31,City_9,City_21,City_5,City_87,City_23,City_68,City_43,City_16,City_46,City_55,City_94,City_28,City_30,City_61,City_49,City_64,City_65,City_77,City_
13,City_81,City_75,City_99,City_62}
๐Ÿ” What Do These Statistics Tell Us?
ColumnStatistic ObservedWhat PostgreSQL Learned
๐Ÿ“… created_daten_distinct = 1All rows have the same timestamp value
๐Ÿ†” customer_idn_distinct = -1Almost every row has a unique value
๐Ÿ‘ค first_namen_distinct = -1Most values are unique
๐Ÿ‘ค last_namen_distinct = -1Most values are unique
๐Ÿ“ง emailn_distinct = -1Almost every email is unique
๐Ÿ“ž phone_numbern_distinct = -1Almost every phone number is unique
๐Ÿ™๏ธ cityn_distinct = 100100 different cities exist in the table
๐Ÿ—บ๏ธ staten_distinct = 2020 different states exist in the table
๐Ÿ“Š Understanding n_distinct
ValueMeaning
๐Ÿ“Š Positive NumberEstimated number of distinct values
๐Ÿ“ˆ -1Nearly every row contains a unique value
๐Ÿ“‰ Small NumberMany rows share the same values
๐Ÿ” Observation

PostgreSQL estimates that the customers table contains 20 distinct states and 100 distinct cities. For columns such as email, phone_number, and customer_id, almost every row contains a unique value.

๐Ÿš€ Key Takeaway

pg_stats contains statistical information collected by ANALYZE. PostgreSQL uses these statistics to estimate row counts and choose efficient execution plans.

๐ŸŽฏ Chapter Takeaway
  • ๐Ÿง  PostgreSQL relies on statistics, not guesses
  • ๐Ÿ“Š Cardinality estimates drive plan selection
  • ๐Ÿšจ Bad statistics can produce bad plans
  • ๐Ÿ”„ ANALYZE helps the planner make better decisions

๐Ÿง  Chapter 8 โ€“ Memory Tuning for Queries

๐Ÿง  Chapter 8 โ€“ Memory Tuning for Queries

๐Ÿ“Œ Goal

Understand how PostgreSQL uses memory during query execution and how memory settings influence query performance.

  • ๐Ÿง  work_mem
  • ๐Ÿง  shared_buffers
  • ๐Ÿง  maintenance_work_mem
  • ๐Ÿง  effective_cache_size

๐Ÿ’ก Not all slow queries are CPU problems or indexing problems. Many performance issues occur because PostgreSQL runs out of working memory and starts using disk instead.

๐ŸŽฏ 8.1 Chapter Introduction

  • ๐Ÿง  PostgreSQL uses memory to reduce expensive disk I/O.
  • ๐Ÿ“Š Sort operations require memory for ordering data.
  • ๐Ÿ“Š Hash operations require memory for joins and aggregations.
  • ๐Ÿ“ Insufficient memory causes temporary files and disk spills.
  • โšก Proper memory tuning can dramatically improve query performance.
  • ๐ŸŽฏ Different memory parameters serve different purposes and workloads.

๐Ÿ” 8.2 Inspect Current Memory Configuration

View the current PostgreSQL memory configuration and understand the purpose of the most important memory parameters.

# Display important memory settings
psql -p 5432 -U postgres -d creditcards -x -c "
SELECT 
    current_setting('work_mem') AS work_mem,
    current_setting('shared_buffers') AS shared_buffers,
    current_setting('maintenance_work_mem') AS maintenance_work_mem,
    current_setting('effective_cache_size') AS effective_cache_size;
"
-[ RECORD 1 ]--------+------
work_mem | 4MB
shared_buffers | 128MB
maintenance_work_mem | 64MB
effective_cache_size | 4GB
ParameterPurpose
๐Ÿง  work_memMemory for sorts, hashes, and aggregations
๐Ÿง  shared_buffersPostgreSQL’s data cache
๐Ÿง  maintenance_work_memMemory for maintenance operations
๐Ÿง  effective_cache_sizePlanner estimate of available cache

๐Ÿง  8.3 Understanding work_mem

๐ŸŽฏ Goal

Understand how PostgreSQL uses work_mem during query execution and why it is one of the most important performance tuning parameters.

๐Ÿ“– What is work_mem?

work_mem defines the amount of memory PostgreSQL can use for certain query operations before it starts writing temporary data to disk.

When an operation requires more memory than the configured work_mem, PostgreSQL creates temporary files and performs the operation using disk storage, which is significantly slower than memory.

๐Ÿ” Where is work_mem Used?

PostgreSQL uses work_mem for several execution plan operations:

OperationDescription
๐Ÿ“Š SortORDER BY, DISTINCT, Merge Join sorting
๐Ÿ”— Hash JoinBuilding hash tables for joins
๐Ÿ“ฆ Hash AggregateGROUP BY and aggregation using hashing
๐Ÿ“‹ MaterializeStoring intermediate query results
โš ๏ธ Important Note

work_mem is allocated per operation, not per query.

A single query may contain multiple sort and hash operations, causing PostgreSQL to use several times the configured work_mem.

For example:

  • ๐Ÿ“Š Sort #1 โ†’ 64 MB
  • ๐Ÿ“Š Sort #2 โ†’ 64 MB
  • ๐Ÿ”— Hash Join โ†’ 64 MB

Total memory consumption:

64 MB + 64 MB + 64 MB = 192 MB
๐Ÿ’ก Key Takeaways
  • ๐Ÿง  work_mem controls memory used by query execution operators.
  • ๐Ÿ“Š Sorts and hashes are the most common consumers.
  • ๐Ÿ“ Exceeding work_mem causes temporary files and disk spills.
  • โšก Increasing work_mem can significantly improve query performance.
  • โš ๏ธ work_mem is allocated per operation, not per query.

๐Ÿ’ฅ 8.4 Sort Spill Demo

๐ŸŽฏ Goal

When PostgreSQL cannot perform a sort entirely in memory, it writes intermediate data to temporary files on disk. This process is known as a sort spill.

๐Ÿ› ๏ธ Step 1 โ€” Force a Small work_mem

Configure a very small memory limit for the current session:

# Set a small work_mem value of 1 MB
psql -p 5432 -U postgres -d creditcards -c "
SET work_mem = '1MB';

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM consumer.transactions
ORDER BY transaction_amount;
"
 Gather Merge  (cost=1332698.19..2307250.47 rows=8352722 width=67) (actual time=13646.532..23286.146 rows=10000000 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=9969 read=135579, temp read=410553 written=411843
-> Sort (cost=1331698.17..1342139.07 rows=4176361 width=67) (actual time=12969.361..14500.438 rows=3333333 loops=3)
Sort Key: transaction_amount
Sort Method: external merge Disk: 275392kB
Buffers: shared hit=9969 read=135579, temp read=410553 written=411843
Worker 0: Sort Method: external merge Disk: 275904kB
Worker 1: Sort Method: external merge Disk: 269840kB
-> Parallel Seq Scan on transactions (cost=0.00..187239.61 rows=4176361 width=67) (actual time=15.672..649.991 rows=3333333 loops=3)
Buffers: shared hit=9897 read=135579
Planning:
Buffers: shared hit=134
Planning Time: 1.910 ms
Execution Time: 24080.528 ms
(16 rows)
ObservationMeaning
Workers Planned: 2The planner expected parallel execution.
Workers Launched: 2Both workers actually ran.
Sort Method: external mergeThis is the key sign of a spill.
Disk: 275392kBLarge temporary disk usage.
temp read/writtenTemporary files were actively used.
Execution Time: 24080 msThe spill made the query much slower.
๐ŸŽฏ Observation

The sort exceeded the available work_mem, causing PostgreSQL to perform an external merge sort and use temporary files on disk. This additional disk I/O increased query execution time.

โšก 8.5 Fix Sort Spill

๐ŸŽฏ Goal

Increase work_mem and show how PostgreSQL can complete the sort in memory instead of spilling to disk.

# Increase work_mem to 2048 MB
psql -p 5432 -U postgres -d creditcards -c "
SET work_mem = '2GB';

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM consumer.transactions
ORDER BY transaction_amount;
"
 Sort  (cost=1411256.73..1436314.90 rows=10023267 width=67) (actual time=8916.448..11437.614 rows=10000000 loops=1)
Sort Key: transaction_amount
Sort Method: quicksort Memory: 1405324kB
Buffers: shared hit=10220 read=135259
-> Seq Scan on transactions (cost=0.00..245708.67 rows=10023267 width=67) (actual time=0.029..1122.591 rows=10000000 loops=1)
Buffers: shared hit=10217 read=135259
Planning:
Buffers: shared hit=134
Planning Time: 1.304 ms
Execution Time: 12110.279 ms
(10 rows)
What changed at work_mem = 2GB
FieldMeaning
Sort Method: quicksortThe sort stayed in memory
Memory: 1405324kBPostgreSQL used about 1.4 GB of memory for the sort
No Disk: lineNo temporary file spill occurred

Higher work_mem reduced disk I/O and improved performance by allowing the sort to finish in memory.

ScenarioSort MethodResult
๐Ÿ’ฅ Low work_memExternal MergeSpilled to disk
โšก 2 GB work_memQuicksortCompleted in memory

๐Ÿ”— 8.6 Hash Spill Demo

๐ŸŽฏ Goal

When a hash table becomes too large to fit in memory, PostgreSQL partitions the data into batches and writes portions of the hash table to temporary files on disk.

Force a Small work_mem1 MB
Configure a very small memory limit for the current session:

# Demonstrate a hash spill
psql -p 5432 -U postgres -d creditcards -c "
SET work_mem = '1MB';

EXPLAIN (ANALYZE, BUFFERS)
SELECT
    t.transaction_id,
    c.customer_id
FROM consumer.transactions t
JOIN consumer.credit_cards cc
    ON t.card_id = cc.card_id
JOIN consumer.customers c
    ON cc.customer_id = c.customer_id;
"
 Hash Join  (cost=81398.43..584287.47 rows=10023267 width=16) (actual time=1213.210..12360.844 rows=10000000 loops=1)
Hash Cond: (cc.customer_id = c.customer_id)
Buffers: shared hit=8883 read=150957, temp read=92758 written=92758
-> Hash Join (cost=39011.00..413797.86 rows=10023267 width=16) (actual time=691.086..7950.914 rows=10000000 loops=1)
Hash Cond: (t.card_id = cc.card_id)
Buffers: shared hit=8878 read=148226, temp read=46852 written=46852
-> Seq Scan on transactions t (cost=0.00..245708.67 rows=10023267 width=16) (actual time=0.020..2162.373 rows=10000000 loops=1)
Buffers: shared hit=8878 read=136598
-> Hash (cost=21628.00..21628.00 rows=1000000 width=16) (actual time=690.748..690.750 rows=1000000 loops=1)
Buckets: 65536 Batches: 32 Memory Usage: 1982kB
Buffers: shared read=11628, temp written=4240
-> Seq Scan on credit_cards cc (cost=0.00..21628.00 rows=1000000 width=16) (actual time=1.230..443.499 rows=1000000 loops=1)
Buffers: shared read=11628
-> Hash (cost=25980.42..25980.42 rows=1000000 width=8) (actual time=521.603..521.603 rows=1000000 loops=1)
Buckets: 65536 Batches: 32 Memory Usage: 1737kB
Buffers: shared hit=5 read=2731, temp written=3294
-> Index Only Scan using customers_pkey on customers c (cost=0.42..25980.42 rows=1000000 width=8) (actual time=0.038..246.394 rows=1000000 loops=1
)
Heap Fetches: 0
Buffers: shared hit=5 read=2731
Planning:
Buffers: shared hit=289 read=18
Planning Time: 21.593 ms
Execution Time: 12793.252 ms
(23 rows)

๐Ÿ” Hash Spill Demo โ€” Important Observations

ItemMeaning
HashPostgreSQL built a hash table for the join
Buckets: 65536Hash table bucket count
Batches: 32The hash table did not fit in memory and was split into 32 batches
Memory Usage: 1982kBVery small in-memory hash space was available
temp written=4240Temporary files were written while building the hash
temp read=92758 written=92758Confirmed heavy temporary file activity during execution

๐Ÿ“ŒPostgreSQL could not keep the hash tables in memory, so it divided the work into batches and spilled to temporary files on disk.

โšก 8.7 Fix Hash Spill

๐ŸŽฏ Goal

Increase work_mem and let us see how PostgreSQL can keep the hash table in memory instead of spilling to disk.

# Increase work_mem to 256 MB
psql -p 5432 -U postgres -d creditcards -c "
SET work_mem = '256MB';

EXPLAIN (ANALYZE, BUFFERS)
SELECT
    t.transaction_id,
    c.customer_id
FROM consumer.transactions t
JOIN consumer.credit_cards cc
    ON t.card_id = cc.card_id
JOIN consumer.customers c
    ON cc.customer_id = c.customer_id;
"
 Hash Join  (cost=72608.43..370939.47 rows=10023267 width=16) (actual time=2204.831..26101.138 rows=10000000 loops=1)
   Hash Cond: (cc.customer_id = c.customer_id)
   Buffers: shared hit=11679 read=148161
   ->  Hash Join  (cost=34128.00..306147.86 rows=10023267 width=16) (actual time=1175.696..15197.240 rows=10000000 loops=1)
         Hash Cond: (t.card_id = cc.card_id)
         Buffers: shared hit=8943 read=148161
         ->  Seq Scan on transactions t  (cost=0.00..245708.67 rows=10023267 width=16) (actual time=0.047..3254.143 rows=10000000 loops=1)
               Buffers: shared hit=8911 read=136565
         ->  Hash  (cost=21628.00..21628.00 rows=1000000 width=16) (actual time=1171.952..1171.955 rows=1000000 loops=1)
               Buckets: 1048576  Batches: 1  Memory Usage: 55067kB
               Buffers: shared hit=32 read=11596
               ->  Seq Scan on credit_cards cc  (cost=0.00..21628.00 rows=1000000 width=16) (actual time=2.788..465.506 rows=1000000 loops=1)
                     Buffers: shared hit=32 read=11596
   ->  Hash  (cost=25980.42..25980.42 rows=1000000 width=8) (actual time=1004.768..1004.769 rows=1000000 loops=1)
         Buckets: 1048576  Batches: 1  Memory Usage: 47255kB
         Buffers: shared hit=2736
         ->  Index Only Scan using customers_pkey on customers c  (cost=0.42..25980.42 rows=1000000 width=8) (actual time=0.095..388.954 rows=1000000 loops=1
)
               Heap Fetches: 0
               Buffers: shared hit=2736
 Planning:
   Buffers: shared hit=307
 Planning Time: 6.337 ms
 Execution Time: 27160.224 ms
(23 rows)
๐Ÿ” Hash Spill Fix โ€” Plan Analysis
ItemMeaning
Batches: 1The hash table fit in memory
Memory Usage: 55067kBAbout 55 MB was used for the hash build
No temp read / temp writtenNo temporary file spill occurred
Buckets: 1048576PostgreSQL allocated a larger hash table structure
work_mem = 256MBEnough memory was available for the hash joins
๐Ÿ’ก Observation

Increasing work_mem allowed PostgreSQL to build the hash table in memory, reducing or eliminating temporary file usage and improving performance.

โœ… Key Point
  • Multiple batches means the hash did not fit in memory.
  • Batches: 1 means the hash stayed in memory.
  • No temp I/O means the spill was fixed.

๐Ÿ“ 8.8 Temporary Files

๐ŸŽฏ Goal

Observe how much temporary file activity PostgreSQL has generated for the current database.

# Display temp file statistics
psql -p 5432 -U postgres -d creditcards -c "
SELECT
    datname,
    temp_files,
    ROUND(temp_bytes / 1024.0 / 1024.0 / 1024.0, 2) AS temp_gb
FROM pg_stat_database
WHERE datname = 'creditcards';
"
   datname   | temp_files | temp_gb
-------------+------------+---------
 creditcards |        270 |    4.75
(1 row)

๐ŸŽฏ PostgreSQL has created 270 temporary files and written approximately 4.75 GB of temporary data, indicating significant disk spill activity from operations such as sorts and hash joins.

๐Ÿ“Š Observation
ColumnMeaning
๐Ÿ“ temp_filesNumber of temporary files created
๐Ÿ’พ temp_bytesTotal size of temporary data written
๐Ÿ’ก Key Point

These values help you confirm that PostgreSQL has been using temporary disk space for operations such as sort spills and hash spills.

๐Ÿง  8.9 Understanding shared_buffers

๐ŸŽฏ Goal

Understand how PostgreSQL caches table and index pages in memory using shared_buffers.

# Display the current setting
psql -p 5432 -U postgres -d creditcards -c "
SHOW shared_buffers;
"
# look for  shared hit and read
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM consumer.payments;
"
 Seq Scan on payments  (cost=0.00..55000.00 rows=3000000 width=34) (actual time=2.798..2420.512 rows=3000000 loops=1)
Buffers: shared read=25000
Planning:
Buffers: shared hit=61 read=2
Planning Time: 6.671 ms
Execution Time: 2798.533 ms
(6 rows)
๐Ÿ“– What is shared_buffers?

shared_buffers is PostgreSQL’s primary memory cache for table and index pages.

When PostgreSQL reads data from disk, the pages are stored in shared_buffers so they can be reused by future queries.

๐Ÿ” Why is shared_buffers Important?
  • ๐Ÿ“ฆ Stores table and index pages in memory
  • ๐Ÿ“Š Keeps frequently accessed data readily available
  • โšก Reduces physical disk reads
  • ๐Ÿ’พ Improves overall query performance
๐ŸŽฏ Key Takeaways
  • ๐Ÿ“ฆ shared_buffers is PostgreSQL’s internal cache.
  • ๐Ÿ“Š Frequently accessed pages can remain in memory.
  • โšก Cached pages are faster to access than disk pages.
  • ๐Ÿ’พ Proper sizing helps reduce I/O activity.

๐Ÿง  8.10 Understanding maintenance_work_mem

๐ŸŽฏ Goal

Understand how PostgreSQL uses maintenance_work_mem for maintenance operations.

# Check maintenance_work_mem
psql -p 5432 -U postgres -d creditcards -c "
SHOW maintenance_work_mem;
"
 maintenance_work_mem
----------------------
64MB
๐Ÿ“– What is maintenance_work_mem?

maintenance_work_mem defines the amount of memory PostgreSQL can use for maintenance tasks.
Unlike work_mem, this setting is not used by normal query operations such as sorts and hash joins.

๐Ÿ” Where is maintenance_work_mem Used?
OperationPurpose
๐Ÿ”จ VACUUMCleanup of dead tuples
๐Ÿ”จ CREATE INDEXBuilding new indexes
๐Ÿ”จ REINDEXRebuilding existing indexes
๐Ÿ”จ ALTER TABLECertain table maintenance operations
โšก Why Is It Important?
  • ๐Ÿ”จ More memory can speed up index creation.
  • ๐Ÿ”จ More memory can improve VACUUM efficiency.
  • ๐Ÿ“ Reduces temporary file usage during maintenance tasks.
  • โšก Can shorten maintenance windows.

๐Ÿง  8.11 Understanding effective_cache_size

๐ŸŽฏ Goal

Understand how effective_cache_size influences PostgreSQL planner decisions.

# Check effective_cache_size
psql -p 5432 -U postgres -d creditcards -c "
SHOW effective_cache_size;
"
 effective_cache_size
----------------------
4GB
(1 row)
๐Ÿ“– What is effective_cache_size?

effective_cache_size is the planner’s estimate of how much data can be cached in memory.

Unlike shared_buffers, it does not reserve or allocate memory.

๐Ÿ” Why is it Important?
  • ๐Ÿ“Š Helps the planner estimate available cache
  • ๐ŸŽฏ Influences query cost calculations
  • โšก Can affect index scan versus sequential scan decisions
๐ŸŽฏ Key Takeaways
  • ๐Ÿ“Š Used by the query planner
  • ๐Ÿšซ Does not allocate memory
  • ๐ŸŽฏ Influences plan selection and cost estimates
  • โšก Should reflect the amount of memory available for caching
๐Ÿ’ก Remember

effective_cache_size is a planner hint, not an actual memory allocation.

๐Ÿ 8.12 Chapter Summary

In this chapter, you explored how PostgreSQL uses memory during query execution and how memory settings influence performance.

๐Ÿ“Š Memory Parameters Overview
ParameterPrimary Use
๐Ÿง  work_memSorts, hash joins, and aggregations
๐Ÿ“ฆ shared_buffersPostgreSQL data cache
๐Ÿ”จ maintenance_work_memVACUUM, CREATE INDEX, REINDEX
๐Ÿ“Š effective_cache_sizePlanner cache estimates
  • ๐Ÿง  work_mem controls memory used by query operators.
  • ๐Ÿ“Š Insufficient memory can cause sort spills and hash spills.
  • ๐Ÿ“ Temporary files indicate that PostgreSQL is using disk instead of memory.
  • ๐Ÿ“ฆ shared_buffers caches table and index pages.
  • ๐Ÿ”จ maintenance_work_mem supports maintenance operations.
  • ๐Ÿ“Š effective_cache_size helps the planner estimate cache availability.
๐Ÿ’ก Key Takeaway

Memory tuning is primarily about preventing unnecessary disk I/O.

When PostgreSQL cannot keep data structures in memory, it creates temporary files and performs additional disk operations. These spills often increase query execution time and can become a significant performance bottleneck.

Common warning signs include:

  • ๐Ÿ“ Temporary file creation
  • ๐Ÿ’ฅ Sort spills
  • ๐Ÿ”— Hash spills
  • ๐Ÿข Unexpected query slowdowns

๐Ÿ”’ Chapter 9 โ€“ Locking, Blocking and Wait Events

๐Ÿ”’ Chapter 9 โ€“ Locking, Blocking and Wait Events

๐Ÿ“Œ Goal

Understand how PostgreSQL coordinates concurrent access to data and how to diagnose locking problems.

  • ๐Ÿ” Locks and lock types
  • ๐Ÿ” Blocking sessions
  • ๐Ÿ” Lock waits
  • ๐Ÿ’€ Deadlocks
  • ๐Ÿ‘€ Wait events
  • ๐Ÿ”Ž Monitoring active sessions

๐ŸŽฏ 9.1 Chapter Introduction

๐Ÿ“Œ Why Locks Matter
  • ๐Ÿ” PostgreSQL uses locks to protect data consistency
  • ๐Ÿ‘ฅ Multiple sessions can access the same tables simultaneously
  • โณ Conflicting operations may wait
  • ๐Ÿ’€ Poor concurrency can lead to deadlocks
  • ๐Ÿ”Ž PostgreSQL provides tools to diagnose lock issues

๐Ÿ” 9.2 Inspect Active Sessions

๐ŸŽฏ Goal
# Display active PostgreSQL sessions
psql -p 5432 -U postgres -d creditcards -c "
SELECT
    pid,
    usename,
    state,
    query
FROM pg_stat_activity;
"
 pid  | usename  | state  |            query
------+----------+--------+-----------------------------
5030 | | |
5031 | postgres | |
6585 | postgres | active | +
| | | SELECT +
| | | pid, +
| | | usename, +
| | | state, +
| | | query +
| | | FROM pg_stat_activity; +
Understanding pg_stat_activity

The pg_stat_activity view displays information about every session currently connected to PostgreSQL. It is one of the most important monitoring views because it shows who is connected, what they are doing, and whether they are actively running queries.

When troubleshooting performance issues, lock waits, or blocking sessions, this is usually the first place a DBA looks.

Key Columns
ColumnDescription
pidUnique process ID for the session
usenameConnected PostgreSQL user
stateCurrent session state
queryCurrent or most recent SQL statement
Session States
StateMeaning
activeCurrently executing a query
idleConnected but not running a query
idle in transactionTransaction is open but not doing work
What to Observe
  • โ€ข ๐Ÿ‘ค Every session has a unique PID
  • โ€ข ๐Ÿƒ Active sessions are currently executing work
  • โ€ข ๐Ÿ˜ด Idle sessions are simply waiting for the next command
  • โ€ข โš ๏ธ Idle transactions can be dangerous because they may continue holding locks
  • โ€ข ๐Ÿ”Ž The query column helps identify what each session is doing
Why This Matters

๐Ÿ”Ž Before investigating blocking sessions, lock waits, or deadlocks, you first need to identify the active sessions involved. The pg_stat_activity view provides that visibility.

๐Ÿ” 9.3 Understanding PostgreSQL Locks

PostgreSQL uses locks to ensure that multiple sessions can work with the same data safely and consistently. Most locks are acquired automatically and released when a transaction completes.

Lock TypePurpose
๐Ÿ“ Row LocksProtect individual rows being modified
๐Ÿ—„๏ธ Table LocksProtect entire tables during certain operations
๐Ÿ”„ Transaction LocksCoordinate transaction activity and visibility
๐Ÿ“ Row Locks

Row locks are acquired when a transaction modifies data using operations such as:

  • โœ๏ธ UPDATE
  • โŒ DELETE
  • ๐Ÿ”’ SELECT … FOR UPDATE

A row lock prevents other transactions from modifying the same row until the current transaction completes.

๐Ÿ” Row locks allow multiple users to work on the same table while protecting individual rows from conflicting changes.

๐Ÿ—„๏ธ Table Locks

PostgreSQL automatically acquires table-level locks during many operations, including:

  • ๐Ÿ“– SELECT
  • โž• INSERT
  • โœ๏ธ UPDATE
  • โŒ DELETE
  • ๐Ÿ› ๏ธ ALTER TABLE
  • ๐Ÿงน VACUUM

Most table locks allow concurrent access, but some operations require stronger locks that can temporarily block other sessions.

๐Ÿ” Table locks help PostgreSQL coordinate operations that affect an entire table.

๐Ÿ”„ Transaction Locks

Every transaction acquires internal locks that help PostgreSQL maintain consistency between concurrent sessions.

These locks remain active until the transaction is:

  • โœ… COMMITTED
  • โ†ฉ๏ธ ROLLED BACK

๐Ÿ” Locks are typically held for the duration of a transaction and released when the transaction ends.

๐Ÿšซ 9.4 Blocking Session Demo

๐ŸŽฏ Goal

Create a blocking scenario and observe how PostgreSQL handles conflicting updates.

Session A

Open a new terminal and start a transaction.

# Session A: Login to the database
psql -p 5432 -U postgres -d creditcards
--Session A: Acquire a row lock and keep it open
BEGIN;

UPDATE consumer.customers
SET state = 'Blocked'
WHERE customer_id = 100;

Do not commit the transaction.

Session B

Open a second terminal and attempt to update the same row.

# Session B: Login to the database
psql -p 5432 -U postgres -d creditcards
-- Session B: Attempt to update the same row
UPDATE consumer.customers
SET state = 'Waiting'
WHERE customer_id = 100;
Observe

The second session does not return immediately.

Instead, the statement appears to hang while waiting for the lock held by Session A.

โณ Session B is waiting for Session A to release the lock before it can continue.

What Happened?

SessionStatus
๐Ÿ” Session AOwns the row lock
โณ Session BWaiting for the lock
๐Ÿ“ Row 100Locked until Session A completes
Why This Happens

When Session A updates the row, PostgreSQL acquires a row lock on that record.
When Session B attempts to update the same row, PostgreSQL prevents the conflicting modification and places Session B into a wait state.
๐Ÿ” PostgreSQL protects data consistency by allowing only one transaction to modify a row at a time.
๐Ÿ’ก Most blocking problems in production are caused by transactions that hold locks longer than necessary.

โณ Leave both sessions running. We will use this blocking scenario in the next lab to investigate lock waits.

๐Ÿ‘€ 9.5 Detect Blocking with pg_stat_activity

๐ŸŽฏ Goal

Identify sessions that are currently waiting and determine the reason for the wait.
While Session B is still blocked from the previous lab, run in Session A:

-- Display sessions that are currently waiting
SELECT
    pid,
    wait_event_type,
    wait_event,
    query
FROM pg_stat_activity
WHERE wait_event IS NOT NULL;
 pid  | wait_event_type |     wait_event      |            query
------+-----------------+---------------------+-----------------------------
5030 | Activity | AutoVacuumMain |
5031 | Activity | LogicalLauncherMain |
6401 | Client | ClientRead | SELECT +
| | | name, +
| | | setting, +
| | | unit, +
| | | source +
| | | FROM pg_settings +
| | | WHERE name IN +
| | | ( +
| | | 'shared_buffers', +
| | | 'work_mem', +
| | | 'maintenance_work_mem',+
| | | 'effective_cache_size' +
| | | );
6797 | Lock | transactionid | UPDATE consumer.customers +
| | | SET state = 'Waiting' +
| | | WHERE customer_id = 100;
What to Observe

Look for a session where:

ColumnExpected Value
wait_event_typeLock
wait_eventtransactionid (or another lock-related wait)
queryThe blocked UPDATE statement

โณ The session is unable to continue because it is waiting for a resource to become available.

๐Ÿ”Ž pg_stat_activity allows us to quickly identify sessions that are waiting and determine whether the wait is caused by a lock, I/O operation, client communication, or another PostgreSQL activity.

๐Ÿ” 9.6 Inspect Locks with pg_locks

The pg_locks system view displays lock information currently tracked by PostgreSQL. Every time a session acquires or waits for a lock, PostgreSQL records the details in this view.

Unlike pg_stat_activity, which shows what sessions are doing, pg_locks shows the locks those sessions hold or are waiting to acquire.

Key Columns
ColumnDescription
pidPostgreSQL session ID
locktypeType of lock being tracked
modeLock mode requested or granted
grantedWhether the lock has been acquired
Common Values
ColumnExample ValueMeaning
๐Ÿ” locktyperelationLock on a table or index
๐Ÿ” locktypetransactionidLock associated with a transaction
๐Ÿ” locktypetupleLock on an individual row
๐Ÿ“ modeRowExclusiveLockTypical lock acquired by UPDATE
๐Ÿ“ modeAccessShareLockTypical lock acquired by SELECT
Common Lock Modes
Lock ModeTypical Operation
๐Ÿ“– AccessShareLockSELECT
โœ๏ธ RowExclusiveLockINSERT, UPDATE, DELETE
๐Ÿ”’ ShareLockWaiting for another transaction
๐Ÿ” ExclusiveLockInternal transaction coordination
--Display transaction locks for Session A
SELECT
    pid,
    locktype,
    transactionid,
    mode,
    granted
FROM pg_locks
WHERE locktype = 'transactionid';
 pid  |   locktype    | transactionid |     mode      | granted
------+---------------+---------------+---------------+---------
6797 | transactionid | 794 | ExclusiveLock | t
6722 | transactionid | 793 | ExclusiveLock | t
6797 | transactionid | 793 | ShareLock | f
(3 rows)
Session Mapping
SessionPIDTransaction ID
๐Ÿ” Session A6722793
โณ Session B6797794

Analyzing the Lock Information
PIDTransaction IDModeGrantedInterpretation
6722793ExclusiveLockโœ… trueSession A owns transaction 793
6797794ExclusiveLockโœ… trueSession B owns transaction 794
6797793ShareLockโŒ falseSession B is waiting on Session A’s transaction

What Is Happening?
StepDescription
1๏ธโƒฃSession A (PID 6722) starts transaction 793
2๏ธโƒฃSession A updates the row and keeps the transaction open
3๏ธโƒฃSession B (PID 6797) starts transaction 794
4๏ธโƒฃSession B attempts to update the same row
5๏ธโƒฃPostgreSQL makes Session B wait on transaction 793
6๏ธโƒฃSession B remains blocked until Session A commits or rolls back

Key Observation
EvidenceMeaning
๐Ÿ” PID 6722 owns transaction 793Session A is the lock owner
โณ PID 6797 requests a ShareLock on transaction 793Session B is waiting
โŒ granted = falseThe requested lock has not been granted

๐Ÿ’ก Session B already owns its own transaction (794), but it cannot continue because it is waiting for Session A’s transaction (793) to complete.

โ›“๏ธ 9.7 Blocking Chain Analysis

๐ŸŽฏ Goal

Identify the blocked session and the session causing the block.

๐Ÿ”Ž pg_blocking_pids() is one of the fastest ways to identify which session is causing a lock wait.

Why This Is Useful
ToolAnswers
pg_stat_activityWho is waiting?
pg_locksWhat lock is involved?
pg_blocking_pids()Who is causing the wait?
--Display the blocking pid in Session A
SELECT
    pid,
    pg_blocking_pids(pid)
FROM pg_stat_activity;
 pid  | pg_blocking_pids
------+------------------
5030 | {}
5031 | {}
6722 | {}
6401 | {}
6797 | {6722}
5027 | {}
Session Status
PIDBlocking PID(s)Status
6722{}๐Ÿ” Blocking session
6797{6722}โณ Blocked session
Understanding the Output
ValueMeaning
{}Session is not blocked by anyone
{6722}Session is waiting for PID 6722
What Is Happening?
StepDescription
1๏ธโƒฃSession A (PID 6722) updates the row
2๏ธโƒฃSession A keeps the transaction open
3๏ธโƒฃSession B (PID 6797) attempts to update the same row
4๏ธโƒฃPostgreSQL blocks Session B
5๏ธโƒฃpg_blocking_pids() identifies PID 6722 as the blocker
Key Observation
EvidenceInterpretation
๐Ÿ” PID 6722 โ†’ {}Not waiting on any session
โณ PID 6797 โ†’ {6722}Waiting for Session A
๐Ÿ‘€ Other PIDs โ†’ {}Not involved in the blocking chain
-- Issue commit in Session A. 
commit; 

Now Observe the Session B, It advances to committed State.

๐Ÿ’€ 9.8 Deadlock Demo

๐ŸŽฏ Goal

A deadlock occurs when Session A holds one lock and waits for another lock held by Session B, while Session B waits for the first lock held by Session A.

Session A
--Session A: Lock the first row
BEGIN;

UPDATE consumer.customers
SET state = 'A'
WHERE customer_id = 100;
Session B
-- Session B: Lock a different row
BEGIN;

UPDATE consumer.customers
SET state = 'B'
WHERE customer_id = 200;
Session A
--Now Session A tries to update the row already locked by Session B.
UPDATE consumer.customers
SET state = 'A'
WHERE customer_id = 200;
ResultMeaning
โณ WaitingSession A is now blocked by Session B
Session B
--Now Session B tries to update the row already locked by Session A.
UPDATE consumer.customers
SET state = 'B'
WHERE customer_id = 100;
creditcards=*# --Now Session B tries to update the row already locked by Session A.
UPDATE consumer.customers
SET state = 'B'
WHERE customer_id = 100;
ERROR: deadlock detected
DETAIL: Process 6797 waits for ShareLock on transaction 795; blocked by process 6722.
Process 6722 waits for ShareLock on transaction 796; blocked by process 6797.
HINT: See server log for query details.
CONTEXT: while updating tuple (16387,48) in relation "customers"
creditcards=!#
Observe
OutputMeaning
ERROR: deadlock detectedPostgreSQL found a circular lock wait
One transaction is cancelledPostgreSQL breaks the deadlock automatically
Summary

๐Ÿ’€ A deadlock happens when two transactions block each other in a circle. PostgreSQL detects the problem and cancels one transaction so the system can continue.

Issue /q in both Session A and Session B – to exit and land in bash prompt

-- in both Session A and Session B. 
\q

๐Ÿšจ 9.9 Understanding Wait Events

๐ŸŽฏ Goal

Understand what PostgreSQL sessions are waiting for and how wait events help diagnose performance problems.

# Display wait event information for active sessions
psql -p 5432 -U postgres -d creditcards -c "
SELECT
    pid,
    wait_event_type,
    wait_event
FROM pg_stat_activity;
"
Understanding Wait Events

When a session cannot continue immediately, PostgreSQL records the reason in the wait_event_type and wait_event columns.
These columns help identify whether a session is waiting for a lock, disk access, client communication, or another resource.

Common Wait Event Types
Wait Event TypeMeaning
๐Ÿ” LockWaiting for a lock
๐Ÿ’พ IOWaiting for disk I/O
๐Ÿ‘ค ClientWaiting for client input
๐Ÿ“ฆ BufferPinWaiting for access to a shared buffer
โš™๏ธ ActivityWaiting for background work

๐Ÿ”Ž The exact wait events visible on your system depend on the workload running at the time the query is executed.

โšก 9.10 Resolve Blocking Sessions

๐ŸŽฏ Goal

Learn how to stop problematic queries and terminate sessions that are causing blocking issues.

Cancel a Running Query

Use pg_cancel_backend() to stop the currently running query while keeping the session connected.

# Cancel the currently running query -
psql -p 5432 -U postgres -d creditcards -c "
SELECT pg_cancel_backend(<pid>);
"
Terminate a Session

Use pg_terminate_backend() to disconnect the entire session.

# Terminate a PostgreSQL session
psql -p 5432 -U postgres -d creditcards -c "
SELECT pg_terminate_backend(<pid>);
"
Understanding the Functions
FunctionAction
โš ๏ธ pg_cancel_backend()Cancel the currently executing query
๐Ÿ’ฅ pg_terminate_backend()Terminate the entire session
When to Use Each
SituationRecommended Action
Long-running queryโš ๏ธ pg_cancel_backend()
Blocking queryโš ๏ธ pg_cancel_backend()
Unresponsive session๐Ÿ’ฅ pg_terminate_backend()
Session holding locks for too long๐Ÿ’ฅ pg_terminate_backend()
Key Takeaway

๐Ÿ’ก Start with pg_cancel_backend() whenever possible. Use pg_terminate_backend() only when the session cannot be recovered or continues causing problems.
๐Ÿ”Ž In production, first identify the blocking PID using pg_stat_activity or pg_blocking_pids(), then decide whether to cancel the query or terminate the session.


โšก Chapter 10 โ€“ Buffer Cache and I/O Analysis

โšก Chapter 10 โ€“ Buffer Cache and I/O Analysis

๐Ÿ“Œ Goal

Understand how PostgreSQL uses memory to avoid disk I/O and how cache warming affects query performance.

๐ŸŽฏ Learn

โ€ข โšก Shared Buffer Cache
โ€ข โšก Buffer Hits vs Disk Reads
โ€ข โšก pg_buffercache
โ€ข โšก pg_prewarm
โ€ข โšก Cold Cache vs Warm Cache

โšก 10.1 Why Buffer Cache Matters
TermMeaning
๐Ÿ’พ Disk ReadData read from storage
โšก Buffer HitData already in memory
๐Ÿง  Shared BuffersPostgreSQL cache
๐Ÿš€ Warm CacheData already cached

โšก Memory access is dramatically faster than disk access. PostgreSQL therefore tries to keep frequently accessed data in shared buffers.

โšก 10.2 Inspect Buffer Hit Ratios

# Display database buffer cache statistics
psql -p 5432 -U postgres -d creditcards -c "
SELECT
    datname,
    blks_read,
    blks_hit,
    ROUND(
        100.0 * blks_hit /
        NULLIF(blks_hit + blks_read,0),
        2
    ) AS hit_ratio_pct
FROM pg_stat_database
WHERE datname='creditcards';
"
   datname   | blks_read | blks_hit | hit_ratio_pct
-------------+-----------+----------+---------------
creditcards | 2924091 | 14394656 | 83.12
MetricMeaning
blks_readRead from disk
blks_hitServed from cache
hit_ratio_pctCache effectiveness

โšก 10.3 Cold Cache Demo โญ

# Execute a full table scan
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*)
FROM consumer.transactions;
"
 Finalize Aggregate  (cost=138728.59..138728.60 rows=1 width=8) (actual time=2954.071..2963.593 rows=1 loops=1)
Buffers: shared hit=30851 read=8896
-> Gather (cost=138728.37..138728.58 rows=2 width=8) (actual time=2953.195..2963.573 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=30851 read=8896
-> Partial Aggregate (cost=137728.37..137728.38 rows=1 width=8) (actual time=2941.472..2941.474 rows=1 loops=3)
Buffers: shared hit=30851 read=8896
-> Parallel Index Only Scan using idx_transactions_merchant on transactions (cost=0.43..127326.34 rows=4160814 width=0) (actual time=0.512..
2012.465 rows=3333333 loops=3)
Heap Fetches: 0
Buffers: shared hit=30851 read=8896
Planning:
Buffers: shared hit=76 read=25
Planning Time: 5.355 ms
Execution Time: 2965.527 ms
(15 rows)
MetricExpected
shared readHigh
Execution timeHigher

๐Ÿ’พ PostgreSQL must read many blocks from disk.

โšก 10.4 Warm Cache Demo โญ

# Execute the same query again
psql -p 5432 -U postgres -d creditcards -c "
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*)
FROM consumer.transactions;
"
 Finalize Aggregate  (cost=138728.59..138728.60 rows=1 width=8) (actual time=2163.070..2181.025 rows=1 loops=1)
Buffers: shared hit=39505
-> Gather (cost=138728.37..138728.58 rows=2 width=8) (actual time=2160.751..2181.004 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=39505
-> Partial Aggregate (cost=137728.37..137728.38 rows=1 width=8) (actual time=2148.674..2148.676 rows=1 loops=3)
Buffers: shared hit=39505
-> Parallel Index Only Scan using idx_transactions_merchant on transactions (cost=0.43..127326.34 rows=4160814 width=0) (actual time=0.535..
1325.674 rows=3333333 loops=3)
Heap Fetches: 0
Buffers: shared hit=39505
Planning:
Buffers: shared hit=101
Planning Time: 1.165 ms
Execution Time: 2182.099 ms
(15 rows)

Observe:

MetricExpected
shared hitHigh
shared readLow (nil in our demo)
Execution timeFaster

โšก Most blocks are now served from memory instead of disk.

โšก 10.5 Understanding BUFFERS Output

Buffer MetricMeaning
shared hitRead from cache
shared readRead from disk
shared dirtiedModified page
shared writtenWritten to disk

โšก 10.6 Inspect Cached Pages with pg_buffercache

# Display objects currently stored in cache
psql -p 5432 -U postgres -d creditcards -c "
SELECT
    c.relname,
    COUNT(*) AS cached_buffers
FROM pg_buffercache b
JOIN pg_class c
    ON b.relfilenode = pg_relation_filenode(c.oid)
GROUP BY c.relname
ORDER BY cached_buffers DESC
LIMIT 10;
"
             relname             | cached_buffers
---------------------------------+----------------
idx_transactions_merchant | 8892
pg_attribute | 61
pg_class | 28
pg_attribute_relid_attnum_index | 16
pg_operator | 14

๐Ÿ’ก pg_buffercache provides a snapshot of what is currently stored in PostgreSQL’s shared buffers. In this example, the idx_transactions_merchant index occupies most of the visible cache, indicating it has been accessed frequently and PostgreSQL considers it valuable to keep in memory.

โšก 10.7 Warm Cache with pg_prewarm

When Should You Use pg_prewarm?
pg_prewarm allows PostgreSQL to proactively load table or index pages into memory before queries access them.

Good CandidatesWhy
๐Ÿš€ Frequently accessed lookup tablesUsed by almost every query
๐Ÿš€ Critical reference dataMust respond quickly after restart
๐Ÿš€ Frequently scanned indexesReduces initial disk reads
๐Ÿš€ Reporting tables used repeatedlyImproves startup performance

๐Ÿš€ pg_prewarm is most useful when specific tables or indexes must be available in memory immediately after startup. It is not a feature that should be applied indiscriminately to every object in the database.

๐Ÿ 10.8 Chapter Summary

TopicTool
Buffer Statisticspg_stat_database
Execution Plan BuffersEXPLAIN BUFFERS
Cache Inspectionpg_buffercache
Cache Warmingpg_prewarm

โšก The difference between a slow query and a fast query is often not the SQL itself, but whether the required data is already cached in memory. Understanding buffer hits, disk reads, and cache warming is essential for diagnosing PostgreSQL performance.


๐Ÿš€ Chapter 11 โ€“ Parallel Query Processing

๐Ÿš€ Chapter 11 โ€“ Parallel Query Processing

๐Ÿ“Œ Goal

Understand how PostgreSQL uses multiple worker processes to execute large queries faster.

๐Ÿš€ 11.1 Chapter Introduction
Why Parallel Query Matters
ConceptDescription
๐Ÿง  Serial ExecutionOne process executes the query
๐Ÿš€ Parallel ExecutionMultiple workers share the work
โšก Faster ExecutionBetter CPU utilization
๐Ÿ“Š Large Queries Benefit MostEspecially scans and aggregates

๐Ÿš€ Parallel query processing allows PostgreSQL to divide work among multiple CPU cores.

๐Ÿš€ 11.2 Inspect Parallel Query Settings

Understand the parameters controlling parallel execution.

# Display parallel query settings
psql -p 5432 -U postgres -d creditcards -c "
SELECT name, setting, unit 
FROM pg_settings 
WHERE name IN (
    'max_parallel_workers_per_gather', 
    'max_parallel_workers', 
    'min_parallel_table_scan_size'
);
"
              name               | setting | unit
---------------------------------+---------+------
max_parallel_workers | 8 |
max_parallel_workers_per_gather | 2 |
min_parallel_table_scan_size | 1024 | 8kB
ParameterPurpose
max_parallel_workers_per_gatherWorkers per query
max_parallel_workersTotal workers available
min_parallel_table_scan_sizeMinimum table size for parallel scan
๐Ÿš€ 11.3 Serial vs Parallel Execution โญ

Step 1 โ€” Serial Baseline

# Disable parallel execution and run a grouped aggregate
psql -p 5432 -U postgres -d creditcards -c "
SET max_parallel_workers_per_gather = 0;

EXPLAIN (ANALYZE, BUFFERS)
SELECT
    merchant_name,
    COUNT(*)
FROM consumer.transactions
GROUP BY merchant_name;
"
 GroupAggregate  (cost=0.43..235557.51 rows=5001 width=21) (actual time=0.703..6173.024 rows=5000 loops=1)
Group Key: merchant_name
Buffers: shared hit=33891
-> Index Only Scan using idx_transactions_merchant on transactions (cost=0.43..185577.73 rows=9985953 width=13) (actual time=0.043..2880.203 rows=100000
00 loops=1)
Heap Fetches: 0
Buffers: shared hit=33891
Planning:
Buffers: shared hit=108
Planning Time: 0.582 ms
Execution Time: 6176.242 ms
(10 rows)

Step 2 โ€” Enable Parallelism

# Enable parallel execution
psql -p 5432 -U postgres -d creditcards -c "
SET max_parallel_workers_per_gather = 4;
SET parallel_setup_cost = 0;
SET parallel_tuple_cost = 0;

EXPLAIN (ANALYZE, BUFFERS)
SELECT
    merchant_name,
    COUNT(*)
FROM consumer.transactions
GROUP BY merchant_name;
"
 Finalize GroupAggregate  (cost=0.49..123647.87 rows=5001 width=21) (actual time=342.845..2783.268 rows=5000 loops=1)
Group Key: merchant_name
Buffers: shared hit=40490
-> Gather Merge (cost=0.49..123497.84 rows=20004 width=21) (actual time=342.736..2755.057 rows=12207 loops=1)
Workers Planned: 4
Workers Launched: 4
Buffers: shared hit=40490
-> Partial GroupAggregate (cost=0.43..123215.53 rows=5001 width=21) (actual time=2.125..2184.931 rows=2441 loops=5)
Group Key: merchant_name
Buffers: shared hit=40490
-> Parallel Index Only Scan using idx_transactions_merchant on transactions (cost=0.43..110683.08 rows=2496488 width=13) (actual time=0.312.
.1080.228 rows=2000000 loops=5)
Heap Fetches: 0
Buffers: shared hit=40490
Planning:
Buffers: shared hit=111
Planning Time: 2.011 ms
Execution Time: 2786.295 ms
(17 rows)

๐Ÿ“Š PostgreSQL Scan Comparison Table

Metric / FeatureSequential Scan PlanParallel Scan PlanVariance & Technical Impact
Total Execution Time6,176.24 ms (~6.18s)2,786.30 ms (~2.79s)54.9% Faster (~2.2x speedup via parallelism)
Planning Time0.582 ms2.011 ms+245% Overhead (Planner must evaluate parallel paths)
Workers Active0 (Single-threaded)4 Workers + 1 Leader5 concurrent execution contexts utilized
Execution StrategyIndex Only Scan $\rightarrow$ GroupAggregateParallel Index Only Scan $\rightarrow$ Partial GroupAggregate $\rightarrow$ Gather Merge $\rightarrow$ Finalize GroupAggregateMoves from single-stage to a two-stage distributed aggregation
Total Rows Processed10,000,000 (1 loop ร— 10M rows)10,000,000 (5 loops ร— 2M rows)Identical data volume, but split into chunks per worker
Shared Buffer Hits33,89140,490~19.4% Increase (Parallel worker synchronization & page-sharing overhead)
Estimated Total Cost235,557.51123,647.87~47.5% Lower Cost (Costing is divided across workers)
Final Row Output5,000 rows5,000 rowsIdentical query results

โšก Resource Boost: You get a much faster query response in parallel scan, but at the expense of higher background CPU utilization.

Key Takeaway

๐Ÿš€ Parallel query processing allows PostgreSQL to use multiple CPU cores for a single query. Large scans and aggregations often benefit the most, but the planner only uses parallelism when the expected gain outweighs the coordination cost.

๐Ÿงน Chapter 12 โ€“ Vacuum, Autovacuum and Bloat Management

๐Ÿงน Chapter 12 โ€“ Vacuum, Autovacuum and Bloat Management

Understand how PostgreSQL reclaims storage.

โ€ข ๐Ÿงน Dead Tuples
โ€ข ๐Ÿงน VACUUM
โ€ข ๐Ÿงน AUTOVACUUM
โ€ข ๐Ÿงน Freeze Operations
โ€ข ๐Ÿ“ฆ Table Bloat
โ€ข ๐Ÿ“ฆ Index Bloatrevents table bloat, and maintains transaction visibility.

Refer to below from Chapter 2 onwards:

PostgreSQL Vacuum, Autovacuum and Bloat Management Deep Dive

2 thoughts on “PostgreSQL Performance Tuning Deep Dive”

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top