Skip to content
Back to skills

Postgresql

ASecurity

PostgreSQL optimization, indexing strategies, CTEs, window functions, extensions (pg_trgm, PostGIS), partitioning, and production tuning

  • 2 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added October 26, 2026
databasesgosqlnodenodejsexpressfastapidjangoapidatabaseperformance

Works with

  • api

Security analysis

A100/100

Scanned October 26, 2026

npx -y skills add nhan03bit/ultrathink --skill postgresql --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Postgresql?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Postgresql
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/nhan03bit-postgresql/badge)](https://www.skillsdirectory.com/skills/nhan03bit-postgresql)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: postgresql
description: PostgreSQL optimization, indexing strategies, CTEs, window functions, extensions (pg_trgm, PostGIS), partitioning, and production tuning
layer: domain
category: database
triggers:
  - "postgresql"
  - "postgres"
  - "pg"
  - "sql optimization"
  - "database indexing"
  - "cte"
  - "window function"
  - "database partitioning"
inputs:
  - "Schema design requirements"
  - "Query performance issues"
  - "Scaling requirements"
outputs:
  - "Optimized schema designs"
  - "Query optimization strategies"
  - "Indexing recommendations"
linksTo:
  - prisma
  - drizzle
  - supabase
  - django
  - fastapi
  - nodejs
  - golang
linkedFrom:
  - error-handling
  - monitoring
preferredNextSkills:
  - prisma
  - drizzle
  - supabase
fallbackSkills:
  - mongodb
riskLevel: low
memoryReadPolicy: selective
memoryWritePolicy: none
sideEffects: []
---

# PostgreSQL Domain Skill

## Purpose

Provide expert-level guidance on PostgreSQL schema design, indexing strategies, query optimization, CTEs, window functions, partitioning, extensions, connection management, and production tuning.

## Key Patterns

### 1. Schema Design

```sql
-- Use UUIDs for distributed-safe primary keys
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- Base table pattern with audit fields
CREATE TABLE users (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email       TEXT NOT NULL,
    name        TEXT NOT NULL,
    role        TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')),
    is_active   BOOLEAN NOT NULL DEFAULT true,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at  TIMESTAMPTZ, -- Soft delete

    CONSTRAINT users_email_unique UNIQUE (email),
    CONSTRAINT users_email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
);

-- Auto-update updated_at
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = now();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER users_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at();

-- Partial index: only index active, non-deleted users
CREATE INDEX idx_users_email_active
    ON users (email)
    WHERE is_active = true AND deleted_at IS NULL;

-- Composite index for common queries
CREATE INDEX idx_users_role_created
    ON users (role, created_at DESC)
    WHERE deleted_at IS NULL;
```

### 2. Indexing Strategies

```sql
-- B-tree (default): Equality and range queries
CREATE INDEX idx_orders_user_date ON orders (user_id, created_at DESC);

-- GIN: Array, JSONB, and full-text search
CREATE INDEX idx_products_tags ON products USING GIN (tags);
CREATE INDEX idx_products_metadata ON products USING GIN (metadata jsonb_path_ops);
CREATE INDEX idx_products_search ON products USING GIN (to_tsvector('english', name || ' ' || description));

-- GiST: Geometric, range, and full-text search
CREATE INDEX idx_locations_coords ON locations USING GIST (coordinates);

-- BRIN: Large, naturally ordered tables (timestamps, sequential IDs)
CREATE INDEX idx_logs_created ON logs USING BRIN (created_at);

-- Partial index: Only index what you query
CREATE INDEX idx_orders_pending ON orders (created_at)
    WHERE status = 'pending'; -- Much smaller than full index

-- Covering index (INCLUDE): Avoid table lookups
CREATE INDEX idx_orders_user_covering ON orders (user_id)
    INCLUDE (status, total, created_at);
    -- SELECT status, total, created_at FROM orders WHERE user_id = $1
    -- Can be answered from index alone (index-only scan)

-- Expression index
CREATE INDEX idx_users_lower_email ON users (lower(email));

-- Multicolumn index ordering matters!
-- This index: (user_id, created_at DESC)
-- Supports: WHERE user_id = ? ORDER BY created_at DESC  ✓
-- Supports: WHERE user_id = ?                            ✓
-- Does NOT support: WHERE created_at > ?                  ✗ (user_id must be first)
```

### 3. CTEs and Window Functions

```sql
-- Recursive CTE: Organization hierarchy
WITH RECURSIVE org_tree AS (
    -- Base case: top-level departments
    SELECT id, name, parent_id, 0 AS depth, ARRAY[id] AS path
    FROM departments
    WHERE parent_id IS NULL

    UNION ALL

    -- Recursive case
    SELECT d.id, d.name, d.parent_id, t.depth + 1, t.path || d.id
    FROM departments d
    JOIN org_tree t ON d.parent_id = t.id
    WHERE t.depth < 10  -- Safety limit
)
SELECT * FROM org_tree ORDER BY path;

-- Window functions: Running totals, rankings, moving averages
SELECT
    date,
    revenue,
    SUM(revenue) OVER (ORDER BY date) AS running_total,
    AVG(revenue) OVER (
        ORDER BY date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS moving_avg_7d,
    RANK() OVER (ORDER BY revenue DESC) AS revenue_rank,
    LAG(revenue, 1) OVER (ORDER BY date) AS previous_day,
    revenue - LAG(revenue, 1) OVER (ORDER BY date) AS day_over_day
FROM daily_revenue
WHERE date >= CURRENT_DATE - INTERVAL '30 days';

-- Deduplication with ROW_NUMBER
WITH ranked AS (
    SELECT *,
        ROW_NUMBER() OVER (
            PARTITION BY email
            ORDER BY created_at DESC
        ) AS rn
    FROM user_imports
)
DELETE FROM user_imports
WHERE id IN (SELECT id FROM ranked WHERE rn > 1);

-- Percentile calculations
SELECT
    department,
    PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY salary) AS median_salary,
    PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY salary) AS p90_salary,
    PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY salary) AS p99_salary
FROM employees
GROUP BY department;
```

### 4. Advanced Queries

```sql
-- UPSERT (INSERT ... ON CONFLICT)
INSERT INTO page_views (page_id, date, count)
VALUES ($1, CURRENT_DATE, 1)
ON CONFLICT (page_id, date)
DO UPDATE SET count = page_views.count + 1;

-- Batch upsert with UNNEST
INSERT INTO products (id, name, price)
SELECT * FROM UNNEST($1::uuid[], $2::text[], $3::int[])
ON CONFLICT (id)
DO UPDATE SET
    name = EXCLUDED.name,
    price = EXCLUDED.price,
    updated_at = now();

-- LATERAL JOIN: For-each-row subquery
SELECT u.id, u.name, recent_orders.*
FROM users u
CROSS JOIN LATERAL (
    SELECT id, total, created_at
    FROM orders
    WHERE orders.user_id = u.id
    ORDER BY created_at DESC
    LIMIT 3
) recent_orders;

-- Full-text search with ranking
SELECT
    id, name, description,
    ts_rank(search_vector, query) AS rank
FROM products,
    to_tsquery('english', 'wireless & (headphone | earphone)') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;

-- JSONB querying
SELECT * FROM events
WHERE metadata @> '{"type": "purchase"}'  -- Contains
  AND metadata ->> 'amount' > '100'        -- Text comparison
  AND metadata ? 'coupon_code';            -- Key exists
```

### 5. Partitioning

```sql
-- Range partitioning for time-series data
CREATE TABLE events (
    id          UUID NOT NULL DEFAULT gen_random_uuid(),
    event_type  TEXT NOT NULL,
    payload     JSONB NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);

-- Create partitions
CREATE TABLE events_2024_q1 PARTITION OF events
    FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');

CREATE TABLE events_2024_q2 PARTITION OF events
    FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');

-- Auto-create future partitions with pg_partman
CREATE EXTENSION pg_partman;
SELECT partman.create_parent(
    p_parent_table := 'public.events',
    p_control := 'created_at',
    p_type := 'native',
    p_interval := '1 month',
    p_premake := 3  -- Create 3 months ahead
);
```

### 6. Performance Tuning

```sql
-- EXPLAIN ANALYZE to understand query plans
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders
WHERE user_id = $1 AND status = 'pending'
ORDER BY created_at DESC
LIMIT 10;

-- Key things to look for:
-- Seq Scan on large tables → Need an index
-- Nested Loop with large row counts → Consider Hash Join
-- Sort with high cost → Add index with matching ORDER BY
-- Buffers: shared hit vs read → Cache hit ratio
```

```ini
# postgresql.conf tuning for production (assuming 16GB RAM, SSD)
shared_buffers = 4GB              # 25% of RAM
effective_cache_size = 12GB       # 75% of RAM
work_mem = 64MB                   # Per-operation sort/hash memory
maintenance_work_mem = 1GB        # For VACUUM, CREATE INDEX
wal_buffers = 64MB
max_connections = 200
random_page_cost = 1.1            # SSD (default 4.0 is for HDD)
effective_io_concurrency = 200    # SSD
max_parallel_workers_per_gather = 4
max_worker_processes = 8
```

## Best Practices

1. **Always use `TIMESTAMPTZ`** not `TIMESTAMP` -- avoid timezone bugs
2. **Use `TEXT`** instead of `VARCHAR(n)` -- no performance difference, less migration pain
3. **Index foreign keys** -- PostgreSQL does not auto-index them
4. **Use partial indexes** for queries that filter on common conditions
5. **Use `EXPLAIN (ANALYZE, BUFFERS)`** to diagnose slow queries
6. **Set `statement_timeout`** to prevent runaway queries
7. **Use connection pooling** (PgBouncer) -- PostgreSQL handles ~500 connections max efficiently
8. **Run `VACUUM ANALYZE`** regularly (autovacuum should be enabled)
9. **Use `pg_stat_statements`** extension to find slow queries
10. **Prefer `bigint`** over `integer` for primary keys if you might exceed 2B rows

## Common Pitfalls

| Pitfall | Impact | Fix |
|---------|--------|-----|
| Missing indexes on foreign keys | Slow JOINs and CASCADE deletes | Index all FK columns |
| Using `SELECT *` | Fetches unnecessary data, prevents index-only scans | Select only needed columns |
| N+1 queries from ORM | Excessive round trips | Use JOINs, batch loading |
| Not using connection pooling | Connection exhaustion | PgBouncer in transaction mode |
| `LIKE '%term%'` without trigram index | Full table scan | Use `pg_trgm` extension with GIN index |
| Large transactions holding locks | Blocking other queries | Keep transactions short, use SKIP LOCKED |
| Not monitoring `pg_stat_user_tables` | Undetected table bloat | Monitor dead tuples, tune autovacuum |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.