diff --git a/skills/postgres-best-practices/SKILL.md b/skills/postgres-best-practices/SKILL.md index 6324bef..1720cf9 100644 --- a/skills/postgres-best-practices/SKILL.md +++ b/skills/postgres-best-practices/SKILL.md @@ -9,7 +9,7 @@ Guidelines and best practices for working with Postgres, covering schema design, ## Supported Versions -This skill covers PostgreSQL 14 through 18. Version-specific features are tagged (e.g., `[PG15+]`, `[PG18+]`). All queries have been validated against PG14-PG18. +This skill covers PostgreSQL 14 through 18. Version-specific features are tagged (e.g., `[PG15+]`, `[PG18+]`); environment-dependent examples identify required privileges, extensions, or multi-node setup. PostgreSQL provides 5 years of support per major version. Always run the latest minor release. diff --git a/skills/postgres-best-practices/references/backup-restore.md b/skills/postgres-best-practices/references/backup-restore.md index 2ee52a3..3437a0b 100644 --- a/skills/postgres-best-practices/references/backup-restore.md +++ b/skills/postgres-best-practices/references/backup-restore.md @@ -88,11 +88,13 @@ pg_restore -d mydb --data-only backup.dump # Clean (drop) objects before recreating pg_restore -d mydb --clean --if-exists backup.dump -# Continue on errors (useful for partial restores) +# Restore atomically and stop on the first error pg_restore -d mydb --single-transaction -j 1 backup.dump # Note: --single-transaction is incompatible with -j > 1 ``` +Selective restore patterns do not automatically include dependencies. Schema-qualify table patterns when names may exist in multiple schemas, and verify required types, sequences, constraints, and referenced tables separately. + ### Restoring Plain SQL Dumps ```bash @@ -144,9 +146,8 @@ Without statistics, the planner uses default estimates after restore until `ANAL 5. **Drop indexes before restore, recreate after** — faster than incremental index maintenance during bulk inserts 6. **Restore schema first, then data, then indexes**: ```bash - pg_restore -d mydb --schema-only backup.dump + pg_restore -d mydb --section=pre-data backup.dump pg_restore -d mydb --data-only -j 4 backup.dump - # Then recreate indexes (already created by schema restore, but if you dropped them:) pg_restore -d mydb --section=post-data -j 4 backup.dump ``` @@ -251,7 +252,7 @@ recovery_target_action = 'promote' # 'pause' to inspect before promoting ### Recovery Target Options -```sql +```text -- Recover to a specific time recovery_target_time = '2024-06-15 14:30:00+00' @@ -290,8 +291,11 @@ This gives you an exact target to recover to if the operation goes wrong. # List contents without restoring pg_restore --list backup.dump -# Verify a physical backup (PG17+: also works with tar format) +# Verify a plain-format physical backup (PG13+) pg_verifybackup /backup/base_20240615 + +# PG18+: verify a tar-format backup directly +pg_verifybackup --no-parse-wal /backup/base_tar_20240615 ``` ### Test Restores Regularly diff --git a/skills/postgres-best-practices/references/bulk-loading.md b/skills/postgres-best-practices/references/bulk-loading.md index f41687d..d9b316f 100644 --- a/skills/postgres-best-practices/references/bulk-loading.md +++ b/skills/postgres-best-practices/references/bulk-loading.md @@ -124,7 +124,7 @@ COPY orders FROM '/path/to/orders.csv' WITH (FORMAT csv, HEADER true); ALTER TABLE orders ENABLE TRIGGER ALL; ``` -Requires table owner or superuser privileges. +`DISABLE TRIGGER ALL` requires superuser privileges when the table has foreign-key or other internally generated constraint triggers. A table owner can use `DISABLE TRIGGER USER` to disable only user-defined triggers. Disabling constraint triggers can admit invalid data, so validate constraints before re-enabling writes. ### 3. Increase maintenance_work_mem @@ -243,11 +243,13 @@ DROP TABLE orders_old; COMMIT; ``` +`LIKE ... INCLUDING ALL` does not copy foreign keys, triggers, rules, grants, row-level security policies, or publication membership. Recreate and verify those objects before swapping, or use a data-only refresh when the original table's identity and dependencies must remain unchanged. + ## Bulk Updates and Deletes -### Batched Deletes +### Chunked Deletes -Large DELETEs lock rows and generate WAL. Batch them: +Large DELETEs lock rows and generate WAL. This loop limits each statement to 10,000 rows: ```sql -- Delete in batches of 10,000 @@ -270,9 +272,11 @@ BEGIN END $$; ``` -### Batched Updates +The entire `DO` block is still one transaction: locks, WAL, and dead rows accumulate until it finishes. For true transaction-level batching, execute one limited DELETE per client transaction and commit between batches. -Same pattern for large updates: +### Chunked Updates + +The same statement-size pattern works for large updates: ```sql DO $$ @@ -295,6 +299,8 @@ BEGIN END $$; ``` +As with the DELETE loop, the `DO` block commits only once. Drive batches from the client or a transaction-controlling procedure invoked outside an explicit transaction when each batch must commit independently. + ### Partition Drop Instead of Delete If data is partitioned by time, dropping a partition is instant vs. a slow DELETE: diff --git a/skills/postgres-best-practices/references/connection-pooling.md b/skills/postgres-best-practices/references/connection-pooling.md index 904c1b9..4a629e3 100644 --- a/skills/postgres-best-practices/references/connection-pooling.md +++ b/skills/postgres-best-practices/references/connection-pooling.md @@ -29,13 +29,18 @@ SELECT (SELECT setting::int FROM pg_settings WHERE name = 'superuser_reserved_connections') AS reserved FROM pg_stat_activity; --- Memory per backend (approximate) -SELECT pg_size_pretty( - (SELECT setting::bigint * 1024 FROM pg_settings WHERE name = 'work_mem') + - (SELECT setting::bigint * 1024 FROM pg_settings WHERE name = 'temp_buffers') -) AS per_backend_estimate; +-- Potential per-operation and per-session memory settings +SELECT + name, + current_setting(name) AS configured_value, + pg_size_bytes(current_setting(name)) AS configured_bytes +FROM pg_settings +WHERE name IN ('work_mem', 'temp_buffers') +ORDER BY name; ``` +These are not baseline allocations per backend. `work_mem` can be consumed by multiple query operations, while `temp_buffers` is allocated lazily when a session uses temporary tables. + ## PgBouncer Configuration PgBouncer is the most widely used connection pooler for PostgreSQL. @@ -196,7 +201,7 @@ psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer ### Key Commands -```sql +```text -- Pool status (most useful) SHOW POOLS; -- Columns: database, user, cl_active, cl_waiting, sv_active, sv_idle, sv_used, pool_mode @@ -216,6 +221,8 @@ SHOW CONFIG; SHOW MEM; ``` +These commands use PgBouncer's admin protocol and fail if sent directly to PostgreSQL. + ### What to Watch | Metric | Healthy | Problem | diff --git a/skills/postgres-best-practices/references/hot-standby.md b/skills/postgres-best-practices/references/hot-standby.md index 52108de..7c4e839 100644 --- a/skills/postgres-best-practices/references/hot-standby.md +++ b/skills/postgres-best-practices/references/hot-standby.md @@ -94,7 +94,8 @@ FROM pg_stat_replication; ```sql SELECT status, - received_lsn, + written_lsn, + flushed_lsn, latest_end_lsn, latest_end_time, slot_name, diff --git a/skills/postgres-best-practices/references/indexing.md b/skills/postgres-best-practices/references/indexing.md index c5abd9d..c1224b5 100644 --- a/skills/postgres-best-practices/references/indexing.md +++ b/skills/postgres-best-practices/references/indexing.md @@ -75,7 +75,10 @@ CREATE INDEX idx_booking_range ON bookings USING gist(during); -- Matches: WHERE during && '[2024-01-01, 2024-02-01)' -- Used in exclusion constraints -EXCLUDE USING gist (room_id WITH =, during WITH &&) +CREATE EXTENSION IF NOT EXISTS btree_gist; +ALTER TABLE bookings + ADD CONSTRAINT bookings_no_overlap + EXCLUDE USING gist (room_id WITH =, during WITH &&); ``` ### BRIN (Block Range Index) @@ -159,7 +162,9 @@ CREATE INDEX idx_events_type ON events((payload->>'type')); -- Query: WHERE payload->>'type' = 'click' -- Date truncation -CREATE INDEX idx_orders_month ON orders(date_trunc('month', created_at)); +-- timestamptz must be made timezone-independent for an immutable expression +CREATE INDEX idx_orders_month + ON orders(date_trunc('month', created_at AT TIME ZONE 'UTC')); ``` The query must use the same expression for the planner to match the index. @@ -199,13 +204,20 @@ ALTER TABLE events ADD PRIMARY KEY (id, occurred_at); CREATE INDEX idx_events_q1_status ON events_2024_q1((payload->>'status')); ``` -- **CONCURRENTLY on partitioned tables**: `CREATE INDEX CONCURRENTLY` on a partitioned parent creates indexes on each partition one at a time, concurrently. This avoids locking the entire table during index builds on large partitioned tables. +- **CONCURRENTLY on partitioned tables**: PostgreSQL does not support `CREATE INDEX CONCURRENTLY` directly on a partitioned parent. Create an invalid parent index with `ON ONLY`, build matching indexes concurrently on each partition, then attach them: ```sql -CREATE INDEX CONCURRENTLY idx_events_customer ON events(customer_id); +CREATE INDEX idx_events_customer + ON ONLY events ((payload->>'customer_id')); + +CREATE INDEX CONCURRENTLY idx_events_q1_customer + ON events_2024_q1 ((payload->>'customer_id')); + +ALTER INDEX idx_events_customer + ATTACH PARTITION idx_events_q1_customer; ``` -If a concurrent build fails on one partition, the parent index is marked `INVALID`. Fix the failed partition index, then run `ALTER INDEX idx_events_customer ATTACH PARTITION ...` or drop and retry. +Repeat the concurrent build and attach steps for every partition. The parent index becomes valid after all partition indexes are attached. If a child build fails, drop or rebuild that invalid child index before attaching it. - **Index-only scans**: Work across partitions. The planner prunes irrelevant partitions first, then uses index-only scans on the remaining ones. diff --git a/skills/postgres-best-practices/references/logical-replication.md b/skills/postgres-best-practices/references/logical-replication.md index f48612b..fbbc32b 100644 --- a/skills/postgres-best-practices/references/logical-replication.md +++ b/skills/postgres-best-practices/references/logical-replication.md @@ -117,15 +117,19 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO repl_user; ### Decoder Plugins -Postgres supports two decoder plugins: +Postgres supports logical decoding output plugins, including: - **`pgoutput`** (default): built into Postgres, used by native logical replication -- **`wal2json`**: converts WAL to JSON format, useful for CDC integrations (Debezium, etc.) +- **`wal2json`**: an optional third-party plugin that converts WAL to JSON format for CDC integrations The decoder is specified when creating a replication slot manually: ```sql SELECT pg_create_logical_replication_slot('my_slot', 'pgoutput'); --- or +``` + +After installing `wal2json` on the database server: + +```sql SELECT pg_create_logical_replication_slot('my_slot', 'wal2json'); ``` @@ -267,7 +271,7 @@ ALTER SUBSCRIPTION my_sub SET PUBLICATION new_pub; -- Add a publication ALTER SUBSCRIPTION my_sub ADD PUBLICATION extra_pub; --- Remove a publication (PG17+) +-- Remove a publication ALTER SUBSCRIPTION my_sub DROP PUBLICATION old_pub; ``` @@ -551,10 +555,12 @@ Initial sync speed depends on table size and network. For very large tables, con If the subscriber has conflicting data (e.g., duplicate key): ```sql --- Check for errors in pg_stat_subscription +-- PG15+: cumulative subscription apply-error counters SELECT * FROM pg_stat_subscription_stats; ``` +This view reports counters rather than detailed error messages. Check PostgreSQL server logs for the specific conflict; on PG14, logs are the primary source because `pg_stat_subscription_stats` is unavailable. + **PG16+**: Skip a conflicting transaction: ```sql diff --git a/skills/postgres-best-practices/references/major-version-upgrades.md b/skills/postgres-best-practices/references/major-version-upgrades.md index 1ae237f..2a678bc 100644 --- a/skills/postgres-best-practices/references/major-version-upgrades.md +++ b/skills/postgres-best-practices/references/major-version-upgrades.md @@ -21,11 +21,12 @@ ## pg_upgrade (In-Place) -`pg_upgrade` replaces the old cluster's data files with the new version in place, without dumping and reloading data. It supports two modes: +`pg_upgrade` replaces the old cluster's data files with the new version in place, without dumping and reloading data. PG18 supports five transfer modes: | Mode | Flag | Speed | Disk usage | |------|------|-------|------------| | Copy | (default) | Moderate | 2x disk during upgrade | +| Copy file range | `--copy-file-range` (PG18+) | Fast where supported | 2x disk during upgrade | | Link | `--link` | Fast | Minimal extra disk (hard links) | | Swap | `--swap` (PG18+) | Fastest | No extra disk (swaps data dirs) | | Clone | `--clone` | Fast (if filesystem supports reflinks) | Minimal extra disk | @@ -91,7 +92,7 @@ pg_upgrade \ --new-bindir /usr/lib/postgresql/18/bin \ --swap -# Parallel checking (PG18+): speed up pre-checks +# Parallel checking: speed up pre-checks pg_upgrade ... --jobs 4 ``` @@ -103,19 +104,21 @@ pg_ctl start -D /var/lib/postgresql/17/main #### 6. Post-Upgrade Tasks -pg_upgrade generates helper scripts in the current directory after a successful run: +After a successful upgrade, refresh optimizer statistics and remove the old cluster only after verification: ```bash -# Generated by pg_upgrade — analyze all databases (update optimizer statistics) -./analyze_new_cluster.sh +# Update optimizer statistics in stages +vacuumdb --all --analyze-in-stages # Generated by pg_upgrade — delete old cluster data (only after confirming the upgrade works) ./delete_old_cluster.sh ``` +Current `pg_upgrade` releases generate the deletion script but may instruct you to run `vacuumdb` directly instead of generating `analyze_new_cluster.sh`. + ### Optimizer Statistics Preservation (PG18+) -PG18's `pg_upgrade` preserves optimizer statistics by default, eliminating the post-upgrade performance dip while `ANALYZE` runs: +PG18's `pg_upgrade` preserves most optimizer statistics by default, reducing the post-upgrade performance dip: ```bash # Default in PG18: statistics are preserved @@ -125,7 +128,7 @@ pg_upgrade ... pg_upgrade ... --no-statistics ``` -On older versions, always run `ANALYZE` on all databases immediately after upgrading. +Some statistics, including cumulative statistics and statistics that cannot be transferred, still need to be rebuilt. On older versions, always run `ANALYZE` on all databases immediately after upgrading. ## Logical Replication (Minimal Downtime) @@ -164,7 +167,7 @@ Converts a physical standby into a logical subscriber, simplifying the setup: # PG18+: --all flag converts all databases at once pg_createsubscriber \ --pgdata /var/lib/postgresql/18/main \ - --publisher-conninfo "host=old_primary dbname=mydb" \ + --publisher-server "host=old_primary dbname=mydb" \ --all ``` @@ -243,14 +246,11 @@ If using streaming replication: ### 1. Update Optimizer Statistics ```bash -# Run the generated script (analyzes all databases) -./analyze_new_cluster.sh - -# Or manually: -vacuumdb --all --analyze-only --jobs 4 +# Rebuild optimizer statistics in stages +vacuumdb --all --analyze-in-stages --jobs 4 ``` -On PG18+ with `pg_upgrade`, statistics are preserved by default — this step is optional but still recommended after significant schema changes. +On PG18+ with `pg_upgrade`, most optimizer statistics are preserved by default, but analyze tables whose statistics were not transferred and tables changed after the upgrade. ### 2. Check for Invalid Indexes @@ -326,6 +326,6 @@ rm -rf /var/lib/postgresql/16/main # be very careful with this - **pg_upgrade (copy mode)**: Old cluster is intact — start it back up - **pg_upgrade (link mode)**: Old cluster is unusable — restore from backup -- **pg_upgrade (swap mode)**: Run `pg_upgrade --swap` again to reverse the swap +- **pg_upgrade (swap mode)**: The file transfer is destructive; restore the old cluster from backup - **Logical replication**: Point applications back to the old cluster - **Always have a tested backup** regardless of method diff --git a/skills/postgres-best-practices/references/performance-diagnostics.md b/skills/postgres-best-practices/references/performance-diagnostics.md index de38bfd..f845d68 100644 --- a/skills/postgres-best-practices/references/performance-diagnostics.md +++ b/skills/postgres-best-practices/references/performance-diagnostics.md @@ -66,7 +66,7 @@ LIMIT 20; `dead_pct` > 20% = VACUUM is falling behind. Check autovacuum settings. -### Table Bloat Estimate +### Relation Storage Breakdown ```sql SELECT @@ -83,7 +83,7 @@ ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC LIMIT 20; ``` -For precise bloat estimation, use the `pgstattuple` extension (requires superuser or elevated privileges): +This separates heap size from indexes and TOAST; it is not a bloat estimate. For bloat measurements, use the `pgstattuple` extension (requires superuser or elevated privileges): ```sql CREATE EXTENSION IF NOT EXISTS pgstattuple; @@ -190,10 +190,10 @@ CREATE EXTENSION IF NOT EXISTS pg_buffercache; SELECT c.relname, - pg_size_pretty(count(*) * 8192) AS buffered, + pg_size_pretty(count(*) * current_setting('block_size')::bigint) AS buffered, round(100.0 * count(*) / (SELECT setting::int FROM pg_settings WHERE name = 'shared_buffers'), 1) AS pct_of_cache FROM pg_buffercache b -JOIN pg_class c ON c.relfilenode = b.relfilenode +JOIN pg_class c ON pg_relation_filenode(c.oid) = b.relfilenode WHERE b.reldatabase = (SELECT oid FROM pg_database WHERE datname = current_database()) GROUP BY c.relname ORDER BY count(*) DESC @@ -209,15 +209,22 @@ FK columns without indexes cause slow JOINs and slow CASCADE deletes: ```sql SELECT c.conrelid::regclass AS table_name, - a.attname AS fk_column, - c.conname AS constraint_name + c.conname AS constraint_name, + pg_get_constraintdef(c.oid) AS constraint_definition FROM pg_constraint c -JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) WHERE c.contype = 'f' AND NOT EXISTS ( SELECT 1 FROM pg_index i WHERE i.indrelid = c.conrelid - AND a.attnum = ANY(i.indkey) + AND i.indisvalid + AND i.indpred IS NULL + AND i.indnkeyatts >= cardinality(c.conkey) + AND ( + SELECT array_agg(key_attnum ORDER BY ordinality) + FROM unnest(i.indkey::smallint[]) WITH ORDINALITY + AS keys(key_attnum, ordinality) + WHERE ordinality <= cardinality(c.conkey) + ) = c.conkey ); ``` @@ -275,13 +282,16 @@ ORDER BY duration DESC; ### Cancel or Terminate a Query ```sql --- Graceful cancel (sends cancel signal) -SELECT pg_cancel_backend(pid); +-- Replace 12345 with a PID selected from pg_stat_activity. +-- Graceful cancel (sends a cancel signal) +SELECT pg_cancel_backend(12345); -- Force terminate (kills the connection) -SELECT pg_terminate_backend(pid); +SELECT pg_terminate_backend(12345); ``` +Do not target your current session (`pg_backend_pid()`). Prefer cancellation first; terminate only when cancellation does not resolve the problem. + ## Lock Analysis ### Blocked Queries and What's Blocking Them @@ -325,13 +335,15 @@ WHERE NOT bl.granted AND kl.granted; For application-level coordination without row locking: ```sql --- Acquire (blocks until available) +-- Choose one acquisition method, not both. +-- Blocking acquisition: SELECT pg_advisory_lock(hashtext('my_job_name')); --- Try (non-blocking, returns boolean) -SELECT pg_try_advisory_lock(hashtext('my_job_name')); +-- Release once for each successful session-level acquisition: +SELECT pg_advisory_unlock(hashtext('my_job_name')); --- Release +-- Or use a non-blocking acquisition: +SELECT pg_try_advisory_lock(hashtext('my_job_name')); SELECT pg_advisory_unlock(hashtext('my_job_name')); ``` diff --git a/skills/postgres-best-practices/references/query-optimization.md b/skills/postgres-best-practices/references/query-optimization.md index d4ef26e..470f1d2 100644 --- a/skills/postgres-best-practices/references/query-optimization.md +++ b/skills/postgres-best-practices/references/query-optimization.md @@ -266,11 +266,19 @@ Except for `shared_buffers`, these parameters can be overridden in the current s ```sql SET random_page_cost = 1.1; -SET effective_io_concurrency = 200; +-- Some platforms without posix_fadvise only support 0. +DO $$ +BEGIN + PERFORM set_config('effective_io_concurrency', '200', false); +EXCEPTION + WHEN invalid_parameter_value THEN + PERFORM set_config('effective_io_concurrency', '0', false); +END +$$; SET effective_cache_size = '24GB'; -- adjust to your server ``` -These make the planner prefer index scans (correct for SSDs where random reads are fast). +These make the planner more willing to use index scans when measurements show SSD random reads are fast. Some platforms without asynchronous prefetch support cap `effective_io_concurrency` at `0`. ### Asynchronous I/O (PG18+) diff --git a/skills/postgres-best-practices/references/query-patterns.md b/skills/postgres-best-practices/references/query-patterns.md index f9c635a..52dc04e 100644 --- a/skills/postgres-best-practices/references/query-patterns.md +++ b/skills/postgres-best-practices/references/query-patterns.md @@ -232,9 +232,11 @@ INSERT INTO users (email, name) VALUES ('a@b.com', 'Alice') ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name -RETURNING id, (xmax = 0) AS inserted; -- true if inserted, false if updated +RETURNING id; ``` +Do not use `(xmax = 0)` as an inserted-versus-updated contract; it relies on implementation details. On PG18+, use `OLD`/`NEW` in `RETURNING`. On earlier versions, distinguish outcomes in application logic or use separate statements when the distinction matters. + ### OLD/NEW in RETURNING (PG18+) All DML commands support `OLD` and `NEW` references in RETURNING, making it easy to see before/after values: @@ -389,7 +391,7 @@ UPDATE events SET payload = jsonb_set(payload, '{status,code}', '"200"'); Simpler syntax for JSONB access and assignment: ```sql --- Read (equivalent to payload->'address'->>'city') +-- Read JSONB (equivalent to payload->'address'->'city') SELECT payload['address']['city'] FROM events; -- Update (equivalent to jsonb_set) diff --git a/skills/postgres-best-practices/references/schema-design.md b/skills/postgres-best-practices/references/schema-design.md index 6490e0f..001935e 100644 --- a/skills/postgres-best-practices/references/schema-design.md +++ b/skills/postgres-best-practices/references/schema-design.md @@ -75,10 +75,11 @@ By default, NULLs are considered distinct in unique constraints (multiple NULLs ```sql -- Standard: allows multiple rows with NULL in email -CREATE UNIQUE INDEX idx_email ON users(email); +CREATE UNIQUE INDEX idx_email_standard ON users(email); -- PG15+: only one NULL allowed -CREATE UNIQUE INDEX idx_email ON users(email) NULLS NOT DISTINCT; +CREATE UNIQUE INDEX idx_email_nulls_not_distinct + ON users(email) NULLS NOT DISTINCT; ``` ### Naming Conventions @@ -191,7 +192,7 @@ CREATE TABLE reservations ( ); ``` -This eliminates the need for `btree_gist` and manual exclusion constraints for non-overlapping range scenarios. +This eliminates the need to write the exclusion constraint manually. The temporal key is implemented with GiST; scalar key columns such as `room_id int` still need an appropriate GiST operator class, supplied by `btree_gist` for common scalar types. ### NOT ENFORCED Constraints (PG18+) @@ -284,7 +285,6 @@ ALTER TABLE events DETACH PARTITION events_2024_q1 CONCURRENTLY; - Partition key must be part of the primary key and all unique indexes - Indexes defined on parent are auto-created on child partitions - Foreign keys referencing partitioned tables are supported -- Identity columns on partitioned tables require PG17+ - Exclusion constraints on partitioned tables require PG17+ (equality on partition key only) ## Virtual Generated Columns (PG18+) @@ -296,6 +296,7 @@ CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quantity int NOT NULL, unit_price numeric(10,2) NOT NULL, + description text NOT NULL, -- Virtual: computed on read, no storage cost total numeric GENERATED ALWAYS AS (quantity * unit_price) VIRTUAL, -- Stored: persisted on disk, can be indexed diff --git a/skills/postgres-best-practices/references/security-roles.md b/skills/postgres-best-practices/references/security-roles.md index 8d3388d..1f1d554 100644 --- a/skills/postgres-best-practices/references/security-roles.md +++ b/skills/postgres-best-practices/references/security-roles.md @@ -239,11 +239,13 @@ ALTER TABLE orders ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.current_tenant')::int); --- Set per-connection +-- Test through a non-owner application role; table owners bypass RLS by default. +SET ROLE app_user; SET app.current_tenant = '42'; -- All queries on orders now automatically filter by tenant_id = 42 SELECT * FROM orders; -- only sees tenant 42's orders +RESET ROLE; ``` ### Policy Types @@ -252,29 +254,29 @@ SELECT * FROM orders; -- only sees tenant 42's orders -- SELECT policy (restrict which rows can be read) CREATE POLICY read_own ON documents FOR SELECT - USING (owner_id = current_user_id()); + USING (owner_id = current_setting('app.current_user_id')::bigint); -- INSERT policy (restrict which rows can be inserted) CREATE POLICY insert_own ON documents FOR INSERT - WITH CHECK (owner_id = current_user_id()); + WITH CHECK (owner_id = current_setting('app.current_user_id')::bigint); -- UPDATE policy (restrict which rows can be updated, and what values are allowed) CREATE POLICY update_own ON documents FOR UPDATE - USING (owner_id = current_user_id()) -- which rows can be selected for update - WITH CHECK (owner_id = current_user_id()); -- what the row must look like after update + USING (owner_id = current_setting('app.current_user_id')::bigint) -- rows selectable for update + WITH CHECK (owner_id = current_setting('app.current_user_id')::bigint); -- required post-update value -- DELETE policy CREATE POLICY delete_own ON documents FOR DELETE - USING (owner_id = current_user_id()); + USING (owner_id = current_setting('app.current_user_id')::bigint); -- ALL (applies to all commands) CREATE POLICY full_access ON documents FOR ALL - USING (owner_id = current_user_id()) - WITH CHECK (owner_id = current_user_id()); + USING (owner_id = current_setting('app.current_user_id')::bigint) + WITH CHECK (owner_id = current_setting('app.current_user_id')::bigint); ``` ### Multiple Policies @@ -287,7 +289,7 @@ CREATE POLICY see_active ON orders USING (status = 'active'); CREATE POLICY see_own ON orders - USING (user_id = current_user_id()); + USING (user_id = current_setting('app.current_user_id')::bigint); -- User sees rows that are active OR owned by them -- Restrictive: combined with AND (with permissive policies) diff --git a/skills/postgres-best-practices/references/transaction-isolation.md b/skills/postgres-best-practices/references/transaction-isolation.md index 0a7ca89..a05ce45 100644 --- a/skills/postgres-best-practices/references/transaction-isolation.md +++ b/skills/postgres-best-practices/references/transaction-isolation.md @@ -198,7 +198,7 @@ Use SERIALIZABLE to prevent write skew. ## SERIALIZABLE -The strongest level. Transactions behave **as if they executed one at a time** (serially). PostgreSQL uses Serializable Snapshot Isolation (SSI) — an optimistic approach that detects conflicts at commit time rather than acquiring heavy locks upfront. +The strongest level. Transactions behave **as if they executed one at a time** (serially). PostgreSQL uses Serializable Snapshot Isolation (SSI) — an optimistic approach that detects conflicts during statement execution or at commit rather than acquiring heavy locks upfront. ### Behavior @@ -251,7 +251,7 @@ Keep transactions short regardless of isolation level. ### Pitfall: Mixing Isolation Levels -If Transaction A is SERIALIZABLE but Transaction B is READ COMMITTED, you only get SERIALIZABLE guarantees for A's view of the data. B can still see intermediate states. For full serializable behavior, **all participating transactions** must use SERIALIZABLE. +If Transaction A is SERIALIZABLE but Transaction B is READ COMMITTED, you only get SERIALIZABLE guarantees for A's view of the data. B never sees uncommitted intermediate states, but it can observe newer committed states between statements and produce non-serializable outcomes. For full serializable behavior, **all participating transactions** must use SERIALIZABLE. ### Pitfall: DDL and Isolation