supabase-migration

ninyawee's avatarfrom ninyawee

Database migration toolkit for Supabase projects. Use when: (1) Creating new migration files, (2) Writing schema changes (CREATE TABLE, ALTER, etc.), (3) Adding indexes, triggers, or RLS policies, (4) Fixing RLS performance issues (auth function wrapping, policy consolidation), (5) Validating migration conventions, (6) Running migrations locally, (7) Naming database objects

⭐0stars🔀0forks📁View on GitHub🕐Updated Dec 23, 2025⬇️1downloads

When & Why to Use This Skill

This Claude skill is a comprehensive database migration toolkit specifically designed for Supabase projects. It streamlines the lifecycle of database schema management by automating the creation of migration files, enforcing strict naming conventions, and implementing advanced PostgreSQL security best practices. By focusing on RLS performance optimization and secure function definitions, it helps developers maintain a scalable, secure, and well-organized database architecture.

Use Cases

  • Standardized Schema Evolution: Automatically generating migration files for new tables, indexes, or enums using mandatory prefixes (e.g., tb_, idx_, en_) to ensure project-wide consistency.
  • RLS Performance Tuning: Fixing slow Row Level Security policies by wrapping auth functions like auth.uid() in SELECT statements and consolidating multiple permissive policies into optimized logic.
  • Security Hardening: Auditing and updating PostgreSQL functions and views to use security_invoker and explicit search_path settings to prevent unauthorized access or object hijacking.
  • Automated Linting: Validating local SQL migration files against strict naming rules and Supabase conventions before deployment to prevent production errors.
  • Complex Multi-tenant Setup: Implementing robust multi-tenant isolation patterns using standardized RLS policy templates that handle JWT-based tenant identification.
namesupabase-migration
description"Database migration toolkit for Supabase projects. Use when: (1) Creating new migration files, (2) Writing schema changes (CREATE TABLE, ALTER, etc.), (3) Adding indexes, triggers, or RLS policies, (4) Fixing RLS performance issues (auth function wrapping, policy consolidation), (5) Validating migration conventions, (6) Running migrations locally, (7) Naming database objects"
licenseProprietary. LICENSE.txt has complete terms

Supabase Database Migrations

Toolkit for creating and managing Supabase database migrations.

Helper Scripts Available (uv scripts - no install needed):

  • scripts/new_migration.py - Create migration file with proper naming
  • scripts/lint_migration.py - Validate migration against conventions
uv run scripts/new_migration.py --help
uv run scripts/lint_migration.py --help

Naming Conventions

Object Prefixes (REQUIRED)

Object Type Prefix Example
Tables tb_ tb_users
Views v_ v_active_users
Materialized Views mv_ mv_daily_stats
Functions fn_ fn_get_balance_v1
Triggers tgr_ tgr_update_ts
Indexes idx_ idx_email
Foreign Keys fk_ fk_order_user
Primary Keys pk_ pk_users
Unique Constraints uq_ uq_email
Enum Types en_ en_status
RLS Policies pc_ pc_users_select

Functions MUST be versioned: fn_calculate_total_v1, fn_calculate_total_v2

Field Suffixes

Suffix Type Example
_dt date birth_dt
_ts timestamp login_ts
_num number items_num
_amt decimal total_amt
_pct decimal discount_pct
_uid uuid user_uid
_cd text status_cd
_bool boolean active_bool
_pn text contact_pn
_em text contact_em
_txt text description_txt
_kg decimal weight_kg
_path text avatar_path

No suffix: id, name, email, created_at, updated_at, deleted_at

Rules

  • MUST use lowercase_snake_case
  • Tables MUST use plural forms (tb_users not tb_user)

Decision Tree

