|$ curl https://forge-ai.dev/api/markdown?path=docs/databases/postgresql
$cat docs/postgresql-deep-dive.md
updated Recentlyยท50 min readยทpublished

PostgreSQL Deep Dive

โ—†PostgreSQLโ—†SQLโ—†Databasesโ—†Intermediate to Advanced๐ŸŽฏFree Tools
Introduction

PostgreSQL is the world's most advanced open-source relational database. It has been in active development for over 35 years and offers features that rival commercial databases like Oracle and SQL Server โ€” all under a liberal open-source license.

What sets PostgreSQL apart is its extensibility, standards compliance, and the breadth of its feature set. It natively supports JSONB, full-text search, geospatial data (PostGIS), vector embeddings (pgvector), time-series (TimescaleDB), and much more โ€” often eliminating the need for separate specialized databases.

This guide goes beyond basic SQL to explore the features that make PostgreSQL uniquely powerful for modern application development.

๐Ÿ“

note

PostgreSQL 17 (released 2024) introduced incremental backup, logical replication improvements, and new JSON_TABLE functionality. This guide covers features available in PostgreSQL 15+.
JSONB โ€” Document Storage in SQL

JSONB (binary JSON) is PostgreSQL's high-performance JSON data type. Unlike the text-based JSON type, JSONB is stored in a decomposed binary format that supports indexing, efficient querying, and operators for path-based access. This effectively gives you document database capabilities inside a relational database.

jsonb-basics.sql
SQL
1-- Creating a table with JSONB columns
2CREATE TABLE products (
3 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 name VARCHAR(255) NOT NULL,
5 metadata JSONB NOT NULL DEFAULT '{}',
6 tags TEXT[] DEFAULT '{}',
7 created_at TIMESTAMPTZ DEFAULT NOW()
8);
9
10-- Inserting JSONB data
11INSERT INTO products (name, metadata, tags) VALUES (
12 'Wireless Headphones',
13 '{
14 "brand": "Sony",
15 "specs": {
16 "weight": 250,
17 "battery_life": 30,
18 "bluetooth_version": 5.2,
19 "noise_canceling": true
20 },
21 "colors": ["black", "silver", "midnight blue"],
22 "reviews": {
23 "avg_rating": 4.7,
24 "count": 2847
25 }
26 }',
27 ARRAY['audio', 'wireless', 'bluetooth']
28);
29
30-- Accessing JSONB values
31SELECT
32 name,
33 metadata->>'brand' AS brand, -- text extraction
34 metadata->'specs'->>'weight' AS weight, -- nested access
35 (metadata->'specs'->>'battery_life')::int AS battery,
36 metadata->'colors' AS all_colors, -- raw JSONB
37 jsonb_array_length(metadata->'colors') AS color_count
38FROM products;
39
40-- JSONB operators
41SELECT name FROM products WHERE metadata @> '{"brand": "Sony"}';
42SELECT name FROM products WHERE metadata->'specs'->>'noise_canceling' = 'true';
43SELECT name FROM products WHERE metadata @? '$.specs.battery_life ? (@ > 20)';
44SELECT * FROM products WHERE metadata @@ 'keys(@) ? (@ == "brand")';
jsonb-advanced.sql
SQL
1-- GIN index for fast JSONB queries
2CREATE INDEX idx_products_metadata ON products USING GIN (metadata);
3CREATE INDEX idx_products_metadata_path ON products USING GIN (metadata jsonb_path_ops);
4
5-- jsonb_path_ops is smaller and faster for containment (@>) queries
6-- but does not support key-existence (?) or all-keys (??) operators
7
8-- JSONB aggregation
9SELECT
10 jsonb_object_agg(
11 name,
12 metadata->>'brand'
13 ) AS product_brands
14FROM products;
15
16-- JSONB in GROUP BY (unnest keys)
17SELECT
18 jsonb_array_elements_text(metadata->'colors') AS color,
19 COUNT(*) AS product_count
20FROM products
21GROUP BY color
22ORDER BY product_count DESC;
23
24-- Update specific JSONB path
25UPDATE products
26SET metadata = jsonb_set(
27 metadata,
28 '{specs, battery_life}',
29 '35'
30)
31WHERE id = $1;
32
33-- Remove a key
34UPDATE products
35SET metadata = metadata - 'reviews';
36
37-- Merge JSONB objects
38UPDATE products
39SET metadata = metadata || '{"warranty": "2 years"}'::jsonb;
40
41-- JSONB to/from relational
42-- Shred JSONB into rows
43SELECT
44 p.name,
45 elem AS color
46FROM products p,
47 jsonb_array_elements_text(p.metadata->'colors') AS elem;
48
49-- Aggregate rows into JSONB
50SELECT
51 jsonb_agg(
52 jsonb_build_object('name', name, 'brand', metadata->>'brand')
53 ) AS all_products
54FROM products;
โ„น

