Skip to content
Back to skills

Querymaster Postgresql

ASecurity

Child skill of querymaster for PostgreSQL (standard, Azure Database, Aurora): psycopg2 and Node connection patterns, read-only session and query best practices. Activates when the resolved engine is PostgreSQL.

  • 17 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 2, 2026
ai-agentsjavascriptpythongojavasqlnodeazureapidatabasesecurity

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 20, 2026

npx -y skills add CarlosCaPe/octorato --skill querymaster-postgresql --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Querymaster Postgresql?

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

Security grade badge for Querymaster Postgresql
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/carloscape-querymaster-postgresql/badge)](https://www.skillsdirectory.com/skills/carloscape-querymaster-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: querymaster-postgresql
description: "Child skill of querymaster for PostgreSQL (standard, Azure Database, Aurora): psycopg2 and Node connection patterns, read-only session and query best practices. Activates when the resolved engine is PostgreSQL."
---

# QueryMaster — PostgreSQL Engine Skill

> Child skill of `querymaster`. Activated when engine is PostgreSQL.
> Covers: Azure Database for PostgreSQL, standard PostgreSQL, Aurora PostgreSQL.
>
> **Knowledge sources**: 31 production skills (~4,100 lines), 94 SQL fix scripts across 79 tickets.
> Last harvested: 2026-03-18

## Connection Patterns

### Python (psycopg2)
```python
import psycopg2
conn = psycopg2.connect(
    host=env["DB_HOST"],
    port=env.get("DB_PORT", 5432),
    user=env["DB_USER"],
    password=env["DB_PASSWORD"],
    dbname=env["DB_NAME"],
    sslmode=env.get("DB_SSLMODE", "require"),
    connect_timeout=10,
)
conn.set_session(readonly=True)  # when --readonly
```

### Node.js (pg)
```javascript
const { Pool } = require('pg');
const pool = new Pool({
    host: process.env.DB_HOST,
    port: process.env.DB_PORT || 5432,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME,
    ssl: { rejectUnauthorized: true },
    max: 5,
    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 10000,
});
```

### Pool Sizing Rule
```
max_pool_size = (core_count * 2) + effective_spindle_count
Azure B1ms (1 vCPU): max = 3
Azure D2s_v3 (2 vCPU): max = 5
```

### connections.json entry
```json
{
  "prod_client": {
    "engine": "postgresql",
    "env_prefix": "DB",
    "env_file": "~/path/to/client-a/.env",
    "defaults": { "port": 5432, "sslmode": "require" }
  }
}
```

---

## Best Practices (Embedded from 31 production client skills)

### Query Generation Rules

1. **Always qualify with schema** — `schema.table` not just `table`
2. **Naming conventions**: `snake_case` for everything. Index: `idx_{table}_{cols}`, unique: `uidx_`, PK: `pk_{table}`, FK: `fk_{child}_{parent}`, check: `chk_{table}_{col}`
3. **Timestamps**: Always `timestamptz`, never `timestamp without time zone`. Default: `now()` not `CURRENT_TIMESTAMP`
4. **Primary keys**: Every table MUST have a PK. Prefer `bigint GENERATED BY DEFAULT AS IDENTITY`
5. **EXPLAIN ANALYZE** — Always suggest for performance questions, but warn it EXECUTES the query

### Schema Discovery Queries

```sql
-- List all schemas
SELECT schema_name FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
ORDER BY schema_name;

-- Tables with row counts and sizes
SELECT schemaname, relname AS table_name, n_live_tup AS estimated_rows,
    pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS total_size
FROM pg_stat_user_tables ORDER BY n_live_tup DESC;

-- Tables without primary keys
SELECT t.tablename
FROM pg_tables t
LEFT JOIN (SELECT tc.table_name FROM information_schema.table_constraints tc
    WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = 'public'
) pk ON pk.table_name = t.tablename
WHERE t.schemaname = 'public' AND pk.table_name IS NULL
ORDER BY t.tablename;

-- Index usage stats
SELECT schemaname, relname, indexrelname, idx_scan, idx_tup_read, idx_tup_fetch,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes ORDER BY idx_scan ASC;

-- Active connections
SELECT pid, usename, application_name, client_addr, state, query_start, query
FROM pg_stat_activity WHERE state != 'idle' ORDER BY query_start;

-- Foreign key relationships
SELECT tc.constraint_name, tc.table_name, kcu.column_name,
    ccu.table_name AS references_table, ccu.column_name AS references_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
ORDER BY tc.table_name;
```

---

## Autovacuum & Bloat Management

PostgreSQL uses MVCC — every UPDATE creates a new row version, DELETE marks rows dead. Dead tuples accumulate as **bloat** until VACUUM reclaims space.

### Monitor dead tuples and vacuum activity
```sql
SELECT schemaname, relname, n_live_tup, n_dead_tup,
    ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
    last_vacuum, last_autovacuum, last_analyze, last_autoanalyze
FROM pg_stat_user_tables WHERE n_dead_tup > 1000 ORDER BY n_dead_tup DESC;
```

### Autovacuum trigger formula
`dead_tuples > threshold + scale_factor × n_live_tup`

For 1M-row table: default (0.20) = vacuum at 200,050 dead tuples. Tuned (0.05) = 50,050.

### Tune autovacuum for high-churn tables
```sql
ALTER TABLE public."AuditLog" SET (
    autovacuum_vacuum_scale_factor = 0.05,   -- vacuum at 5% dead (default 20%)
    autovacuum_analyze_scale_factor = 0.02   -- analyze at 2% changes (default 10%)
);
```

### Decision matrix

| Dead tuple % | Action |
|-------------|--------|
| < 10% | Normal — autovacuum handling it |
| 10-30% | Lower `autovacuum_vacuum_scale_factor` |
| > 30% | Immediate manual `VACUUM`; then tune autovacuum |
| After bulk DELETE/UPDATE | Run `VACUUM ANALYZE` manually |

### Reindex for index bloat
```sql
REINDEX INDEX CONCURRENTLY public."ix_auditlog_createddate";

-- Check for invalid indexes (failed concurrent operations)
SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE NOT indisvalid;
```

**Gotchas**: Never disable autovacuum (transaction ID wraparound catastrophe). `VACUUM FULL` acquires `AccessExclusiveLock` — blocks all access. `VACUUM` reclaims space for reuse but does NOT return it to the OS.

---

## Fillfactor & HOT Update Tuning

Fillfactor controls how full each 8KB page is filled during INSERT, leaving room for in-page HOT updates.

### Analyze workload
```sql
SELECT relname, n_tup_ins AS inserts, n_tup_upd AS updates,
    ROUND(100.0 * n_tup_upd / NULLIF(n_tup_ins + n_tup_upd + n_tup_del, 0), 1) AS update_pct
FROM pg_stat_user_tables WHERE schemaname = 'public' ORDER BY n_tup_upd DESC;
```

| Update % | Fillfactor | Rationale |
|----------|------------|-----------|
| < 10% | 100 (default) | Mostly INSERT-only, maximize density |
| 10-50% | 90 | Balance between density and HOT updates |
| > 50% | 80-85 | Heavy UPDATE workload, maximize HOT success |
| Append-only (logs) | 100 | Never updated |

### Validate HOT effectiveness
```sql
SELECT relname, n_tup_hot_upd, n_tup_upd,
    ROUND(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 1) AS hot_pct
FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY n_tup_upd DESC;
```
Target: HOT update percentage > 90% for update-heavy tables.

---

## EXPLAIN ANALYZE Validation

### Usage
```sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT "HospitalId", COUNT(*) FROM public."AppointmentInfo"
WHERE "StatusId" = 1 GROUP BY "HospitalId";
```

### Key metrics

| Metric | Red Flag |
|--------|----------|
| `Seq Scan` on table > 10K rows with WHERE | Missing index |
| `Rows Removed by Filter` >> `actual rows` | Wrong/missing index |
| `shared read` high | I/O problem, missing cache |
| `actual rows` vs `rows` large mismatch | Stale stats — run ANALYZE |
| `Nested Loop` with `loops=100000` | Bad JOIN order, missing index on inner |
| `Sort Method: external merge` | Increase `work_mem` or add sort-matching index |

### Before/after comparison pattern
```sql
-- Before: Seq Scan, 45ms, 8234 buffers
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM public."Recording" WHERE "HospitalId" = 42;
-- After adding index: Index Scan, 0.8ms, 12 buffers
```

**Gotcha**: Always `ANALYZE` the table before running EXPLAIN if data was recently loaded. Wrap destructive statements: `BEGIN; EXPLAIN ANALYZE DELETE ...; ROLLBACK;`

---

## pg_stat_statements Observability

### Enable (Azure Flexible Server)
```text
shared_preload_libraries = pg_stat_statements   -- requires restart
pg_stat_statements.track = all
pg_stat_statements.max = 5000
```
```sql
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
```

### Top queries by total time
```sql
SELECT calls, ROUND(total_exec_time::numeric, 2) AS total_ms,
    ROUND(mean_exec_time::numeric, 2) AS mean_ms, rows, LEFT(query, 120) AS query_preview
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
```

### Top queries by I/O
```sql
SELECT calls, shared_blks_read + shared_blks_hit AS total_blks,
    ROUND(100.0 * shared_blks_hit / NULLIF(shared_blks_read + shared_blks_hit, 0), 2) AS cache_hit_pct,
    ROUND(mean_exec_time::numeric, 2) AS mean_ms, LEFT(query, 120) AS query_preview
FROM pg_stat_statements ORDER BY shared_blks_read DESC LIMIT 10;
```

### Action thresholds

| Metric | Threshold |
|--------|-----------|
| `total_exec_time` | Top query > 50% of total |
| `mean_exec_time` | > 100ms for OLTP queries |
| `calls` | > 10K calls/hour |
| `rows` | > 10K rows per call (missing pagination?) |
| `cache_hit_pct` | < 95% = under-provisioned memory |

**Gotcha**: The `query` column normalizes literals to `$1, $2, ...` — you cannot see actual parameter values. `pg_stat_statements_reset()` clears ALL stats (no per-query reset).

---

## Index Creation (CONCURRENTLY)

### Always use CONCURRENTLY on production
```sql
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_appointmentinfo_hospitalid
    ON public."AppointmentInfo" ("HospitalId");
```

### Lock comparison

| Mode | Lock | Blocks Writes? |
|------|------|----------------|
| `CREATE INDEX` | `SHARE` | Yes |
| `CREATE INDEX CONCURRENTLY` | `SHARE UPDATE EXCLUSIVE` | No |

### Covering index (Index-Only Scans)
```sql
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_vwr_animalid_covering
    ON analytics."events" ("entity_id")
    INCLUDE ("AnimalName", "Species", "Breed");
```

### Partitioned table workaround
`CONCURRENTLY` is NOT supported on partitioned parents. Workaround:
1. `CREATE INDEX ON ONLY parent_table (col)` — marks parent index as invalid
2. `CREATE INDEX CONCURRENTLY ON each_partition (col)` — non-blocking per child
3. `ALTER INDEX parent_idx ATTACH PARTITION child_idx` — links them
4. Once all attached, parent index becomes valid automatically

**Gotchas**: Cannot run inside a transaction or DO block. Failed builds leave INVALID indexes — check `pg_index WHERE NOT indisvalid`. `IF NOT EXISTS` does NOT rebuild invalid indexes. Concurrent builds take ~2x longer (two table scans).

---

## Connection Pooling & Timeout Safety

### Server parameters
```text
statement_timeout = 30000             -- 30s for app queries
idle_in_transaction_session_timeout = 60000   -- kill idle-in-transaction after 60s
log_min_duration_statement = 250      -- log queries > 250ms
```

### Temporary override for migrations
```sql
BEGIN;
    SET LOCAL statement_timeout = '5min';  -- only within this transaction
    ALTER TABLE public."LargeTable" ADD COLUMN "IsActive" boolean NOT NULL DEFAULT true;
COMMIT;
```

### Monitor connections
```sql
SELECT state, COUNT(*) FROM pg_stat_activity GROUP BY state ORDER BY COUNT(*) DESC;

SELECT usename, application_name, state, NOW() - query_start AS duration,
    LEFT(query, 80) AS query_preview
FROM pg_stat_activity WHERE state <> 'idle' ORDER BY duration DESC;
```

| Scenario | statement_timeout |
|----------|------------------|
| Web API queries | 30s |
| Migration scripts | 5min (SET LOCAL) |
| Bulk data loads | 10min (SET LOCAL) |
| pg_cron jobs | Default (30s) |

**Gotcha**: `statement_timeout` applies to the entire DO block, not individual statements within it. `SET LOCAL` only works inside a transaction. PgBouncer in transaction mode breaks `SET` — use `SET LOCAL`.

---

## Timestamp Standardization (timestamptz)

### Find non-timestamptz columns
```sql
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND data_type = 'timestamp without time zone'
ORDER BY table_name, ordinal_position;
```

### Convert with UTC reinterpretation
```sql
ALTER TABLE public."MyTable"
    ALTER COLUMN "CreatedDate" TYPE timestamptz
    USING "CreatedDate" AT TIME ZONE 'UTC';
```

### Handle dependent views
Drop views before ALTER, recreate after. Use `pg_get_viewdef()` to save definitions.

| Scenario | Use |
|----------|-----|
| New "when" column | `timestamptz NOT NULL DEFAULT now()` |
| Date only (no time) | `date` |
| Duration | `interval` |
| Legacy `timestamp` | Convert to `timestamptz` |

**Gotcha**: `ALTER COLUMN TYPE` rewrites the table — locks it. Without `AT TIME ZONE 'UTC'`, PostgreSQL assumes session timezone.

---

## Range Partitioning for Growth Tables

### When to partition

| Signal | Partition? |
|--------|-----------|
| Table > 10M rows and growing | Yes |
| Table > 1M rows, date-filtered queries | Yes |
| < 1M rows | No — overhead not justified |
| No date column in WHERE | No — pruning cannot help |
| Need to archive/purge old data | Yes — DROP partition = instant |

### Create partitioned table
```sql
CREATE TABLE public."AuditLog" (
    "AuditLogId" bigint GENERATED BY DEFAULT AS IDENTITY,
    "Action" text NOT NULL,
    "CreatedAt" timestamptz NOT NULL DEFAULT NOW(),
    "Payload" jsonb
) PARTITION BY RANGE ("CreatedAt");

CREATE TABLE public."AuditLog_2026_01" PARTITION OF public."AuditLog"
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
```

### Automate with pg_cron
```sql
SELECT cron.schedule('create_auditlog_partition', '0 3 25 * *',
    $$ DO $$ DECLARE
        v_start date := date_trunc('month', NOW() + INTERVAL '1 month');
        v_end   date := v_start + INTERVAL '1 month';
        v_name  text := 'AuditLog_' || to_char(v_start, 'YYYY_MM');
    BEGIN
        EXECUTE format('CREATE TABLE IF NOT EXISTS public.%I PARTITION OF public."AuditLog"
            FOR VALUES FROM (%L) TO (%L)', v_name, v_start, v_end);
    END $$; $$);
```

### Archive old partitions
```sql
ALTER TABLE public."AuditLog" DETACH PARTITION public."AuditLog_2023_01";
ALTER TABLE public."AuditLog_2023_01" SET SCHEMA archive;  -- or DROP TABLE
```

**Critical gotchas**:
- PK must include partition key column — `(Id, CreatedAt)` not just `(Id)`
- FK NOT VALID limitation: FK on partitioned tables cannot use NOT VALID — scans ALL partitions
- Unique indexes must include partition key
- If no partition exists for inserted row's date, INSERT **fails** — create in advance
- No retroactive `ALTER TABLE ... PARTITION BY` — requires data migration

---

## Data Retention Policy Lifecycle

Five-phase approach: Size Analysis → Rule Definition → Maintenance Procedure → Schedule → Validate.

### Phase 1: Size analysis
```sql
SELECT pg_size_pretty(pg_total_relation_size('"ShiftAuditLog"')) AS total_size,
    (SELECT COUNT(*) FROM public."ShiftAuditLog") AS row_count,
    MIN("CreatedDate") AS oldest_row, MAX("CreatedDate") AS newest_row
FROM public."ShiftAuditLog";
```

### Phase 3: Maintenance procedure
```sql
CREATE OR REPLACE PROCEDURE maintenance.purge_shift_audit_log(p_retention_days int DEFAULT 90)
LANGUAGE plpgsql AS $$
DECLARE v_cutoff timestamptz; v_deleted bigint;
BEGIN
    v_cutoff := now() - (p_retention_days || ' days')::interval;
    DELETE FROM public."ShiftAuditLog" WHERE "CreatedDate" < v_cutoff;
    GET DIAGNOSTICS v_deleted = ROW_COUNT;
    RAISE NOTICE 'Purged % rows older than %', v_deleted, v_cutoff;
END; $$;
```

### Phase 4: Schedule
```sql
SELECT cron.schedule('purge-shift-audit-log', '0 3 * * 0',
    $$CALL maintenance.purge_shift_audit_log(90)$$);
```

**Always** index the retention column before scheduling purge jobs. Large deletes cause bloat — schedule VACUUM after.

---

## pg_cron Scheduled Maintenance

```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;

-- Schedule
SELECT cron.schedule('purge-expired-tokens', '0 * * * *',
    $$CALL maintenance.purge_expired_tokens()$$);

-- Validate execution
SELECT jobid, start_time, end_time, status, return_message
FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;

-- Rollback
SELECT cron.unschedule('purge-expired-tokens');
```

**Gotchas**: Runs in `postgres` database by default — use `cron.schedule_in_database()` for others. Requires `shared_preload_libraries` on Azure.

---

## Primary Key Coverage & Identity Columns

### Audit missing PKs
```sql
SELECT t.tablename FROM pg_tables t
LEFT JOIN (SELECT tc.table_name FROM information_schema.table_constraints tc
    WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = 'public'
) pk ON pk.table_name = t.tablename
WHERE t.schemaname = 'public' AND pk.table_name IS NULL ORDER BY t.tablename;
```

### Add PK with identity
```sql
ALTER TABLE public."EmailConfig" ADD COLUMN "Id" bigint GENERATED BY DEFAULT AS IDENTITY;
ALTER TABLE public."EmailConfig" ADD CONSTRAINT pk_emailconfig PRIMARY KEY ("Id");
```

### BIGINT vs UUID

| Type | Use When |
|------|----------|
| `bigint IDENTITY` | Single-database, sequential (default recommendation) |
| `uuid v4` | Distributed systems, external IDs |
| `uuid v7` | Distributed + temporal ordering |

Use `GENERATED BY DEFAULT` (allows explicit INSERT) over `GENERATED ALWAYS` for backward compatibility.

---

## Serial to Identity Conversion

```sql
-- 1. Check current state
SELECT column_name, column_default, is_identity FROM information_schema.columns
WHERE table_name = 'feature_flagss' AND column_name = 'FlagId';

-- 2. Remove old default
ALTER TABLE feature_flags."feature_flagss" ALTER COLUMN "FlagId" DROP DEFAULT;

-- 3. Drop orphaned sequence
DROP SEQUENCE IF EXISTS feature_flags."feature_flagss_FlagId_seq";

-- 4. Add identity
ALTER TABLE feature_flags."feature_flagss" ALTER COLUMN "FlagId"
    ADD GENERATED BY DEFAULT AS IDENTITY;

-- 5. CRITICAL: Sync sequence to max value
SELECT setval(pg_get_serial_sequence('feature_flags."feature_flagss"', 'FlagId'),
    COALESCE((SELECT MAX("FlagId") FROM feature_flags."feature_flagss"), 1));
```

**Gotcha**: Must sync sequence after conversion — skip this and next INSERT gets duplicate key error.

---

## Foreign Key Constraints

### Standard pattern: NOT VALID + VALIDATE
```sql
-- Step 1: Add without scanning existing rows (instant, lightweight lock)
ALTER TABLE public."AppointmentInfo"
    ADD CONSTRAINT fk_appointmentinfo_hospitalid
    FOREIGN KEY ("HospitalId") REFERENCES public."Hospital"("HospitalId")
    NOT VALID;

-- Step 2: Validate existing rows (non-blocking, concurrent reads/writes OK)
ALTER TABLE public."AppointmentInfo"
    VALIDATE CONSTRAINT fk_appointmentinfo_hospitalid;
```

### Lock behavior
- `ADD FK` acquires `SHARE ROW EXCLUSIVE` on both child AND parent
- `NOT VALID` skips row scan — instant creation
- `VALIDATE CONSTRAINT` acquires lighter `SHARE UPDATE EXCLUSIVE` (allows concurrent reads AND writes)

### Pre-requisites
1. No orphans exist (see Orphan Detection below)
2. Parent column has PK or UNIQUE constraint
3. Data types match between child and parent columns

**Gotchas**: Always add supporting index on FK columns — PostgreSQL does NOT auto-create them. On partitioned tables, FK cannot use NOT VALID.

---

## Orphan Detection & FK Rollout

### Detect orphans
```sql
SELECT c."HospitalId", COUNT(*) AS orphan_count
FROM public."AppointmentInfo" c
LEFT JOIN public."Hospital" p ON p."HospitalId" = c."HospitalId"
WHERE p."HospitalId" IS NULL AND c."HospitalId" IS NOT NULL
GROUP BY c."HospitalId" ORDER BY orphan_count DESC;
```

### Remediation strategies

| Strategy | When to Use |
|----------|-------------|
| Delete orphans | Child rows valueless without parent |
| NULL out FK column | Child rows valuable, FK is optional |
| Create missing parents | Parent accidentally deleted |
| Skip FK | Orphans too numerous or business-critical |

Always scan for orphans BEFORE attempting `ADD CONSTRAINT`.

---

## Stored Procedure Hardening

### FK-aware delete ordering
```sql
-- WRONG: delete parent before children → FK violation
DELETE FROM public."Users" WHERE "UserId" = p_user_id;
DELETE FROM public."UserRoles" WHERE "UserId" = p_user_id;  -- too late!

-- RIGHT: children first, then parent
DELETE FROM public."UserRoles" WHERE "UserId" = p_user_id;
DELETE FROM public."UserSessions" WHERE "UserId" = p_user_id;
DELETE FROM public."Users" WHERE "UserId" = p_user_id;  -- safe now
```

### Explicit error handling
```sql
IF NOT EXISTS (SELECT 1 FROM public."Users" WHERE "UserId" = p_user_id) THEN
    RAISE EXCEPTION 'User % not found', p_user_id;
END IF;

DELETE FROM public."Users" WHERE "UserId" = p_user_id;
GET DIAGNOSTICS v_count = ROW_COUNT;

IF v_count <> 1 THEN
    RAISE EXCEPTION 'Expected to delete 1 user, deleted %', v_count;
END IF;
```

### Deterministic behavior
Always use explicit `ORDER BY` in SELECT results — never rely on implicit ordering.

---

## Security Roles & Least-Privilege

### Three standard roles
```sql
CREATE ROLE app_owner NOLOGIN;    -- Schema management, migrations
CREATE ROLE app_user NOLOGIN;     -- Application runtime (DML only)
CREATE ROLE app_readonly NOLOGIN; -- Dashboards, reporting (SELECT only)

GRANT app_readonly TO app_user;
GRANT app_user TO app_owner;
```

### Schema-level grants
```sql
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

GRANT ALL ON SCHEMA public TO app_owner;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_user;
GRANT USAGE ON SCHEMA public TO app_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly;
```

### Default privileges for future objects
```sql
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA public
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA public
    GRANT SELECT ON TABLES TO app_readonly;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA public
    GRANT USAGE ON SEQUENCES TO app_user;
```

### Audit permissions
```sql
SELECT grantee, table_schema, table_name, privilege_type
FROM information_schema.table_privileges
WHERE table_schema = 'public' ORDER BY grantee, table_name;
```

**Azure notes**: Admin user has `azure_pg_admin` (NOT true superuser). Password rotation: use Azure Key Vault. `DROP OWNED BY <role>` drops ALL objects — extreme caution.

---

## DDL Generation Rules

### 1. Idempotent Design (ALWAYS)
```sql
-- Column renames
DO $$ BEGIN
    IF EXISTS (SELECT 1 FROM information_schema.columns
        WHERE table_name = 'Applicant' AND column_name = 'SpeialtyRequirement') THEN
        ALTER TABLE public."Applicant" RENAME COLUMN "SpeialtyRequirement" TO "SpecialtyRequirement";
        RAISE NOTICE 'Renamed column';
    ELSE
        RAISE NOTICE 'Skipped (already renamed)';
    END IF;
END $$;

-- Index creation
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_my_index ON public."MyTable" ("MyColumn");

-- FK constraints
DO $$ BEGIN
    IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints
        WHERE constraint_name = 'fk_my_constraint') THEN
        ALTER TABLE public."MyTable" ADD CONSTRAINT fk_my_constraint
            FOREIGN KEY ("MyColumn") REFERENCES public."OtherTable"("Id");
    END IF;
END $$;
```

### 2. Atomic 3-Phase DDL Scripts
```text
Phase 1: Pre-check — gap analysis, dry-run gate (RAISE EXCEPTION if dry_run = true)
Phase 2: Execute — all changes inside ONE DO block (atomic rollback on failure)
Phase 3: Post-check — verify every expected outcome, report confirmed/failed counts
```
`CREATE INDEX CONCURRENTLY` cannot run inside Phase 2 (needs its own phase outside DO block).

### 3. Backward-Compatible Schema Changes
- Rename columns in table but preserve procedure parameter names
- Application continues working without code changes
- Document the "debt" for future parameter name cleanup

### 4. Safety Guards
- **Never generate** `DROP TABLE` without `IF EXISTS`
- **Never generate** `DELETE FROM` without `WHERE` clause
- **Never generate** `TRUNCATE` without explicit user request + double confirmation
- **Always suggest** `BEGIN; ... ROLLBACK;` wrapper for destructive operations in dry-run
- **Timeout**: `SET statement_timeout = '30s';` prepended to long-running queries
- **FK with NOT VALID first**, then `VALIDATE CONSTRAINT` separately
- **Columns**: Never add NOT NULL without DEFAULT on existing tables (full rewrite)

---

## Common Prompt → SQL Mappings

| User says | Generated SQL |
|-----------|--------------|
| "table sizes" | `pg_stat_user_tables` + `pg_total_relation_size` |
| "slow queries" | `pg_stat_statements ORDER BY mean_exec_time DESC` |
| "missing indexes" | Seq-scan heavy tables from `pg_stat_user_tables` |
| "locks" / "blocking" | `pg_locks JOIN pg_stat_activity` |
| "vacuum status" | `pg_stat_user_tables` last_autovacuum/last_vacuum |
| "connections" | `pg_stat_activity` grouped by state |
| "foreign keys" | `information_schema.table_constraints + key_column_usage` |
| "duplicate rows" | `GROUP BY all columns HAVING COUNT(*) > 1` |
| "table structure" | `information_schema.columns WHERE table_name = ...` |
| "dead tuples" / "bloat" | `pg_stat_user_tables WHERE n_dead_tup > 1000` |
| "orphan records" | `LEFT JOIN parent WHERE parent.id IS NULL` |
| "missing PKs" | `pg_tables LEFT JOIN table_constraints` |
| "timestamp types" | `information_schema.columns WHERE data_type = 'timestamp without time zone'` |
| "permissions" / "grants" | `information_schema.table_privileges` |
| "index bloat" | `pgstattuple` extension or REINDEX CONCURRENTLY |
| "partition info" | `pg_catalog.pg_inherits` + `pg_class` |
| "HOT updates" | `pg_stat_user_tables.n_tup_hot_upd` |
| "serial columns" | `information_schema.columns WHERE column_default LIKE 'nextval%'` |

---

## Error Handling

| Error | Cause | Action |
|-------|-------|--------|
| `connection refused` | Wrong host/port or server down | Verify env vars, check server status |
| `FATAL: password authentication failed` | Wrong credentials | Check .env DB_PASSWORD |
| `SSL SYSCALL error` | Network interruption | Retry with backoff |
| `canceling statement due to statement timeout` | Query too slow | Suggest EXPLAIN ANALYZE, increase --timeout |
| `permission denied for table` | Insufficient privileges | Show current role, suggest GRANT |
| `column "x" does not exist` | Wrong column name or table | Check `information_schema.columns` |
| `relation "x" does not exist` | Wrong table name or schema | Verify with schema-qualified name |
| `violates foreign key constraint` | Orphan data or FK ordering | Check orphans, fix delete order |
| `duplicate key value violates unique` | Duplicate row or sequence desync | Check for dupes, resync sequence with `setval` |
| `deadlock detected` | Concurrent conflicting transactions | Retry with backoff, review lock ordering |
| `could not obtain lock` | DDL waiting for active queries | Check `pg_stat_activity`, retry in maintenance window |

---

## Lessons Learned

> This section is auto-populated when queries fail. Each entry captures the error pattern, root cause, and fix for future reference.

| Date | Error Pattern | Root Cause | Fix |
|------|--------------|-----------|-----|
| 2026-03-17 | `no such column: direction` in Optuna SQLite DB | Column is in `study_directions` table, not `studies` | JOIN `study_directions` on `study_id` |

<!-- New lessons are appended here by the self-improvement pipeline -->

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…