| uuid: primary and foreign keys | 128-bit identifiers. A random UUID is not an authorization rule. Referencing auth.users(id) ties a row to a Supabase user. | id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) | Official source ↗ |
| timestamptz: instants | Stores an instant, normalized internally to UTC, displayed in the session time zone. The original zone name is not retained. | created_at timestamptz not null default now();
select now() at time zone 'America/Montevideo'; | Official source ↗ |
| timestamp and date: local calendar values | timestamp without time zone is a wall-clock value, not an instant. date is a calendar day. Store a named time zone separately for local recurring schedules. | select date '2026-10-09', timestamp '2026-10-09 09:00'; | Official source ↗ |
| jsonb: structured metadata | Binary JSON supports containment and indexing. It does not retain whitespace, object key order or duplicate keys. Enforce critical fields in typed columns. | select '{"plan":"pro","seats":3}'::jsonb ->> 'plan'; | Official source ↗ |
| text and varchar | text stores variable-length strings. varchar(n) adds a length limit, with no general speed benefit over text. | name text not null check (length(name) > 0),
country_code varchar(2) | Official source ↗ |
| numeric: money and exact decimals | numeric is exact decimal arithmetic. Choose precision and scale for your domain. Floating point types are approximate; do not use them for exact money. | amount numeric(12,2) not null check (amount >= 0) | Official source ↗ |
| integer and bigint | Whole numbers. bigint covers a larger range than integer; JavaScript cannot represent every bigint safely as a Number. | quantity integer not null check (quantity >= 0),
sequence_number bigint generated always as identity | Official source ↗ |
| boolean, arrays and enum | boolean models true/false/null. Arrays hold one element type. Enums constrain values but need deliberate migrations when the set evolves. | create type public.task_state as enum ('open', 'done');
select true::boolean, array['api', 'billing']::text[]; | Official source ↗ |
| Create an owned SaaS table | Run the RLS and policy examples too before exposing this table. These are educational SQL snippets, not migrations applied by this tool. | create table public.projects (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id),
name text not null,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
); | Official source ↗ |
| Enable RLS and minimum grants | RLS and SQL grants are separate checks. RLS without policies denies ordinary role access. Table owners and privileged roles can bypass RLS; keep service credentials on the server. | alter table public.projects enable row level security;
grant select, insert, update, delete on public.projects to authenticated;
revoke all on public.projects from anon; | Official source ↗ |
| SELECT policy: read your own rows | auth.uid() is null without a signed-in user. USING filters existing rows. Restrict the policy to authenticated; users cannot authorize themselves with user_metadata. | create policy "Read own projects" on public.projects
for select to authenticated
using ((select auth.uid()) = owner_id); | Official source ↗ |
| INSERT policy: assign the correct owner | WITH CHECK validates the new row. A SELECT policy alone never authorizes inserts. | create policy "Insert own projects" on public.projects
for insert to authenticated
with check ((select auth.uid()) = owner_id); | Official source ↗ |
| UPDATE policy: preserve row ownership | USING limits old rows; WITH CHECK constrains the updated row and prevents owner transfer. Supabase also requires a matching SELECT policy for updates. | create policy "Update own projects" on public.projects
for update to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id); | Official source ↗ |
| DELETE policy: remove your own rows | USING constrains deletion. Avoid a policy querying the same protected table recursively. Test with ordinary authenticated identities, not a service role. | create policy "Delete own projects" on public.projects
for delete to authenticated
using ((select auth.uid()) = owner_id); | Official source ↗ |
| Functions: security invoker and search_path | SECURITY INVOKER uses the caller permissions and RLS. Empty search_path with qualified names reduces name resolution risks. Functions default to public execution: revoke and grant intentionally. | create function public.my_projects()
returns setof public.projects
language sql stable security invoker
set search_path = ''
as $$ select * from public.projects
where owner_id = (select auth.uid()); $$;
revoke all on function public.my_projects() from public, anon;
grant execute on function public.my_projects() to authenticated; | Official source ↗ |
| B-tree and partial indexes | An index on owner_id helps RLS lookups. A partial index only helps compatible predicates. Indexes cost write time and storage; inspect real plans. | create index projects_owner_created_idx
on public.projects (owner_id, created_at desc);
create index projects_open_idx on public.projects (owner_id)
where metadata ->> 'state' = 'open'; | Official source ↗ |
| GIN index and jsonb containment | The default GIN jsonb operator class supports containment and key existence. jsonb_path_ops is smaller for supported containment/path queries but lacks key-existence operators. | create index projects_metadata_idx on public.projects using gin (metadata);
select * from public.projects where metadata @> '{"plan":"pro"}'::jsonb; | Official source ↗ |
| Upsert with an intentional conflict key | ON CONFLICT needs a matching unique constraint or index. An upsert may require SELECT, INSERT and UPDATE policies. Random UUIDs alone do not deduplicate retries. | insert into public.projects (id, owner_id, name)
values ('11111111-1111-4111-8111-111111111111', auth.uid(), 'Demo')
on conflict (id) do update set name = excluded.name
returning id; | Official source ↗ |
| Keyset pagination and parameterized queries | Use placeholders with a server-side driver, never concatenate untrusted SQL. A composite cursor resolves tied timestamps. Add LIMIT and a supporting index. | select id, name, created_at from public.projects
where owner_id = $1::uuid
and (created_at, id) < ($2::timestamptz, $3::uuid)
order by created_at desc, id desc limit 25;
-- Supporting index: (owner_id, created_at desc, id desc) | Official source ↗ |
| Transactions and EXPLAIN | BEGIN/COMMIT group changes; ROLLBACK abandons them. EXPLAIN ANALYZE executes the query, including writes: use a test database or a rollback transaction. | begin;
explain select * from public.projects where owner_id = auth.uid();
rollback; | Official source ↗ |