info

Use the containment operator @> with a GIN index for the fastest JSONB queries. Thejsonb_path_ops operator class is smaller and faster than the default GIN class, but only supports containment queries.
Advanced CTEs

Common Table Expressions in PostgreSQL support features that go beyond the SQL standard, including data-modifying CTEs that can INSERT, UPDATE, or DELETE rows and return the affected rows in a single query.

advanced-ctes.sql
SQL
1-- Data-modifying CTE: UPDATE with returning results
2WITH updated_orders AS (
3 UPDATE orders
4 SET status = 'shipped', shipped_at = NOW()
5 WHERE status = 'confirmed'
6 AND created_at < NOW() - INTERVAL '2 days'
7 RETURNING id, user_id, total
8)
9-- Insert shipping notifications for shipped orders
10INSERT INTO notifications (user_id, type, data, created_at)
11SELECT
12 user_id,
13 'order_shipped',
14 jsonb_build_object('order_id', id, 'total', total),
15 NOW()
16FROM updated_orders;
17
18-- CTE with multiple statements
19WITH
20 -- Calculate monthly stats
21 monthly AS (
22 SELECT
23 DATE_TRUNC('month', created_at) AS month,
24 SUM(total) AS revenue,
25 COUNT(*) AS orders
26 FROM orders WHERE status = 'completed'
27 GROUP BY DATE_TRUNC('month', created_at)
28 ),
29 -- Calculate growth rates
30 growth AS (
31 SELECT
32 month,
33 revenue,
34 orders,
35 LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
36 ROUND(
37 (revenue - LAG(revenue) OVER (ORDER BY month))
38 / NULLIF(LAG(revenue) OVER (ORDER BY month), 0) * 100, 2
39 ) AS growth_pct
40 FROM monthly
41 ),
42 -- Flag anomalies (revenue dropped > 20%)
43 anomalies AS (
44 SELECT * FROM growth WHERE growth_pct < -20
45 )
46SELECT * FROM anomalies ORDER BY month DESC;
๐Ÿ”ฅ

pro tip

Data-modifying CTEs are PostgreSQL-specific and incredibly useful for complex multi-step operations that need to return intermediate results. They are essentially stored procedures without the overhead.
Window Functions โ€” Advanced

PostgreSQL offers a rich set of window functions that go beyond basic ranking and aggregation. These include statistical functions, percentiles, hypothetical set functions, and frame specifications.

