Skip to content

PostgreSQL Advanced

Extensions add functionality to PostgreSQL through a well-defined API. They run in the same process As the server and have access to the same data, making them powerful but also a trust boundary.

-- Available extensions
SELECT * FROM pg_available_extensions;
-- Install an extension
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS hstore;
CREATE EXTENSION IF NOT EXISTS uuid-ossp;
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Check installed extensions
SELECT * FROM pg_extension;

PostGIS adds geographic data types and spatial functions:

CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE locations (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
geom GEOMETRY(POINT, 4326) NOT NULL -- WGS84 (GPS coordinates)
);
CREATE INDEX idx_locations_geom ON locations USING GIST (geom);
-- Insert a point (longitude, latitude)
INSERT INTO locations (name, geom)
VALUES ("San Francisco', ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326));
-- Find locations within 10km of a point
SELECT name, ST_Distance(geom, ST_SetSRID(ST_MakePoint(-122.4, 37.77), 4326)) AS distance_meters
FROM locations
WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint(-122.4, 37.77), 4326), 10000)
ORDER BY distance_meters;

Trigram-based similarity and fuzzy matching:

CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- GIN trigram index for fast ILIKE
CREATE INDEX idx_users_name_trgm ON users USING GIN (name gin_trgm_ops);
-- Fast case-insensitive partial match
SELECT * FROM users WHERE name ILIKE '%john%';
-- Similarity search (0 to 1, where 1 is exact match)
SELECT name, similarity(name, 'John Smith') AS sim
FROM users
WHERE name % 'John Smith'
ORDER BY sim DESC;
-- Adjust similarity threshold
SET pg_trgm.similarity_threshold = 0.3;

Cryptographic functions:

CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Generate UUIDs
SELECT gen_random_uuid();
-- Returns: 550e8400-e29b-41d4-a716-446655440000
-- Hash functions
SELECT digest('password', 'sha256');
SELECT crypt('password', gen_salt('bf'));
-- Verify password
SELECT (crypt('password', stored_hash) = stored_hash) AS is_valid
FROM users WHERE email = 'user@example.com';
-- PGP encryption
SELECT pgp_sym_encrypt('secret data', 'encryption_key');
SELECT pgp_sym_decrypt(encrypted_column, 'encryption_key');

Key-value pairs within a single column:

CREATE EXTENSION IF NOT EXISTS hstore;
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
attributes HSTORE
);
INSERT INTO products (name, attributes)
VALUES ('Widget', 'color => "red", weight => "2.5", material => "steel"');
-- Query hstore fields
SELECT name FROM products WHERE attributes -> 'color' = 'red';
SELECT name, attributes -> 'weight' AS weight FROM products;
-- GIN index for hstore containment
CREATE INDEX idx_products_attrs ON products USING GIN (attributes);
-- Containment queries
SELECT * FROM products WHERE attributes @> 'color => "red"';
SELECT * FROM products WHERE attributes ? 'weight'; -- has key 'weight'
CREATE EXTENSION IF NOT EXISTS uuid-ossp;
-- UUID v1 (MAC address + timestamp)
SELECT uuid_generate_v1();
-- UUID v4 (random)
SELECT uuid_generate_v4();
-- UUID v5 (SHA-1 hash of namespace + name, deterministic)
SELECT uuid_generate_v5(uuid_ns_dns(), 'example.com');

Case-insensitive text:

CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE accounts (
username CITEXT PRIMARY KEY,
email CITEXT NOT NULL
);
-- Both insertions fail (case-insensitive uniqueness)
INSERT INTO accounts VALUES ('Alice', 'alice@example.com');
INSERT INTO accounts VALUES ('alice', 'alice@other.com');
-- ERROR: duplicate key value violates unique constraint
-- Must be loaded at server start
-- shared_preload_libraries = 'pg_stat_statements'
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Top queries by total time
SELECT query, calls, total_exec_time / 1000 AS total_ms,
mean_exec_time / 1000 AS avg_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
AspectPhysical ReplicationLogical Replication
What replicatesEntire WAL (all databases, all tables)Selected tables (per-publication)
GranularityDatabase level (entire cluster)Table level (per publication/subscription)
Cross-versionSame major version onlyCan replicate between major versions
Write on replicaNo (read-only)Yes (subscription tables are writable)
Use caseHigh availability, disaster recoveryData sharing, partial replication, CDC

Publisher (source):

-- Set wal_level = logical in postgresql.conf and restart
-- wal_level = logical
CREATE PUBLICATION pub_orders FOR TABLE orders;
CREATE PUBLICATION pub_all_tables FOR ALL TABLES;
-- Publication with filter
CREATE PUBLICATION pub_active_orders FOR TABLE orders
WHERE (status = 'active' AND created_at >= '2024-01-01');

Subscriber (target):

CREATE SUBSCRIPTION sub_orders
CONNECTION 'host=publisher.example.com dbname=mydb user=replicator'
PUBLICATION pub_orders
WITH (create_slot = true, slot_name = 'sub_orders_slot');
-- Verify subscription status
SELECT * FROM pg_stat_subscription;

On the subscriber, conflicts can occur when the subscription table has local modifications that Conflict with incoming replication changes:

