Skill hijo de querymaster para PostgreSQL (estandar, Azure Database, Aurora): patrones de conexion psycopg2 y Node, sesion readonly y buenas practicas de consulta. Se activa cuando el motor resuelto es PostgreSQL.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add CarlosCaPe/octorato --skill querymaster-postgresql --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Querymaster Postgresql?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/carloscape-querymaster-postgresql)More formats (shields.io, HTML) on the badges page.
---
name: querymaster-postgresql
description: "Skill hijo de querymaster para PostgreSQL (estandar, Azure Database, Aurora): patrones de conexion psycopg2 y Node, sesion readonly y buenas practicas de consulta. Se activa cuando el motor resuelto es 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 -->Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!