window-advanced.sql
SQL
1-- Percentiles and distribution
2SELECT
3 name,
4 salary,
5 PERCENT_RANK() OVER (ORDER BY salary) AS percentile_rank,
6 CUME_DIST() OVER (ORDER BY salary) AS cumulative_distribution,
7 PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary)
8 OVER (PARTITION BY department) AS median_salary,
9 PERCENTILE_DISC(0.9) WITHIN GROUP (ORDER BY salary)
10 OVER (PARTITION BY department) AS p90_salary
11FROM employees;
12
13-- Hypothetical aggregate functions (for "what if" analysis)
14SELECT
15 product_name,
16 current_rank,
17 hypothetical_rank
18FROM (
19 SELECT
20 p.name AS product_name,
21 RANK() OVER (ORDER BY p.sales DESC) AS current_rank,
22 RANK() WITHIN GROUP (ORDER BY p.sales DESC)
23 OVER () AS hypothetical_rank
24 FROM products p
25) sub
26WHERE hypothetical_rank <= 10;
27
28-- Frame specifications for precise control
29SELECT
30 DATE_TRUNC('day', created_at) AS day,
31 revenue,
32 -- Cumulative from start of partition
33 SUM(revenue) OVER w AS cumulative,
34 -- Rolling 7-day average
35 AVG(revenue) OVER w7 AS rolling_7d_avg,
36 -- Rolling 30-day max
37 MAX(revenue) OVER w30 AS rolling_30d_max
38FROM daily_revenue
39WINDOW
40 w AS (ORDER BY DATE_TRUNC('day', created_at)),
41 w7 AS (w ROWS BETWEEN 6 PRECEDING AND CURRENT ROW),
42 w30 AS (w ROWS BETWEEN 29 PRECEDING AND CURRENT ROW)
43ORDER BY day DESC;
Array and Composite Types

PostgreSQL supports arrays as a native column type, allowing you to store lists of values directly in a table. Combined with GIN indexes, array queries can be very efficient.

arrays.sql
SQL
1-- Array column
2CREATE TABLE articles (
3 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 title TEXT NOT NULL,
5 tags TEXT[] DEFAULT '{}',
6 scores INTEGER[] DEFAULT '{}'
7);
8
9-- Insert arrays
10INSERT INTO articles (title, tags, scores) VALUES
11 ('PostgreSQL Tips', ARRAY['postgres', 'database', 'sql'], '{95, 87, 92}'),
12 ('Redis Caching', ARRAY['redis', 'caching', 'performance'], '{88, 91}');
13
14-- Array operators
15SELECT * FROM articles WHERE 'postgres' = ANY(tags); -- contains value
16SELECT * FROM articles WHERE tags @> ARRAY['database']; -- contains subset
17SELECT * FROM articles WHERE tags && ARRAY['redis', 'sql'];-- overlaps
18SELECT * FROM articles WHERE NOT tags @> ARRAY['python']; -- does not contain
19
20-- Unnest array to rows
21SELECT title, unnest(tags) AS tag FROM articles;
22
23-- Aggregate rows to array
24SELECT array_agg(DISTINCT tag) AS all_tags
25FROM articles, unnest(tags) AS tag;
26
27-- Array aggregation and manipulation
28SELECT
29 title,
30 array_length(tags, 1) AS tag_count,
31 tags[1] AS first_tag,
32 array_remove(tags, 'sql') AS without_sql,
33 array_cat(tags, ARRAY['new-tag']) AS with_added,
34 array_append(tags, 'another') AS appended
35FROM articles;
36
37-- GiST and GIN indexes for arrays
38CREATE INDEX idx_articles_tags ON articles USING GIN (tags);
39CREATE INDEX idx_articles_tags_gist ON articles USING GiST (tags);
Essential Extensions

PostgreSQL's extension system allows adding functionality without modifying the core database. Extensions are SQL scripts that create functions, types, and operators. Here are the most important ones for modern applications.