-- Configure conflict resolution
ALTER SUBSCRIPTION sub_orders SET (slot_name = 'sub_orders_slot');
-- Handle conflicts (check pg_stat_subscription_stats)
SELECT subname, slot_name, sync_error_count, apply_error_count
FROM pg_stat_subscription_stats;
-- Common conflicts:
-- 1. insert_duplicate_key: row already exists on subscriber
-- 2. update_conflict: row version mismatch
-- Solutions: skip errors, or use trigger-based conflict resolution
ALTER SUBSCRIPTION sub_orders SKIP (ALTER TABLE orders REPLICA IDENTITY FULL);

Logical replication needs to know which column(s) uniquely identify a row:

-- Default: primary key
ALTER TABLE orders REPLICA IDENTITY DEFAULT;
-- Use a unique index
ALTER TABLE orders REPLICA IDENTITY USING INDEX idx_orders_order_id;
-- Full row comparison (no unique index available, slower)
ALTER TABLE orders REPLICA IDENTITY FULL;
-- No row identity (deletes are not replicated)
ALTER TABLE orders REPLICA IDENTITY NOTHING;

Query remote PostgreSQL servers as if they were local tables:

CREATE EXTENSION IF NOT EXISTS postgres_fdw;
-- Create the foreign server
CREATE SERVER remote_db
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'remote.example.com', port '5432', dbname 'analytics');
-- Create user mapping (local user → remote user)
CREATE USER MAPPING FOR local_app
SERVER remote_db
OPTIONS (user 'analytics_user', password 'secret');
-- Import remote tables
IMPORT FOREIGN SCHEMA public LIMIT TO (events, users)
FROM SERVER remote_db INTO public;
-- Or create individual foreign tables
CREATE FOREIGN TABLE remote_events (
event_id BIGINT,
event_type TEXT,
payload JSONB,
created_at TIMESTAMPTZ
) SERVER remote_db OPTIONS (schema_name 'public', table_name 'events');
-- Query the remote table (PostgreSQL pushes down WHERE and LIMIT)
SELECT event_type, COUNT(*) FROM remote_events
WHERE created_at >= '2024-01-01'
GROUP BY event_type;
-- Join local and remote tables
SELECT u.name, COUNT(r.event_id) AS event_count
FROM users u
JOIN remote_events r ON u.user_id = r.user_id
WHERE r.created_at >= '2024-01-01'
GROUP BY u.name;

Pg_cron requires shared_preload_libraries = 'pg_cron' and a PostgreSQL restart. Jobs run in the Context of the database where pg_cron is installed. Cross-database scheduling is not supported.

Pg_background runs commands in the background, returning control to the client immediately:

CREATE EXTENSION IF NOT EXISTS pg_background;
-- Run a long-running vacuum in the background
SELECT pg_background.launch('VACUUM ANALYZE orders');
-- Check background worker status
SELECT * FROM pg_background.result;
; pgbouncer.ini
[databases]
; Override per-database settings
mydb = host=127.0.0.1 port=5432 dbname=mydb pool_size=10
analytics = host=analytics-db port=5432 dbname=analytics pool_mode=session
[pgbouncer]
; Pool modes
pool_mode = transaction
; Connection limits
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5 ; keep minimum connections warm
reserve_pool_size = 5 ; extra connections for spikes
reserve_pool_timeout = 3 ; seconds to wait for reserve pool
; Timeouts
server_idle_timeout = 600 ; close idle server connections after 10 min
server_lifetime = 3600 ; recycle server connections after 1 hour
client_idle_timeout = 0 ; do not disconnect idle clients
client_login_timeout = 60 ; login must complete within 60s
; Server connection settings
server_connect_timeout = 15
server_check_delay = 30 ; health check interval
server_check_query = SELECT 1
server_reset_query = DISCARD ALL
; Logging
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
verbose = 1
; Admin interface
admin_users = admin
stats_period = 60
-- Connect to PgBouncer admin console
psql -h 127.0.0.1 -p 6432 -U admin pgbouncer
-- Show pool statistics
SHOW POOLS;
-- Show databases
SHOW DATABASES;
-- Show clients
SHOW CLIENTS;
-- Show server connections
SHOW SERVERS;
-- Show active queries
SHOW STATS;
-- Stats per database
SHOW STATS_STATS;
-- List configured databases
SHOW LISTS;
-- Ensure wal_level = logical (requires restart)
-- wal_level = logical
-- Create a replication slot for logical decoding
SELECT pg_create_logical_replication_slot('my_slot', 'pgoutput');
-- Read changes
SELECT * FROM pg_logical_slot_peek_changes('my_slot', NULL, NULL);
-- Consume changes (advances the slot)
SELECT * FROM pg_logical_slot_get_changes('my_slot', NULL, NULL);
-- Drop the slot
SELECT pg_drop_replication_slot('my_slot');

Debezium captures row-level changes from PostgreSQL WAL and publishes them to Kafka:

Architecture:
PostgreSQL → WAL → Debezium Connector → Kafka → Consumers
Debezium reads WAL using logical replication slots.
It publishes change events as JSON/Avro to Kafka topics.
Each table gets its own topic.
Events include: before/after images, operation type, transaction metadata.