Database migration, RLS, schema guide — performance patterns, anti-patterns, SECURITY DEFINER, safety checklist. Use when creating/modifying migration files, RLS policies, or DB schema.
Reference skill for writing migration files, designing RLS policies, and schema changes. Based on official documentation, performance benchmarks, and production incident learnings.
Always verify the next available timestamp before creating a new migration.
GOOD: 20260315000001_add-user-alerts.sql
BAD: 20260315000001_update.sql
-- ============================================================
-- Purpose: [what is being changed]
-- Reason: [why it is needed]
-- Impact: [which tables/policies are affected]
-- ============================================================
(select auth.uid()) Wrapping — 94-99.99% Improvement-- BAD: auth.uid() called per-row (179ms)
CREATE POLICY "..." ON my_table
USING (auth.uid() = user_id);
-- GOOD: initPlan executes once (9ms, 94.97% improvement)
CREATE POLICY "..." ON my_table
TO authenticated
USING ((select auth.uid()) = user_id);
CREATE INDEX IF NOT EXISTS idx_{table}_{column}
ON public.{table} USING btree ({column});
-- GOOD: anon skips instantly
CREATE POLICY "..." ON my_table
TO authenticated
USING ((select auth.uid()) = user_id);
-- GOOD: fixed-value join
CREATE POLICY "..." ON test_table TO authenticated
USING (
team_id IN (
SELECT team_id FROM team_user
WHERE user_id = (select auth.uid())
)
);
Add the same filter on the client side as RLS for better query plans.
TO authenticated(select auth.uid())CREATE POLICY "{table}_select_own" ON public.{table}
FOR SELECT TO authenticated
USING ((select auth.uid()) = user_id);
CREATE POLICY "{table}_insert_own" ON public.{table}
FOR INSERT TO authenticated
WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "{table}_update_own" ON public.{table}
FOR UPDATE TO authenticated
USING ((select auth.uid()) = user_id)
WITH CHECK ((select auth.uid()) = user_id);
Always separate into 4 operations with specific roles.
PostgreSQL combines: (permissive1 OR permissive2) AND restrictive1 AND restrictive2
When a SELECT policy queries the same table → infinite recursion. Fix with SECURITY DEFINER function.
Public data tables (profiles, availability) need USING (true) for cross-user reads.
Review all 4 operations for every table.
Use TO authenticated to prevent anon execution.
CREATE FUNCTION private.has_role(role_name TEXT)
RETURNS BOOLEAN LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = '' -- Required: prevents search_path injection
STABLE AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.roles_table
WHERE user_id = auth.uid() AND role = role_name
);
END; $$;
Must-haves:
SET search_path = ''REVOKE FROM PUBLIC + REVOKE FROM anonpublic. prefix on all references(select function()) in policies-- Migration 1: instant (validates new rows only)
ALTER TABLE orders ADD CONSTRAINT fk_user
FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
-- Migration 2: validates existing rows with lightweight lock
ALTER TABLE orders VALIDATE CONSTRAINT fk_user;
CREATE INDEX CONCURRENTLY idx_col ON public.{table}(col);
| Operation | Lock | Blocks Reads? | Blocks Writes? |
|---|---|---|---|
| ADD COLUMN (nullable) | ACCESS EXCLUSIVE | Momentary | Momentary |
| ALTER COLUMN TYPE | ACCESS EXCLUSIVE | Full | Full |
| CREATE INDEX | SHARE | No | Yes |
| CREATE INDEX CONCURRENTLY | None | No | No |
| VALIDATE CONSTRAINT | SHARE UPDATE EXCLUSIVE | No | No |
IF NOT EXISTS / IF EXISTS for idempotencyENABLE ROW LEVEL SECURITY on new tablesTO authenticated specified(select auth.uid()) wrappedSET search_path = ''REVOKE FROM PUBLIC + REVOKE FROM anonGRANT TO authenticatedpublic. prefix usedDROP POLICY IF EXISTS before recreateCREATE OR REPLACE for functions/triggers