Fix live-tested PostgreSQL examples

Correct executable SQL, operational commands, version gates, and semantic guidance found through PostgreSQL 14/17 integration testing.
This commit is contained in:
sav-maya
2026-08-20 13:04:12 -07:00
parent a507eee6c0
commit 01c9ee75a3
14 changed files with 138 additions and 77 deletions
+1 -1
View File
@@ -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.
@@ -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
@@ -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:
@@ -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 |
@@ -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,
@@ -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.
@@ -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
@@ -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
@@ -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'));
```
@@ -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+)
@@ -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)
@@ -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
@@ -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)
@@ -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