AGENTS.md
cli-tool/components/skills/database/supabase-postgres-best-practices/AGENTS.mdAGENTS.md
Quality
45/100
Scores the file, not the repository.Length
5,896 words
43 headings · 60 code blocksRepository
30k
— · pushed 0 days agoLast changed
2 days ago
First indexed 2 days ago.1# Postgres Best Practices23**Version 1.0.0**4Supabase5January 202667> This document is optimized for AI agents and LLMs. Rules are prioritized by performance impact.89---1011## Abstract1213Comprehensive Postgres performance optimization guide for developers using Supabase and Postgres. Contains performance rules across 8 categories, prioritized by impact from critical (query performance, connection management) to incremental (advanced features). Each rule includes detailed explanations, incorrect vs. correct SQL examples, query plan analysis, and specific performance metrics to guide automated optimization and code generation.1415---1617## Table of Contents18191. [Query Performance](#query-performance) - **CRITICAL**20 - 1.1 [Add Indexes on WHERE and JOIN Columns](#11-add-indexes-on-where-and-join-columns)21 - 1.2 [Choose the Right Index Type for Your Data](#12-choose-the-right-index-type-for-your-data)22 - 1.3 [Create Composite Indexes for Multi-Column Queries](#13-create-composite-indexes-for-multi-column-queries)23 - 1.4 [Use Covering Indexes to Avoid Table Lookups](#14-use-covering-indexes-to-avoid-table-lookups)24 - 1.5 [Use Partial Indexes for Filtered Queries](#15-use-partial-indexes-for-filtered-queries)25262. [Connection Management](#connection-management) - **CRITICAL**27 - 2.1 [Configure Idle Connection Timeouts](#21-configure-idle-connection-timeouts)28 - 2.2 [Set Appropriate Connection Limits](#22-set-appropriate-connection-limits)29 - 2.3 [Use Connection Pooling for All Applications](#23-use-connection-pooling-for-all-applications)30 - 2.4 [Use Prepared Statements Correctly with Pooling](#24-use-prepared-statements-correctly-with-pooling)31323. [Security & RLS](#security-rls) - **CRITICAL**33 - 3.1 [Apply Principle of Least Privilege](#31-apply-principle-of-least-privilege)34 - 3.2 [Enable Row Level Security for Multi-Tenant Data](#32-enable-row-level-security-for-multi-tenant-data)35 - 3.3 [Optimize RLS Policies for Performance](#33-optimize-rls-policies-for-performance)36374. [Schema Design](#schema-design) - **HIGH**38 - 4.1 [Choose Appropriate Data Types](#41-choose-appropriate-data-types)39 - 4.2 [Index Foreign Key Columns](#42-index-foreign-key-columns)40 - 4.3 [Partition Large Tables for Better Performance](#43-partition-large-tables-for-better-performance)41 - 4.4 [Select Optimal Primary Key Strategy](#44-select-optimal-primary-key-strategy)42 - 4.5 [Use Lowercase Identifiers for Compatibility](#45-use-lowercase-identifiers-for-compatibility)43445. [Concurrency & Locking](#concurrency-locking) - **MEDIUM-HIGH**45 - 5.1 [Keep Transactions Short to Reduce Lock Contention](#51-keep-transactions-short-to-reduce-lock-contention)46 - 5.2 [Prevent Deadlocks with Consistent Lock Ordering](#52-prevent-deadlocks-with-consistent-lock-ordering)47 - 5.3 [Use Advisory Locks for Application-Level Locking](#53-use-advisory-locks-for-application-level-locking)48 - 5.4 [Use SKIP LOCKED for Non-Blocking Queue Processing](#54-use-skip-locked-for-non-blocking-queue-processing)49506. [Data Access Patterns](#data-access-patterns) - **MEDIUM**51 - 6.1 [Batch INSERT Statements for Bulk Data](#61-batch-insert-statements-for-bulk-data)52 - 6.2 [Eliminate N+1 Queries with Batch Loading](#62-eliminate-n1-queries-with-batch-loading)53 - 6.3 [Use Cursor-Based Pagination Instead of OFFSET](#63-use-cursor-based-pagination-instead-of-offset)54 - 6.4 [Use UPSERT for Insert-or-Update Operations](#64-use-upsert-for-insert-or-update-operations)55567. [Monitoring & Diagnostics](#monitoring-diagnostics) - **LOW-MEDIUM**57 - 7.1 [Enable pg_stat_statements for Query Analysis](#71-enable-pgstatstatements-for-query-analysis)58 - 7.2 [Maintain Table Statistics with VACUUM and ANALYZE](#72-maintain-table-statistics-with-vacuum-and-analyze)59 - 7.3 [Use EXPLAIN ANALYZE to Diagnose Slow Queries](#73-use-explain-analyze-to-diagnose-slow-queries)60618. [Advanced Features](#advanced-features) - **LOW**62 - 8.1 [Index JSONB Columns for Efficient Querying](#81-index-jsonb-columns-for-efficient-querying)63 - 8.2 [Use tsvector for Full-Text Search](#82-use-tsvector-for-full-text-search)6465---6667## 1. Query Performance6869**Impact: CRITICAL**7071Slow queries, missing indexes, inefficient query plans. The most common source of Postgres performance issues.7273### 1.1 Add Indexes on WHERE and JOIN Columns7475**Impact: CRITICAL (100-1000x faster queries on large tables)**7677Queries filtering or joining on unindexed columns cause full table scans, which become exponentially slower as tables grow.7879**Incorrect (sequential scan on large table):**8081```sql82-- No index on customer_id causes full table scan83select * from orders where customer_id = 123;8485-- EXPLAIN shows: Seq Scan on orders (cost=0.00..25000.00 rows=100 width=85)86```8788**Correct (index scan):**8990```sql91-- Create index on frequently filtered column92create index orders_customer_id_idx on orders (customer_id);9394select * from orders where customer_id = 123;9596-- EXPLAIN shows: Index Scan using orders_customer_id_idx (cost=0.42..8.44 rows=100 width=85)97-- Index the referencing column98create index orders_customer_id_idx on orders (customer_id);99100select c.name, o.total101from customers c102join orders o on o.customer_id = c.id;103```104105For JOIN columns, always index the foreign key side:106107Reference: https://supabase.com/docs/guides/database/query-optimization108109---110111### 1.2 Choose the Right Index Type for Your Data112113**Impact: HIGH (10-100x improvement with correct index type)**114115Different index types excel at different query patterns. The default B-tree isn't always optimal.116117**Incorrect (B-tree for JSONB containment):**118119```sql120-- B-tree cannot optimize containment operators121create index products_attrs_idx on products (attributes);122select * from products where attributes @> '{"color": "red"}';123-- Full table scan - B-tree doesn't support @> operator124```125126**Correct (GIN for JSONB):**127128```sql129-- GIN supports @>, ?, ?&, ?| operators130create index products_attrs_idx on products using gin (attributes);131select * from products where attributes @> '{"color": "red"}';132-- B-tree (default): =, <, >, BETWEEN, IN, IS NULL133create index users_created_idx on users (created_at);134135-- GIN: arrays, JSONB, full-text search136create index posts_tags_idx on posts using gin (tags);137138-- BRIN: large time-series tables (10-100x smaller)139create index events_time_idx on events using brin (created_at);140141-- Hash: equality-only (slightly faster than B-tree for =)142create index sessions_token_idx on sessions using hash (token);143```144145Index type guide:146147Reference: https://www.postgresql.org/docs/current/indexes-types.html148149---150151### 1.3 Create Composite Indexes for Multi-Column Queries152153**Impact: HIGH (5-10x faster multi-column queries)**154155When queries filter on multiple columns, a composite index is more efficient than separate single-column indexes.156157**Incorrect (separate indexes require bitmap scan):**158159```sql160-- Two separate indexes161create index orders_status_idx on orders (status);162create index orders_created_idx on orders (created_at);163164-- Query must combine both indexes (slower)165select * from orders where status = 'pending' and created_at > '2024-01-01';166```167168**Correct (composite index):**169170```sql171-- Single composite index (leftmost column first for equality checks)172create index orders_status_created_idx on orders (status, created_at);173174-- Query uses one efficient index scan175select * from orders where status = 'pending' and created_at > '2024-01-01';176-- Good: status (=) before created_at (>)177create index idx on orders (status, created_at);178179-- Works for: WHERE status = 'pending'180-- Works for: WHERE status = 'pending' AND created_at > '2024-01-01'181-- Does NOT work for: WHERE created_at > '2024-01-01' (leftmost prefix rule)182```183184**Column order matters** - place equality columns first, range columns last:185186Reference: https://www.postgresql.org/docs/current/indexes-multicolumn.html187188---189190### 1.4 Use Covering Indexes to Avoid Table Lookups191192**Impact: MEDIUM-HIGH (2-5x faster queries by eliminating heap fetches)**193194Covering indexes include all columns needed by a query, enabling index-only scans that skip the table entirely.195196**Incorrect (index scan + heap fetch):**197198```sql199create index users_email_idx on users (email);200201-- Must fetch name and created_at from table heap202select email, name, created_at from users where email = 'user@example.com';203```204205**Correct (index-only scan with INCLUDE):**206207```sql208-- Include non-searchable columns in the index209create index users_email_idx on users (email) include (name, created_at);210211-- All columns served from index, no table access needed212select email, name, created_at from users where email = 'user@example.com';213-- Searching by status, but also need customer_id and total214create index orders_status_idx on orders (status) include (customer_id, total);215216select status, customer_id, total from orders where status = 'shipped';217```218219Use INCLUDE for columns you SELECT but don't filter on:220221Reference: https://www.postgresql.org/docs/current/indexes-index-only-scans.html222223---224225### 1.5 Use Partial Indexes for Filtered Queries226227**Impact: HIGH (5-20x smaller indexes, faster writes and queries)**228229Partial indexes only include rows matching a WHERE condition, making them smaller and faster when queries consistently filter on the same condition.230231**Incorrect (full index includes irrelevant rows):**232233```sql234-- Index includes all rows, even soft-deleted ones235create index users_email_idx on users (email);236237-- Query always filters active users238select * from users where email = 'user@example.com' and deleted_at is null;239```240241**Correct (partial index matches query filter):**242243```sql244-- Index only includes active users245create index users_active_email_idx on users (email)246where deleted_at is null;247248-- Query uses the smaller, faster index249select * from users where email = 'user@example.com' and deleted_at is null;250-- Only pending orders (status rarely changes once completed)251create index orders_pending_idx on orders (created_at)252where status = 'pending';253254-- Only non-null values255create index products_sku_idx on products (sku)256where sku is not null;257```258259Common use cases for partial indexes:260261Reference: https://www.postgresql.org/docs/current/indexes-partial.html262263---264265## 2. Connection Management266267**Impact: CRITICAL**268269Connection pooling, limits, and serverless strategies. Critical for applications with high concurrency or serverless deployments.270271### 2.1 Configure Idle Connection Timeouts272273**Impact: HIGH (Reclaim 30-50% of connection slots from idle clients)**274275Idle connections waste resources. Configure timeouts to automatically reclaim them.276277**Incorrect (connections held indefinitely):**278279```sql280-- No timeout configured281show idle_in_transaction_session_timeout; -- 0 (disabled)282283-- Connections stay open forever, even when idle284select pid, state, state_change, query285from pg_stat_activity286where state = 'idle in transaction';287-- Shows transactions idle for hours, holding locks288```289290**Correct (automatic cleanup of idle connections):**291292```ini293-- Terminate connections idle in transaction after 30 seconds294alter system set idle_in_transaction_session_timeout = '30s';295296-- Terminate completely idle connections after 10 minutes297alter system set idle_session_timeout = '10min';298299-- Reload configuration300select pg_reload_conf();301# pgbouncer.ini302server_idle_timeout = 60303client_idle_timeout = 300304```305306For pooled connections, configure at the pooler level:307308Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT309310---311312### 2.2 Set Appropriate Connection Limits313314**Impact: CRITICAL (Prevent database crashes and memory exhaustion)**315316Too many connections exhaust memory and degrade performance. Set limits based on available resources.317318**Incorrect (unlimited or excessive connections):**319320```sql321-- Default max_connections = 100, but often increased blindly322show max_connections; -- 500 (way too high for 4GB RAM)323324-- Each connection uses 1-3MB RAM325-- 500 connections * 2MB = 1GB just for connections!326-- Out of memory errors under load327```328329**Correct (calculate based on resources):**330331```sql332-- Formula: max_connections = (RAM in MB / 5MB per connection) - reserved333-- For 4GB RAM: (4096 / 5) - 10 = ~800 theoretical max334-- But practically, 100-200 is better for query performance335336-- Recommended settings for 4GB RAM337alter system set max_connections = 100;338339-- Also set work_mem appropriately340-- work_mem * max_connections should not exceed 25% of RAM341alter system set work_mem = '8MB'; -- 8MB * 100 = 800MB max342select count(*), state from pg_stat_activity group by state;343```344345Monitor connection usage:346347Reference: https://supabase.com/docs/guides/platform/performance#connection-management348349---350351### 2.3 Use Connection Pooling for All Applications352353**Impact: CRITICAL (Handle 10-100x more concurrent users)**354355Postgres connections are expensive (1-3MB RAM each). Without pooling, applications exhaust connections under load.356357**Incorrect (new connection per request):**358359```sql360-- Each request creates a new connection361-- Application code: db.connect() per request362-- Result: 500 concurrent users = 500 connections = crashed database363364-- Check current connections365select count(*) from pg_stat_activity; -- 487 connections!366```367368**Correct (connection pooling):**369370```sql371-- Use a pooler like PgBouncer between app and database372-- Application connects to pooler, pooler reuses a small pool to Postgres373374-- Configure pool_size based on: (CPU cores * 2) + spindle_count375-- Example for 4 cores: pool_size = 10376377-- Result: 500 concurrent users share 10 actual connections378select count(*) from pg_stat_activity; -- 10 connections379```380381Pool modes:382- **Transaction mode**: connection returned after each transaction (best for most apps)383- **Session mode**: connection held for entire session (needed for prepared statements, temp tables)384385Reference: https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler386387---388389### 2.4 Use Prepared Statements Correctly with Pooling390391**Impact: HIGH (Avoid prepared statement conflicts in pooled environments)**392393Prepared statements are tied to individual database connections. In transaction-mode pooling, connections are shared, causing conflicts.394395**Incorrect (named prepared statements with transaction pooling):**396397```sql398-- Named prepared statement399prepare get_user as select * from users where id = $1;400401-- In transaction mode pooling, next request may get different connection402execute get_user(123);403-- ERROR: prepared statement "get_user" does not exist404```405406**Correct (use unnamed statements or session mode):**407408```sql409-- Option 1: Use unnamed prepared statements (most ORMs do this automatically)410-- The query is prepared and executed in a single protocol message411412-- Option 2: Deallocate after use in transaction mode413prepare get_user as select * from users where id = $1;414execute get_user(123);415deallocate get_user;416417-- Option 3: Use session mode pooling (port 5432 vs 6543)418-- Connection is held for entire session, prepared statements persist419-- Many drivers use prepared statements by default420-- Node.js pg: { prepare: false } to disable421-- JDBC: prepareThreshold=0 to disable422```423424Check your driver settings:425426Reference: https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pool-modes427428---429430## 3. Security & RLS431432**Impact: CRITICAL**433434Row-Level Security policies, privilege management, and authentication patterns.435436### 3.1 Apply Principle of Least Privilege437438**Impact: MEDIUM (Reduced attack surface, better audit trail)**439440Grant only the minimum permissions required. Never use superuser for application queries.441442**Incorrect (overly broad permissions):**443444```sql445-- Application uses superuser connection446-- Or grants ALL to application role447grant all privileges on all tables in schema public to app_user;448grant all privileges on all sequences in schema public to app_user;449450-- Any SQL injection becomes catastrophic451-- drop table users; cascades to everything452```453454**Correct (minimal, specific grants):**455456```sql457-- Create role with no default privileges458create role app_readonly nologin;459460-- Grant only SELECT on specific tables461grant usage on schema public to app_readonly;462grant select on public.products, public.categories to app_readonly;463464-- Create role for writes with limited scope465create role app_writer nologin;466grant usage on schema public to app_writer;467grant select, insert, update on public.orders to app_writer;468grant usage on sequence orders_id_seq to app_writer;469-- No DELETE permission470471-- Login role inherits from these472create role app_user login password 'xxx';473grant app_writer to app_user;474-- Revoke default public access475revoke all on schema public from public;476revoke all on all tables in schema public from public;477```478479Revoke public defaults:480481Reference: https://supabase.com/blog/postgres-roles-and-privileges482483---484485### 3.2 Enable Row Level Security for Multi-Tenant Data486487**Impact: CRITICAL (Database-enforced tenant isolation, prevent data leaks)**488489Row Level Security (RLS) enforces data access at the database level, ensuring users only see their own data.490491**Incorrect (application-level filtering only):**492493```sql494-- Relying only on application to filter495select * from orders where user_id = $current_user_id;496497-- Bug or bypass means all data is exposed!498select * from orders; -- Returns ALL orders499```500501**Correct (database-enforced RLS):**502503```sql504-- Enable RLS on the table505alter table orders enable row level security;506507-- Create policy for users to see only their orders508create policy orders_user_policy on orders509 for all510 using (user_id = current_setting('app.current_user_id')::bigint);511512-- Force RLS even for table owners513alter table orders force row level security;514515-- Set user context and query516set app.current_user_id = '123';517select * from orders; -- Only returns orders for user 123518create policy orders_user_policy on orders519 for all520 to authenticated521 using (user_id = auth.uid());522```523524Policy for authenticated role:525526Reference: https://supabase.com/docs/guides/database/postgres/row-level-security527528---529530### 3.3 Optimize RLS Policies for Performance531532**Impact: HIGH (5-10x faster RLS queries with proper patterns)**533534Poorly written RLS policies can cause severe performance issues. Use subqueries and indexes strategically.535536**Incorrect (function called for every row):**537538```sql539create policy orders_policy on orders540 using (auth.uid() = user_id); -- auth.uid() called per row!541542-- With 1M rows, auth.uid() is called 1M times543```544545**Correct (wrap functions in SELECT):**546547```sql548create policy orders_policy on orders549 using ((select auth.uid()) = user_id); -- Called once, cached550551-- 100x+ faster on large tables552-- Create helper function (runs as definer, bypasses RLS)553create or replace function is_team_member(team_id bigint)554returns boolean555language sql556security definer557set search_path = ''558as $$559 select exists (560 select 1 from public.team_members561 where team_id = $1 and user_id = (select auth.uid())562 );563$$;564565-- Use in policy (indexed lookup, not per-row check)566create policy team_orders_policy on orders567 using ((select is_team_member(team_id)));568create index orders_user_id_idx on orders (user_id);569```570571Use security definer functions for complex checks:572Always add indexes on columns used in RLS policies:573574Reference: https://supabase.com/docs/guides/database/postgres/row-level-security#rls-performance-recommendations575576---577578## 4. Schema Design579580**Impact: HIGH**581582Table design, index strategies, partitioning, and data type selection. Foundation for long-term performance.583584### 4.1 Choose Appropriate Data Types585586**Impact: HIGH (50% storage reduction, faster comparisons)**587588Using the right data types reduces storage, improves query performance, and prevents bugs.589590**Incorrect (wrong data types):**591592```sql593create table users (594 id int, -- Will overflow at 2.1 billion595 email varchar(255), -- Unnecessary length limit596 created_at timestamp, -- Missing timezone info597 is_active varchar(5), -- String for boolean598 price varchar(20) -- String for numeric599);600```601602**Correct (appropriate data types):**603604```sql605create table users (606 id bigint generated always as identity primary key, -- 9 quintillion max607 email text, -- No artificial limit, same performance as varchar608 created_at timestamptz, -- Always store timezone-aware timestamps609 is_active boolean default true, -- 1 byte vs variable string length610 price numeric(10,2) -- Exact decimal arithmetic611);612-- IDs: use bigint, not int (future-proofing)613-- Strings: use text, not varchar(n) unless constraint needed614-- Time: use timestamptz, not timestamp615-- Money: use numeric, not float (precision matters)616-- Enums: use text with check constraint or create enum type617```618619Key guidelines:620621Reference: https://www.postgresql.org/docs/current/datatype.html622623---624625### 4.2 Index Foreign Key Columns626627**Impact: HIGH (10-100x faster JOINs and CASCADE operations)**628629Postgres does not automatically index foreign key columns. Missing indexes cause slow JOINs and CASCADE operations.630631**Incorrect (unindexed foreign key):**632633```sql634create table orders (635 id bigint generated always as identity primary key,636 customer_id bigint references customers(id) on delete cascade,637 total numeric(10,2)638);639640-- No index on customer_id!641-- JOINs and ON DELETE CASCADE both require full table scan642select * from orders where customer_id = 123; -- Seq Scan643delete from customers where id = 123; -- Locks table, scans all orders644```645646**Correct (indexed foreign key):**647648```sql649create table orders (650 id bigint generated always as identity primary key,651 customer_id bigint references customers(id) on delete cascade,652 total numeric(10,2)653);654655-- Always index the FK column656create index orders_customer_id_idx on orders (customer_id);657658-- Now JOINs and cascades are fast659select * from orders where customer_id = 123; -- Index Scan660delete from customers where id = 123; -- Uses index, fast cascade661select662 conrelid::regclass as table_name,663 a.attname as fk_column664from pg_constraint c665join pg_attribute a on a.attrelid = c.conrelid and a.attnum = any(c.conkey)666where c.contype = 'f'667 and not exists (668 select 1 from pg_index i669 where i.indrelid = c.conrelid and a.attnum = any(i.indkey)670 );671```672673Find missing FK indexes:674675Reference: https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK676677---678679### 4.3 Partition Large Tables for Better Performance680681**Impact: MEDIUM-HIGH (5-20x faster queries and maintenance on large tables)**682683Partitioning splits a large table into smaller pieces, improving query performance and maintenance operations.684685**Incorrect (single large table):**686687```sql688create table events (689 id bigint generated always as identity,690 created_at timestamptz,691 data jsonb692);693694-- 500M rows, queries scan everything695select * from events where created_at > '2024-01-01'; -- Slow696vacuum events; -- Takes hours, locks table697```698699**Correct (partitioned by time range):**700701```sql702create table events (703 id bigint generated always as identity,704 created_at timestamptz not null,705 data jsonb706) partition by range (created_at);707708-- Create partitions for each month709create table events_2024_01 partition of events710 for values from ('2024-01-01') to ('2024-02-01');711712create table events_2024_02 partition of events713 for values from ('2024-02-01') to ('2024-03-01');714715-- Queries only scan relevant partitions716select * from events where created_at > '2024-01-15'; -- Only scans events_2024_01+717718-- Drop old data instantly719drop table events_2023_01; -- Instant vs DELETE taking hours720```721722When to partition:723- Tables > 100M rows724- Time-series data with date-based queries725- Need to efficiently drop old data726727Reference: https://www.postgresql.org/docs/current/ddl-partitioning.html728729---730731### 4.4 Select Optimal Primary Key Strategy732733**Impact: HIGH (Better index locality, reduced fragmentation)**734735Primary key choice affects insert performance, index size, and replication736efficiency.737738**Incorrect (problematic PK choices):**739740```sql741-- identity is the SQL-standard approach742create table users (743 id serial primary key -- Works, but IDENTITY is recommended744);745746-- Random UUIDs (v4) cause index fragmentation747create table orders (748 id uuid default gen_random_uuid() primary key -- UUIDv4 = random = scattered inserts749);750```751752**Correct (optimal PK strategies):**753754```sql755-- Use IDENTITY for sequential IDs (SQL-standard, best for most cases)756create table users (757 id bigint generated always as identity primary key758);759760-- For distributed systems needing UUIDs, use UUIDv7 (time-ordered)761-- Requires pg_uuidv7 extension: create extension pg_uuidv7;762create table orders (763 id uuid default uuid_generate_v7() primary key -- Time-ordered, no fragmentation764);765766-- Alternative: time-prefixed IDs for sortable, distributed IDs (no extension needed)767create table events (768 id text default concat(769 to_char(now() at time zone 'utc', 'YYYYMMDDHH24MISSMS'),770 gen_random_uuid()::text771 ) primary key772);773```774775Guidelines:776- Single database: `bigint identity` (sequential, 8 bytes, SQL-standard)777- Distributed/exposed IDs: UUIDv7 (requires pg_uuidv7) or ULID (time-ordered, no778 fragmentation)779- `serial` works but `identity` is SQL-standard and preferred for new780 applications781- Avoid random UUIDs (v4) as primary keys on large tables (causes index782 fragmentation)783[Identity Columns](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-PARMS-GENERATED-IDENTITY)784785---786787### 4.5 Use Lowercase Identifiers for Compatibility788789**Impact: MEDIUM (Avoid case-sensitivity bugs with tools, ORMs, and AI assistants)**790791PostgreSQL folds unquoted identifiers to lowercase. Quoted mixed-case identifiers require quotes forever and cause issues with tools, ORMs, and AI assistants that may not recognize them.792793**Incorrect (mixed-case identifiers):**794795```sql796-- Quoted identifiers preserve case but require quotes everywhere797CREATE TABLE "Users" (798 "userId" bigint PRIMARY KEY,799 "firstName" text,800 "lastName" text801);802803-- Must always quote or queries fail804SELECT "firstName" FROM "Users" WHERE "userId" = 1;805806-- This fails - Users becomes users without quotes807SELECT firstName FROM Users;808-- ERROR: relation "users" does not exist809```810811**Correct (lowercase snake_case):**812813```sql814-- Unquoted lowercase identifiers are portable and tool-friendly815CREATE TABLE users (816 user_id bigint PRIMARY KEY,817 first_name text,818 last_name text819);820821-- Works without quotes, recognized by all tools822SELECT first_name FROM users WHERE user_id = 1;823-- ORMs often generate quoted camelCase - configure them to use snake_case824-- Migrations from other databases may preserve original casing825-- Some GUI tools quote identifiers by default - disable this826827-- If stuck with mixed-case, create views as a compatibility layer828CREATE VIEW users AS SELECT "userId" AS user_id, "firstName" AS first_name FROM "Users";829```830831Common sources of mixed-case identifiers:832833Reference: https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS834835---836837## 5. Concurrency & Locking838839**Impact: MEDIUM-HIGH**840841Transaction management, isolation levels, deadlock prevention, and lock contention patterns.842843### 5.1 Keep Transactions Short to Reduce Lock Contention844845**Impact: MEDIUM-HIGH (3-5x throughput improvement, fewer deadlocks)**846847Long-running transactions hold locks that block other queries. Keep transactions as short as possible.848849**Incorrect (long transaction with external calls):**850851```sql852begin;853select * from orders where id = 1 for update; -- Lock acquired854855-- Application makes HTTP call to payment API (2-5 seconds)856-- Other queries on this row are blocked!857858update orders set status = 'paid' where id = 1;859commit; -- Lock held for entire duration860```861862**Correct (minimal transaction scope):**863864```sql865-- Validate data and call APIs outside transaction866-- Application: response = await paymentAPI.charge(...)867868-- Only hold lock for the actual update869begin;870update orders871set status = 'paid', payment_id = $1872where id = $2 and status = 'pending'873returning *;874commit; -- Lock held for milliseconds875-- Abort queries running longer than 30 seconds876set statement_timeout = '30s';877878-- Or per-session879set local statement_timeout = '5s';880```881882Use `statement_timeout` to prevent runaway transactions:883884Reference: https://www.postgresql.org/docs/current/tutorial-transactions.html885886---887888### 5.2 Prevent Deadlocks with Consistent Lock Ordering889890**Impact: MEDIUM-HIGH (Eliminate deadlock errors, improve reliability)**891892Deadlocks occur when transactions lock resources in different orders. Always893acquire locks in a consistent order.894895**Incorrect (inconsistent lock ordering):**896897```sql898-- Transaction A -- Transaction B899begin; begin;900update accounts update accounts901set balance = balance - 100 set balance = balance - 50902where id = 1; where id = 2; -- B locks row 2903904update accounts update accounts905set balance = balance + 100 set balance = balance + 50906where id = 2; -- A waits for B where id = 1; -- B waits for A907908-- DEADLOCK! Both waiting for each other909```910911**Correct (lock rows in consistent order first):**912913```sql914-- Explicitly acquire locks in ID order before updating915begin;916select * from accounts where id in (1, 2) order by id for update;917918-- Now perform updates in any order - locks already held919update accounts set balance = balance - 100 where id = 1;920update accounts set balance = balance + 100 where id = 2;921commit;922-- Single statement acquires all locks atomically923begin;924update accounts925set balance = balance + case id926 when 1 then -100927 when 2 then 100928end929where id in (1, 2);930commit;931-- Check for recent deadlocks932select * from pg_stat_database where deadlocks > 0;933934-- Enable deadlock logging935set log_lock_waits = on;936set deadlock_timeout = '1s';937```938939Alternative: use a single statement to update atomically:940Detect deadlocks in logs:941[Deadlocks](https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS)942943---944945### 5.3 Use Advisory Locks for Application-Level Locking946947**Impact: MEDIUM (Efficient coordination without row-level lock overhead)**948949Advisory locks provide application-level coordination without requiring database rows to lock.950951**Incorrect (creating rows just for locking):**952953```sql954-- Creating dummy rows to lock on955create table resource_locks (956 resource_name text primary key957);958959insert into resource_locks values ('report_generator');960961-- Lock by selecting the row962select * from resource_locks where resource_name = 'report_generator' for update;963```964965**Correct (advisory locks):**966967```sql968-- Session-level advisory lock (released on disconnect or unlock)969select pg_advisory_lock(hashtext('report_generator'));970-- ... do exclusive work ...971select pg_advisory_unlock(hashtext('report_generator'));972973-- Transaction-level lock (released on commit/rollback)974begin;975select pg_advisory_xact_lock(hashtext('daily_report'));976-- ... do work ...977commit; -- Lock automatically released978-- Returns immediately with true/false instead of waiting979select pg_try_advisory_lock(hashtext('resource_name'));980981-- Use in application982if (acquired) {983 -- Do work984 select pg_advisory_unlock(hashtext('resource_name'));985} else {986 -- Skip or retry later987}988```989990Try-lock for non-blocking operations:991992Reference: https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS993994---995996### 5.4 Use SKIP LOCKED for Non-Blocking Queue Processing997998**Impact: MEDIUM-HIGH (10x throughput for worker queues)**9991000When multiple workers process a queue, SKIP LOCKED allows workers to process different rows without waiting.10011002**Incorrect (workers block each other):**10031004```sql1005-- Worker 1 and Worker 2 both try to get next job1006begin;1007select * from jobs where status = 'pending' order by created_at limit 1 for update;1008-- Worker 2 waits for Worker 1's lock to release!1009```10101011**Correct (SKIP LOCKED for parallel processing):**10121013```sql1014-- Each worker skips locked rows and gets the next available1015begin;1016select * from jobs1017where status = 'pending'1018order by created_at1019limit 11020for update skip locked;10211022-- Worker 1 gets job 1, Worker 2 gets job 2 (no waiting)10231024update jobs set status = 'processing' where id = $1;1025commit;1026-- Atomic claim-and-update in one statement1027update jobs1028set status = 'processing', worker_id = $1, started_at = now()1029where id = (1030 select id from jobs1031 where status = 'pending'1032 order by created_at1033 limit 11034 for update skip locked1035)1036returning *;1037```10381039Complete queue pattern:10401041Reference: https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE10421043---10441045## 6. Data Access Patterns10461047**Impact: MEDIUM**10481049N+1 query elimination, batch operations, cursor-based pagination, and efficient data fetching.10501051### 6.1 Batch INSERT Statements for Bulk Data10521053**Impact: MEDIUM (10-50x faster bulk inserts)**10541055Individual INSERT statements have high overhead. Batch multiple rows in single statements or use COPY.10561057**Incorrect (individual inserts):**10581059```sql1060-- Each insert is a separate transaction and round trip1061insert into events (user_id, action) values (1, 'click');1062insert into events (user_id, action) values (1, 'view');1063insert into events (user_id, action) values (2, 'click');1064-- ... 1000 more individual inserts10651066-- 1000 inserts = 1000 round trips = slow1067```10681069**Correct (batch insert):**10701071```sql1072-- Multiple rows in single statement1073insert into events (user_id, action) values1074 (1, 'click'),1075 (1, 'view'),1076 (2, 'click'),1077 -- ... up to ~1000 rows per batch1078 (999, 'view');10791080-- One round trip for 1000 rows1081-- COPY is fastest for bulk loading1082copy events (user_id, action, created_at)1083from '/path/to/data.csv'1084with (format csv, header true);10851086-- Or from stdin in application1087copy events (user_id, action) from stdin with (format csv);10881,click10891,view10902,click1091\.1092```10931094For large imports, use COPY:10951096Reference: https://www.postgresql.org/docs/current/sql-copy.html10971098---10991100### 6.2 Eliminate N+1 Queries with Batch Loading11011102**Impact: MEDIUM-HIGH (10-100x fewer database round trips)**11031104N+1 queries execute one query per item in a loop. Batch them into a single query using arrays or JOINs.11051106**Incorrect (N+1 queries):**11071108```sql1109-- First query: get all users1110select id from users where active = true; -- Returns 100 IDs11111112-- Then N queries, one per user1113select * from orders where user_id = 1;1114select * from orders where user_id = 2;1115select * from orders where user_id = 3;1116-- ... 97 more queries!11171118-- Total: 101 round trips to database1119```11201121**Correct (single batch query):**11221123```sql1124-- Collect IDs and query once with ANY1125select * from orders where user_id = any(array[1, 2, 3, ...]);11261127-- Or use JOIN instead of loop1128select u.id, u.name, o.*1129from users u1130left join orders o on o.user_id = u.id1131where u.active = true;11321133-- Total: 1 round trip1134-- Instead of looping in application code:1135-- for user in users: db.query("SELECT * FROM orders WHERE user_id = $1", user.id)11361137-- Pass array parameter:1138select * from orders where user_id = any($1::bigint[]);1139-- Application passes: [1, 2, 3, 4, 5, ...]1140```11411142Application pattern:11431144Reference: https://supabase.com/docs/guides/database/query-optimization11451146---11471148### 6.3 Use Cursor-Based Pagination Instead of OFFSET11491150**Impact: MEDIUM-HIGH (Consistent O(1) performance regardless of page depth)**11511152OFFSET-based pagination scans all skipped rows, getting slower on deeper pages. Cursor pagination is O(1).11531154**Incorrect (OFFSET pagination):**11551156```sql1157-- Page 1: scans 20 rows1158select * from products order by id limit 20 offset 0;11591160-- Page 100: scans 2000 rows to skip 19801161select * from products order by id limit 20 offset 1980;11621163-- Page 10000: scans 200,000 rows!1164select * from products order by id limit 20 offset 199980;1165```11661167**Correct (cursor/keyset pagination):**11681169```sql1170-- Page 1: get first 201171select * from products order by id limit 20;1172-- Application stores last_id = 2011731174-- Page 2: start after last ID1175select * from products where id > 20 order by id limit 20;1176-- Uses index, always fast regardless of page depth11771178-- Page 10000: same speed as page 11179select * from products where id > 199980 order by id limit 20;1180-- Cursor must include all sort columns1181select * from products1182where (created_at, id) > ('2024-01-15 10:00:00', 12345)1183order by created_at, id1184limit 20;1185```11861187For multi-column sorting:11881189Reference: https://supabase.com/docs/guides/database/pagination11901191---11921193### 6.4 Use UPSERT for Insert-or-Update Operations11941195**Impact: MEDIUM (Atomic operation, eliminates race conditions)**11961197Using separate SELECT-then-INSERT/UPDATE creates race conditions. Use INSERT ... ON CONFLICT for atomic upserts.11981199**Incorrect (check-then-insert race condition):**12001201```sql1202-- Race condition: two requests check simultaneously1203select * from settings where user_id = 123 and key = 'theme';1204-- Both find nothing12051206-- Both try to insert1207insert into settings (user_id, key, value) values (123, 'theme', 'dark');1208-- One succeeds, one fails with duplicate key error!1209```12101211**Correct (atomic UPSERT):**12121213```sql1214-- Single atomic operation1215insert into settings (user_id, key, value)1216values (123, 'theme', 'dark')1217on conflict (user_id, key)1218do update set value = excluded.value, updated_at = now();12191220-- Returns the inserted/updated row1221insert into settings (user_id, key, value)1222values (123, 'theme', 'dark')1223on conflict (user_id, key)1224do update set value = excluded.value1225returning *;1226-- Insert only if not exists (no update)1227insert into page_views (page_id, user_id)1228values (1, 123)1229on conflict (page_id, user_id) do nothing;1230```12311232Insert-or-ignore pattern:12331234Reference: https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT12351236---12371238## 7. Monitoring & Diagnostics12391240**Impact: LOW-MEDIUM**12411242Using pg_stat_statements, EXPLAIN ANALYZE, metrics collection, and performance diagnostics.12431244### 7.1 Enable pg_stat_statements for Query Analysis12451246**Impact: LOW-MEDIUM (Identify top resource-consuming queries)**12471248pg_stat_statements tracks execution statistics for all queries, helping identify slow and frequent queries.12491250**Incorrect (no visibility into query patterns):**12511252```sql1253-- Database is slow, but which queries are the problem?1254-- No way to know without pg_stat_statements1255```12561257**Correct (enable and query pg_stat_statements):**12581259```sql1260-- Enable the extension1261create extension if not exists pg_stat_statements;12621263-- Find slowest queries by total time1264select1265 calls,1266 round(total_exec_time::numeric, 2) as total_time_ms,1267 round(mean_exec_time::numeric, 2) as mean_time_ms,1268 query1269from pg_stat_statements1270order by total_exec_time desc1271limit 10;12721273-- Find most frequent queries1274select calls, query1275from pg_stat_statements1276order by calls desc1277limit 10;12781279-- Reset statistics after optimization1280select pg_stat_statements_reset();1281-- Queries with high mean time (candidates for optimization)1282select query, mean_exec_time, calls1283from pg_stat_statements1284where mean_exec_time > 100 -- > 100ms average1285order by mean_exec_time desc;1286```12871288Key metrics to monitor:12891290Reference: https://supabase.com/docs/guides/database/extensions/pg_stat_statements12911292---12931294### 7.2 Maintain Table Statistics with VACUUM and ANALYZE12951296**Impact: MEDIUM (2-10x better query plans with accurate statistics)**12971298Outdated statistics cause the query planner to make poor decisions. VACUUM reclaims space, ANALYZE updates statistics.12991300**Incorrect (stale statistics):**13011302```sql1303-- Table has 1M rows but stats say 10001304-- Query planner chooses wrong strategy1305explain select * from orders where status = 'pending';1306-- Shows: Seq Scan (because stats show small table)1307-- Actually: Index Scan would be much faster1308```13091310**Correct (maintain fresh statistics):**13111312```sql1313-- Manually analyze after large data changes1314analyze orders;13151316-- Analyze specific columns used in WHERE clauses1317analyze orders (status, created_at);13181319-- Check when tables were last analyzed1320select1321 relname,1322 last_vacuum,1323 last_autovacuum,1324 last_analyze,1325 last_autoanalyze1326from pg_stat_user_tables1327order by last_analyze nulls first;1328-- Increase frequency for high-churn tables1329alter table orders set (1330 autovacuum_vacuum_scale_factor = 0.05, -- Vacuum at 5% dead tuples (default 20%)1331 autovacuum_analyze_scale_factor = 0.02 -- Analyze at 2% changes (default 10%)1332);13331334-- Check autovacuum status1335select * from pg_stat_progress_vacuum;1336```13371338Autovacuum tuning for busy tables:13391340Reference: https://supabase.com/docs/guides/database/database-size#vacuum-operations13411342---13431344### 7.3 Use EXPLAIN ANALYZE to Diagnose Slow Queries13451346**Impact: LOW-MEDIUM (Identify exact bottlenecks in query execution)**13471348EXPLAIN ANALYZE executes the query and shows actual timings, revealing the true performance bottlenecks.13491350**Incorrect (guessing at performance issues):**13511352```sql1353-- Query is slow, but why?1354select * from orders where customer_id = 123 and status = 'pending';1355-- "It must be missing an index" - but which one?1356```13571358**Correct (use EXPLAIN ANALYZE):**13591360```sql1361explain (analyze, buffers, format text)1362select * from orders where customer_id = 123 and status = 'pending';13631364-- Output reveals the issue:1365-- Seq Scan on orders (cost=0.00..25000.00 rows=50 width=100) (actual time=0.015..450.123 rows=50 loops=1)1366-- Filter: ((customer_id = 123) AND (status = 'pending'::text))1367-- Rows Removed by Filter: 9999501368-- Buffers: shared hit=5000 read=150001369-- Planning Time: 0.150 ms1370-- Execution Time: 450.500 ms1371-- Seq Scan on large tables = missing index1372-- Rows Removed by Filter = poor selectivity or missing index1373-- Buffers: read >> hit = data not cached, needs more memory1374-- Nested Loop with high loops = consider different join strategy1375-- Sort Method: external merge = work_mem too low1376```13771378Key things to look for:13791380Reference: https://supabase.com/docs/guides/database/inspect13811382---13831384## 8. Advanced Features13851386**Impact: LOW**13871388Full-text search, JSONB optimization, PostGIS, extensions, and advanced Postgres features.13891390### 8.1 Index JSONB Columns for Efficient Querying13911392**Impact: MEDIUM (10-100x faster JSONB queries with proper indexing)**13931394JSONB queries without indexes scan the entire table. Use GIN indexes for containment queries.13951396**Incorrect (no index on JSONB):**13971398```sql1399create table products (1400 id bigint primary key,1401 attributes jsonb1402);14031404-- Full table scan for every query1405select * from products where attributes @> '{"color": "red"}';1406select * from products where attributes->>'brand' = 'Nike';1407```14081409**Correct (GIN index for JSONB):**14101411```sql1412-- GIN index for containment operators (@>, ?, ?&, ?|)1413create index products_attrs_gin on products using gin (attributes);14141415-- Now containment queries use the index1416select * from products where attributes @> '{"color": "red"}';14171418-- For specific key lookups, use expression index1419create index products_brand_idx on products ((attributes->>'brand'));1420select * from products where attributes->>'brand' = 'Nike';1421-- jsonb_ops (default): supports all operators, larger index1422create index idx1 on products using gin (attributes);14231424-- jsonb_path_ops: only @> operator, but 2-3x smaller index1425create index idx2 on products using gin (attributes jsonb_path_ops);1426```14271428Choose the right operator class:14291430Reference: https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING14311432---14331434### 8.2 Use tsvector for Full-Text Search14351436**Impact: MEDIUM (100x faster than LIKE, with ranking support)**14371438LIKE with wildcards can't use indexes. Full-text search with tsvector is orders of magnitude faster.14391440**Incorrect (LIKE pattern matching):**14411442```sql1443-- Cannot use index, scans all rows1444select * from articles where content like '%postgresql%';14451446-- Case-insensitive makes it worse1447select * from articles where lower(content) like '%postgresql%';1448```14491450**Correct (full-text search with tsvector):**14511452```sql1453-- Add tsvector column and index1454alter table articles add column search_vector tsvector1455 generated always as (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,''))) stored;14561457create index articles_search_idx on articles using gin (search_vector);14581459-- Fast full-text search1460select * from articles1461where search_vector @@ to_tsquery('english', 'postgresql & performance');14621463-- With ranking1464select *, ts_rank(search_vector, query) as rank1465from articles, to_tsquery('english', 'postgresql') query1466where search_vector @@ query1467order by rank desc;1468-- AND: both terms required1469to_tsquery('postgresql & performance')14701471-- OR: either term1472to_tsquery('postgresql | mysql')14731474-- Prefix matching1475to_tsquery('post:*')1476```14771478Search multiple terms:14791480Reference: https://supabase.com/docs/guides/database/full-text-search14811482---14831484## References14851486- https://www.postgresql.org/docs/current/1487- https://supabase.com/docs1488- https://wiki.postgresql.org/wiki/Performance_Optimization1489- https://supabase.com/docs/guides/database/overview1490- https://supabase.com/docs/guides/auth/row-level-security1491
Also in davila7/claude-code-templates
Diff this repo’s formatsOne repository carrying more than one format is the comparison this product exists for: does anyone actually write different content in each file, or is one a copy of the other?
| Repository | Format | Stack | Covers | Score | Changed |
|---|---|---|---|---|---|
| davila7/claude-code-templatesCLAUDE.md · 30k | CLAUDE.md | setupbuildteststyle+13 | 84/100 | 2 days ago | |
| davila7/claude-code-templatescli-tool/components/skills/development/postgres-best-practices/AGENTS.md · 30k | AGENTS.md | styletypessecuritydatabase+3 | 45/100 | 2 days ago | |
| davila7/claude-code-templatescli-tool/components/skills/development/react-best-practices/AGENTS.md · 30k | AGENTS.md | buildlint-formatstyledependencies+4 | 61/100 | 2 days ago | |
| davila7/claude-code-templatescli-tool/components/skills/ai-research/loki-mode/CLAUDE.md · 30k | CLAUDE.md | testlint-formatstylearch+5 | 77/100 | 2 days ago |
Similar configs
Same format, overlapping stack, ranked by quality.
| Repository | Format | Stack | Covers | Score | Changed |
|---|---|---|---|---|---|
| OnlyTerp/prompt-cache-skillsAGENTS.md · 112 | AGENTS.md | setupbuildtestlint-format+5 | 100/100 | 3 days ago | |
| vllm-project/vllmAGENTS.md · 88k | AGENTS.md | setuptestlint-formatstyle+5 | 100/100 | 3 days ago | |
| SkeneTechnologies/skene-cookbookAGENTS.md · 51 | AGENTS.md | setupbuildtestlint-format+7 | 100/100 | 2 days ago | |
| duckduckgo/content-scope-scriptsspecial-pages/AGENTS.md · 70 | AGENTS.md | buildteststylearch+3 | 100/100 | 3 days ago | |
| mui/material-uiAGENTS.md · 99k | AGENTS.md | setupbuildtestlint-format+9 | 100/100 | 3 days ago | |
| n8n-io/n8npackages/@n8n/agents/AGENTS.md · 199k | AGENTS.md | buildteststylearch+3 | 100/100 | 3 days ago | |
| trick77/agents-md-syncAGENTS.md · 2 | AGENTS.md | setupbuildteststyle+5 | 100/100 | 3 days ago | |
| TryGhost/Ghoste2e/AGENTS.md · 55k | AGENTS.md | setupteststylearch+2 | 100/100 | 3 days ago |
