Supabase migrations
Self-hosted Supabase fails differently from managed Supabase. The failures are quiet: the
migration succeeds, the API returns an empty array, and nothing anywhere reports an error.
This skill is the checklist that catches those before they ship.
When to use this
Writing a migration, reviewing one, or debugging "the table exists but the API says it
does not".
The order that matters
Take the next free number. Never reuse one, never renumber an applied migration.
ls migrations/ | sort -V | tail -1
Write the DDL idempotently. create table if not exists, add column if not exists, drop policy if exists before create policy. A migration that cannot be
run twice will eventually be run twice.
Grant, then policy, then enable. Row Level Security without a grant returns
permission-denied; a grant without RLS exposes every row. Both, in that order.
End with the schema reload.
notify pgrst, 'reload schema';
This is the one that gets forgotten. PostgREST caches the schema at boot. Without this
line the table exists in Postgres and does not exist over the API, and every symptom
points at your client code.
Verify against the database, not against the file.
bash scripts/verify.sh <table_name>
Template
-- 00NN_<what_it_does>.sql
-- <one line: why this exists>
create table if not exists public.thing (
id uuid primary key default gen_random_uuid(),
slug text unique not null,
status text not null default 'draft'
check (status in ('draft', 'published', 'archived')),
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists thing_status_idx on public.thing (status)
where status = 'published';
alter table public.thing enable row level security;
grant select on public.thing to anon, authenticated;
grant all on public.thing to service_role;
drop policy if exists "thing: public reads published" on public.thing;
create policy "thing: public reads published"
on public.thing for select
to anon, authenticated
using (status = 'published');
drop policy if exists "thing: service_role full access" on public.thing;
create policy "thing: service_role full access"
on public.thing for all
to service_role
using (true) with check (true);
notify pgrst, 'reload schema';
RPCs
A function the API can call needs security definer only when it must bypass RLS, and
then it needs a pinned search_path or it is an escalation path:
create or replace function public.increment_counter(p_slug text, p_kind text)
returns void
language plpgsql
security definer
set search_path = public
as $$
begin
update public.thing
set payload = jsonb_set(
payload, array['counts', p_kind],
to_jsonb(coalesce((payload->'counts'->>p_kind)::int, 0) + 1)),
updated_at = now()
where slug = p_slug;
end;
$$;
revoke all on function public.increment_counter(text, text) from public;
grant execute on function public.increment_counter(text, text) to anon, authenticated;
revoke ... from public first, then grant to the roles that need it. Creating a function
grants execute to public by default, which is almost never what you want for a
security definer.
Failure modes, and what they actually mean
| Symptom |
Cause |
API returns [], table has rows |
RLS on, no policy for that role. Not a query bug |
| API returns "relation does not exist" |
Missing notify pgrst, 'reload schema' |
| Works as service_role, not as anon |
Grant present, policy missing (or the reverse) |
| Insert silently drops rows |
Policy has using but no with check |
| Migration passes locally, fails in prod |
Different extension set. Check gen_random_uuid |
Before it runs against production
- Read the whole file top to bottom, out of the diff.
- Confirm nothing is destructive: no bare
drop table, no alter column ... type without
a using, no delete from without a where.
- Confirm it ends with the notify line.
- Run it against a scratch database first if one exists.
- Never run it as part of the same step that writes it.
Files
scripts/verify.sh — post-migration verification: table exists, RLS on, policies
present, and the anon role can actually read what it should.
1---2name: supabase-migrations3description: Write and review Postgres migrations for a self-hosted Supabase project — numbering, RLS policies, grants, idempotency and the PostgREST schema reload. Use when adding a table, changing a column, writing an RPC, or reviewing a migration before it runs against production.4---56# Supabase migrations78Self-hosted Supabase fails differently from managed Supabase. The failures are quiet: the9migration succeeds, the API returns an empty array, and nothing anywhere reports an error.10This skill is the checklist that catches those before they ship.1112## When to use this1314Writing a migration, reviewing one, or debugging "the table exists but the API says it15does not".1617## The order that matters18191. **Take the next free number.** Never reuse one, never renumber an applied migration.2021 ```bash22 ls migrations/ | sort -V | tail -123 ```24252. **Write the DDL idempotently.** `create table if not exists`, `add column if not26 exists`, `drop policy if exists` before `create policy`. A migration that cannot be27 run twice will eventually be run twice.28293. **Grant, then policy, then enable.** Row Level Security without a grant returns30 permission-denied; a grant without RLS exposes every row. Both, in that order.31324. **End with the schema reload.**3334 ```sql35 notify pgrst, 'reload schema';36 ```3738 This is the one that gets forgotten. PostgREST caches the schema at boot. Without this39 line the table exists in Postgres and does not exist over the API, and every symptom40 points at your client code.41425. **Verify against the database, not against the file.**4344 ```bash45 bash scripts/verify.sh <table_name>46 ```4748## Template4950```sql51-- 00NN_<what_it_does>.sql52-- <one line: why this exists>5354create table if not exists public.thing (55 id uuid primary key default gen_random_uuid(),56 slug text unique not null,57 status text not null default 'draft'58 check (status in ('draft', 'published', 'archived')),59 payload jsonb not null default '{}'::jsonb,60 created_at timestamptz not null default now(),61 updated_at timestamptz not null default now()62);6364create index if not exists thing_status_idx on public.thing (status)65 where status = 'published';6667alter table public.thing enable row level security;6869grant select on public.thing to anon, authenticated;70grant all on public.thing to service_role;7172drop policy if exists "thing: public reads published" on public.thing;73create policy "thing: public reads published"74 on public.thing for select75 to anon, authenticated76 using (status = 'published');7778drop policy if exists "thing: service_role full access" on public.thing;79create policy "thing: service_role full access"80 on public.thing for all81 to service_role82 using (true) with check (true);8384notify pgrst, 'reload schema';85```8687## RPCs8889A function the API can call needs `security definer` only when it must bypass RLS, and90then it needs a pinned `search_path` or it is an escalation path:9192```sql93create or replace function public.increment_counter(p_slug text, p_kind text)94returns void95language plpgsql96security definer97set search_path = public98as $$99begin100 update public.thing101 set payload = jsonb_set(102 payload, array['counts', p_kind],103 to_jsonb(coalesce((payload->'counts'->>p_kind)::int, 0) + 1)),104 updated_at = now()105 where slug = p_slug;106end;107$$;108109revoke all on function public.increment_counter(text, text) from public;110grant execute on function public.increment_counter(text, text) to anon, authenticated;111```112113`revoke ... from public` first, then grant to the roles that need it. Creating a function114grants execute to `public` by default, which is almost never what you want for a115`security definer`.116117## Failure modes, and what they actually mean118119| Symptom | Cause |120|---------|-------|121| API returns `[]`, table has rows | RLS on, no policy for that role. Not a query bug |122| API returns "relation does not exist" | Missing `notify pgrst, 'reload schema'` |123| Works as service_role, not as anon | Grant present, policy missing (or the reverse) |124| Insert silently drops rows | Policy has `using` but no `with check` |125| Migration passes locally, fails in prod | Different extension set. Check `gen_random_uuid` |126127## Before it runs against production128129- Read the whole file top to bottom, out of the diff.130- Confirm nothing is destructive: no bare `drop table`, no `alter column ... type` without131 a `using`, no `delete from` without a `where`.132- Confirm it ends with the notify line.133- Run it against a scratch database first if one exists.134- Never run it as part of the same step that writes it.135136## Files137138- `scripts/verify.sh` — post-migration verification: table exists, RLS on, policies139 present, and the anon role can actually read what it should.