extensions.sql
SQL
1-- Enable extensions
2CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID generation
3CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- Encryption functions
4CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Trigram similarity search
5CREATE EXTENSION IF NOT EXISTS "pg_stat_statements";-- Query performance tracking
6CREATE EXTENSION IF NOT EXISTS "btree_gist"; -- GiST index for B-tree types
7CREATE EXTENSION IF NOT EXISTS "intarray"; -- Integer array operations
8CREATE EXTENSION IF NOT EXISTS "hstore"; -- Key-value store type
9CREATE EXTENSION IF NOT EXISTS "pgvector"; -- Vector similarity search (AI)
10CREATE EXTENSION IF NOT EXISTS "postgis"; -- Geospatial data
11CREATE EXTENSION IF NOT EXISTS "pg_partman"; -- Partition management
12CREATE EXTENSION IF NOT EXISTS "timescaledb"; -- Time-series optimization
13
14-- pgvector: store and query embeddings
15CREATE TABLE document_embeddings (
16 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
17 content TEXT NOT NULL,
18 embedding vector(1536) NOT NULL
19);
20
21CREATE INDEX idx_doc_embedding ON document_embeddings
22 USING ivfflat (embedding vector_cosine_ops)
23 WITH (lists = 100);
24
25-- Query similar documents
26SELECT
27 content,
28 1 - (embedding <=> '[0.1, 0.2, ...]'::vector) AS similarity
29FROM document_embeddings
30ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
31LIMIT 10;
32
33-- PostGIS: geospatial queries
34CREATE TABLE locations (
35 id UUID PRIMARY KEY,
36 name TEXT,
37 geom GEOGRAPHY(POINT, 4326)
38);
39
40CREATE INDEX idx_locations_geom ON locations USING GIST (geom);
41
42-- Find all locations within 5km
43SELECT name, ST_Distance(geom, ST_MakePoint(-73.9857, 40.7484)::geography) AS distance
44FROM locations
45WHERE ST_DWithin(
46 geom,
47 ST_MakePoint(-73.9857, 40.7484)::geography,
48 5000 -- 5km in meters
49)
50ORDER BY distance;
๐Ÿ“

note

pgvector has become essential for AI applications, enabling similarity search on embeddings directly in PostgreSQL. It powers RAG pipelines, semantic search, and recommendation systems without requiring a separate vector database.
Table Partitioning

Partitioning splits large tables into smaller, more manageable pieces while maintaining a single logical table. PostgreSQL supports declarative partitioning by range, list, and hash.

partitioning.sql
SQL
1-- Range partitioning by date (most common for time-series)
2CREATE TABLE events (
3 id UUID DEFAULT gen_random_uuid(),
4 user_id UUID NOT NULL,
5 event_type VARCHAR(50) NOT NULL,
6 data JSONB DEFAULT '{}',
7 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
8) PARTITION BY RANGE (created_at);
9
10-- Create monthly partitions
11CREATE TABLE events_2025_01 PARTITION OF events
12 FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
13CREATE TABLE events_2025_02 PARTITION OF events
14 FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
15CREATE TABLE events_2025_03 PARTITION OF events
16 FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');
17
18-- Default partition (catches rows that don't match any partition)
19CREATE TABLE events_default PARTITION OF events DEFAULT;
20
21-- List partitioning (by discrete values)
22CREATE TABLE orders (
23 id UUID DEFAULT gen_random_uuid(),
24 user_id UUID NOT NULL,
25 status VARCHAR(20) NOT NULL,
26 total DECIMAL(10, 2)
27) PARTITION BY LIST (status);
28
29CREATE TABLE orders_pending PARTITION OF orders FOR VALUES IN ('pending', 'processing');
30CREATE TABLE orders_completed PARTITION OF orders FOR VALUES IN ('completed', 'delivered');
31CREATE TABLE orders_cancelled PARTITION OF orders FOR VALUES IN ('cancelled', 'refunded');
32
33-- Hash partitioning (for even distribution)
34CREATE TABLE sessions (
35 id UUID DEFAULT gen_random_uuid(),
36 user_id UUID NOT NULL,
37 data JSONB
38) PARTITION BY HASH (id);
39
40CREATE TABLE sessions_0 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 0);
41CREATE TABLE sessions_1 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 1);
42CREATE TABLE sessions_2 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 2);
43CREATE TABLE sessions_3 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 3);
โ„น

info

Use the pg_partman extension to automate partition creation and maintenance. It can create future partitions on a schedule and drop old ones based on retention policies.
Row-Level Security

Row-Level Security (RLS) restricts which rows a user can see or modify based on policies defined in SQL. It enforces access control at the database level, providing an additional security layer beyond application code.