Task → What type of change?
    ├─ New table → uv run scripts/new_migration.py "add_users_table"
    ├─ Alter table → uv run scripts/new_migration.py "add_avatar_to_users" --type alter
    ├─ New function → uv run scripts/new_migration.py "add_calc_fn" --type function
    └─ Before commit → uv run scripts/lint_migration.py migrations/*.sql

File Naming

supabase/migrations/YYYYMMDDHHMMSS_description.sql

Migration Template

-- Migration: [Description]
BEGIN;

-- 1. Types/Enums
DO $$ BEGIN CREATE TYPE en_status AS ENUM ('active', 'inactive');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

-- 2. Tables
CREATE TABLE IF NOT EXISTS tb_examples (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    status_cd en_status NOT NULL DEFAULT 'active',
    created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 3. Comments
COMMENT ON TABLE tb_examples IS 'Module: Description';

-- 4. Indexes
CREATE INDEX IF NOT EXISTS idx_examples_status ON tb_examples(status_cd);

-- 5. Triggers
DROP TRIGGER IF EXISTS tgr_update_examples_timestamp ON tb_examples;
CREATE TRIGGER tgr_update_examples_timestamp
    BEFORE UPDATE ON tb_examples FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

-- 6. RLS (use SELECT wrapper for auth functions - see below)
ALTER TABLE tb_examples ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS pc_examples_select ON tb_examples;
CREATE POLICY pc_examples_select ON tb_examples FOR SELECT
    USING (user_uid = (SELECT auth.uid()));

COMMIT;

Supabase Security

Views - MUST use security_invoker

CREATE VIEW v_active_users WITH (security_invoker) AS
SELECT * FROM tb_users WHERE deleted_at IS NULL;

SECURITY DEFINER views must be converted to functions:

Views with SECURITY DEFINER bypass RLS of the querying user. Convert to a function with explicit tenant/auth checks.

-- BAD: View with SECURITY DEFINER bypasses caller's RLS
CREATE VIEW v_line_responders WITH (security_definer) AS
SELECT tenant_id, ... FROM tb_line_events GROUP BY ...;

-- GOOD: Function with explicit auth check
CREATE OR REPLACE FUNCTION fn_get_line_responders_v1()
RETURNS TABLE (
    tenant_id uuid,
    responder_user_id text,
    message_count bigint
) LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = extensions, public, pg_temp
AS $$
    SELECT tenant_id, event_payload->>'user_id', COUNT(*)
    FROM public.tb_line_events
    WHERE tenant_id = ((SELECT auth.jwt()) ->> 'tenant_id')::uuid
    GROUP BY tenant_id, event_payload->>'user_id';
$$;

REVOKE ALL ON FUNCTION fn_get_line_responders_v1() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION fn_get_line_responders_v1() TO authenticated;

Functions - MUST set search_path

Mutable search_path allows object hijacking. Always set explicitly.

-- New function
CREATE OR REPLACE FUNCTION fn_example_v1()
RETURNS void AS $$ BEGIN END; $$
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = extensions, public, pg_temp;

-- Fix existing function
ALTER FUNCTION fn_find_nearby_facilities(/* arg types */)
SET search_path = extensions, public, pg_temp;
Scenario search_path
Uses extensions (PostGIS, etc.) extensions, public, pg_temp
No extensions public, pg_temp
SECURITY DEFINER + auth Add REVOKE/GRANT (see Views section)

Extensions - MUST use extensions schema

CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA extensions;

RLS Performance - MUST wrap auth functions in SELECT

Bad Good
auth.uid() (SELECT auth.uid())
auth.jwt() (SELECT auth.jwt())
auth.role() (SELECT auth.role())
current_setting(...) (SELECT current_setting(...))
-- Wrap auth functions to avoid per-row re-evaluation
USING (user_uid = (SELECT auth.uid()))
USING (org_uid = ((SELECT auth.jwt()) ->> 'org_id')::uuid)

RLS Performance - Consolidate multiple permissive policies

Same role + same action = consolidate with OR (don't create multiple policies).

Scenario Approach
Same role, same action Consolidate with OR
Different roles Keep separate policies
Different actions Keep separate policies
-- Single policy with OR instead of multiple permissive policies
CREATE POLICY pc_records_select ON tb_records FOR SELECT
TO authenticated
USING (
    ((SELECT auth.jwt()) -> 'app_metadata' ->> 'role') = 'admin'
    OR user_uid = (SELECT auth.uid())
);

Multi-tenant pattern:

CREATE POLICY pc_registrations_select ON tb_registrations FOR SELECT
TO authenticated
USING (
    tenant_id = (((SELECT auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::smallint)
    AND (
        ((SELECT auth.jwt()) -> 'app_metadata' ->> 'role') = ANY (ARRAY['staff', 'admin'])
        OR user_uid = (SELECT auth.uid())
    )
);

Running Migrations

supabase db reset    # Reset and apply all
supabase db push     # Deploy to remote

References

Topic When to Read
auth-function-wrapping.md Why (SELECT auth.*) improves performance
policy-consolidation.md Why multiple permissive policies hurt performance
security-definer-views.md Why SECURITY DEFINER views are dangerous
function-search-path.md Why mutable search_path is dangerous
multi-tenant.md Setting up SaaS/multi-env isolation
private-schema.md Hiding tables from API
jsonb.md Using flexible JSON columns
advanced-types.md ltree, tstzrange, int8range