row-level-security.sql
SQL
1-- Enable RLS on a table
2ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
3
4-- Policy: users can only see their own documents
5CREATE POLICY user_documents_select ON documents
6 FOR SELECT
7 USING (user_id = current_setting('app.current_user_id')::uuid);
8
9-- Policy: users can only insert their own documents
10CREATE POLICY user_documents_insert ON documents
11 FOR INSERT
12 WITH CHECK (user_id = current_setting('app.current_user_id')::uuid);
13
14-- Policy: users can only update their own documents
15CREATE POLICY user_documents_update ON documents
16 FOR UPDATE
17 USING (user_id = current_setting('app.current_user_id')::uuid)
18 WITH CHECK (user_id = current_setting('app.current_user_id')::uuid);
19
20-- Policy: users can only delete their own documents
21CREATE POLICY user_documents_delete ON documents
22 FOR DELETE
23 USING (user_id = current_setting('app.current_user_id')::uuid);
24
25-- Admin bypass policy
26CREATE POLICY admin_all_documents ON documents
27 FOR ALL
28 USING (current_setting('app.current_user_role') = 'admin');
29
30-- Set session variables (in application code)
31-- SET app.current_user_id = 'user-uuid-here';
32-- SET app.current_user_role = 'admin';
Performance Tuning

PostgreSQL performance tuning involves configuration, query optimization, indexing strategy, and monitoring. Here are the key areas to focus on.

performance.sql
SQL
1-- Analyze query execution plans
2EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
3SELECT u.name, COUNT(o.id) AS order_count
4FROM users u
5JOIN orders o ON o.user_id = u.id
6WHERE o.created_at >= '2025-01-01'
7GROUP BY u.id, u.name;
8
9-- Key EXPLAIN indicators:
10-- Seq Scan โ†’ missing index (bad for large tables)
11-- Index Scan โ†’ using index (good)
12-- Hash Join โ†’ efficient for large joins
13-- Nested Loop โ†’ efficient for small result sets
14-- Sort โ†’ check if sort_key matches index
15
16-- Vacuum and analyze (essential for performance)
17VACUUM (VERBOSE, ANALYZE) users;
18
19-- Check table bloat
20SELECT
21 schemaname,
22 tablename,
23 pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS total_size,
24 pg_size_pretty(pg_relation_size(schemaname || '.' || tablename)) AS table_size,
25 pg_size_pretty(
26 pg_total_relation_size(schemaname || '.' || tablename)
27 - pg_relation_size(schemaname || '.' || tablename)
28 ) AS index_size
29FROM pg_tables
30WHERE schemaname = 'public'
31ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;
32
33-- Monitor slow queries
34SELECT
35 substring(query, 1, 100) AS short_query,
36 calls,
37 ROUND(total_exec_time::numeric, 2) AS total_ms,
38 ROUND(mean_exec_time::numeric, 2) AS avg_ms,
39 rows
40FROM pg_stat_statements
41ORDER BY mean_exec_time DESC
42LIMIT 20;
โœ“

best practice

Run ANALYZE after bulk data changes. PostgreSQL uses a query planner that relies on table statistics to choose optimal execution plans. Stale statistics lead to suboptimal plans.
Logical Replication

Logical replication replicates data at the transaction level, allowing you to replicate specific tables, filter rows, or transform data. It is essential for zero-downtime migrations and data distribution.

logical-replication.sql
SQL
1-- Publisher (source database)
2ALTER SYSTEM SET wal_level = logical;
3SELECT pg_reload_conf();
4
5CREATE PUBLICATION my_publication FOR TABLE users, orders;
6-- Or replicate all tables:
7-- CREATE PUBLICATION my_publication FOR ALL TABLES;
8
9-- Subscriber (target database)
10CREATE SUBSCRIPTION my_subscription
11 CONNECTION 'postgresql://user:pass@publisher-host:5432/dbname'
12 PUBLICATION my_publication;
13
14-- Check replication status
15SELECT * FROM pg_stat_subscription;
16SELECT * FROM pg_replication_slots;
17
18-- Refresh a logical replication subscriber after schema changes
19ALTER SUBSCRIPTION my_subscription REFRESH PUBLICATION;