Skip to main content
ASoc
Tutorial

Supabase SQL Editor: This Schema Has Never Been Edited Through It

Studio's SQL Editor is built for one-off queries. Every schema change here shipped instead as one of 8 reviewed migration files — what each tool is actually for.

The ASoc Team8 min read

The SQL Editor is Supabase Studio's browser console — type a statement, run it, see rows. It is the right tool for inspecting data and one-off debugging. It has never been the tool that changed this schema. Every table, policy, and function here shipped as one of 8 numbered .sql files under supabase/migrations/, written, reviewed, and committed like any other code change before it ever ran against a database.

What each tool is actually for

SQL Editor (Studio)A versioned migration file
Where it livesThe browser, against whichever project you're connected tosupabase/migrations/000N_name.sql, in the repo
HistoryStudio keeps a personal query log; nothing in gitgit log on the file, forever
ReviewNone — a query runs the moment you click RunA pull request, like any other diff
Repeatable across environmentsNo — you'd have to remember and retype itYes — same file applies to a fresh branch, staging, or prod
RollbackWhatever you can hand-write nextA new migration that reverses the change, reviewed the same way
What it's good for hereChecking a row during a support request, confirming an index exists, counting entitlement_slots for one userEvery table, policy, function, and constraint this schema has

Nothing about the SQL Editor is unsafe by design — Studio's own SQL Editor feature page is straightforward about what it's for: writing and running queries, with autocomplete and history. The gap is what it doesn't give you: a diff a second person can read before the statement runs, and a file that still says why the change happened a year later.

A migration file is a diff someone can veto before it runs

Migration 0006_pricing_v2_slot_kinds.sql is a one-line constraint swap — the pricing model changed from three template-scoped slots to one all_templates slot, so the entitlement_slots.kind allowlist needed a new value:

-- Pricing v2 (grant-by-template): update the entitlement_slots.kind allowlist.
-- Safe to run: the CHECK is a constraint swap only; the create_order_with_slots
-- RPC is kind-agnostic (inserts one row per element of p_slot_kinds), so it
-- needs no change. No existing rows use the removed kind (commerce is not live).
alter table public.entitlement_slots
  drop constraint if exists entitlement_slots_kind_check;

alter table public.entitlement_slots
  add constraint entitlement_slots_kind_check
  check (kind in ('template_single', 'all_templates', 'all_access'));

That comment block is the whole point. "Safe to run" is a claim a reviewer can check against the RPC it references before the statement executes — the same way they'd check any other pull request. Typed into the SQL Editor instead, the identical alter table runs the instant you press the shortcut, and the reasoning for why it was safe lives only in whoever's memory wrote it, if anywhere.

The hardening pass a reviewable history makes possible

0004_lock_down_rate_limit_fn.sql is four lines, and it only exists because the migration before it was sitting in a diff someone could re-read:

-- Consistency/defense-in-depth: the download endpoint calls downloads_in_last_hour
-- via the service-role admin client, so revoking EXECUTE from public roles doesn't
-- affect it (it's security-invoker + RLS so it was already non-leaking).
revoke execute on function public.downloads_in_last_hour(uuid) from anon, authenticated, public;

downloads_in_last_hour() shipped in 0001_commerce_init.sql without an explicit revoke, so Postgres's default EXECUTE grant to PUBLIC stood: any authenticated client could call the function directly, even though the function itself was already safe (security invoker, RLS-scoped). The fix was noticing the gap on a later pass through the file and closing it in four lines, with a comment stating exactly why it was defense-in-depth rather than a live vulnerability. That kind of second look is what a file sitting in version control invites — a query already run and forgotten in the SQL Editor doesn't get re-read.

This codebase's actual concurrency bug — a check-then-act race in the download rate limiter, fixed in 0007_atomic_download_rate_limit.sql with a transaction-scoped advisory lock — has its own full writeup in the gated-downloads post, and the backend-tradeoff angle on it is in Supabase vs Convex; this post cites both rather than re-deriving the function. What's worth adding here is how that fix shipped: as 0007, a file in the same pull request as the download route code that calls it, reviewed together — not as a statement someone ran once against the production database and then went and updated the route separately.

What all 8 migrations actually did

FileWhat it did
0001_commerce_init.sqlInitial schema — profiles, orders, entitlement_slots, download_events, four read-own RLS policies, the rate-limit counter function
0002_display_name_allowlist.sqlSwapped a denylist CHECK for a Unicode-safe allowlist regex — covered in depth here
0003_commerce_rpcs.sqlAdded the two atomic SECURITY DEFINER RPCs — create_order_with_slots() and refund_order()
0004_lock_down_rate_limit_fn.sqlRevoked a default PUBLIC EXECUTE grant — the hardening pass above
0005_retain_anonymized_download_audit.sqlChanged a cascade-delete foreign key to set null, so the abuse-audit trail survives account deletion
0006_pricing_v2_slot_kinds.sqlThe constraint swap above, for a pricing-model change
0007_atomic_download_rate_limit.sqlFixed the TOCTOU race — full function in the gated-downloads post
0008_refund_requests.sqlAdded self-service refund requests, with a partial unique index (where status = 'pending') enforcing at most one pending request per order

Eight files, eight pull requests, eight commit messages that still say why. None of that exists for a statement typed into a browser tab.

When the SQL Editor is still the right call

None of this makes the SQL Editor the wrong tool — it's the wrong tool for a schema change. For everything else it's exactly what you want: confirming a specific user's entitlement_slots rows during a support ticket, checking whether an index exists before assuming a query is slow because of a missing one, or counting rows while debugging a webhook that didn't fire. Those are questions, not changes — nothing about them needs to survive as a file, and forcing every diagnostic query through a migration would be the opposite mistake: version-controlling things nobody will ever need to reapply.

The dividing line is whether the statement should run again, identically, somewhere else — a fresh branch database, staging, another developer's machine. If yes, it is schema, and it belongs in a file with a message next to it. If the answer is "no, I just needed to know the count," the SQL Editor is faster and nothing was lost by not versioning it.

Mistakes and how they show up

MistakeWhat happensThe fix
Running an alter table directly in the SQL Editor against productionNo diff exists for anyone to review before it executes; the reasoning lives in Slack or nowhereWrite it as a migration file first, even if you'll apply it through the CLI a minute later
A SECURITY DEFINER function with no search_path pinnedThe function resolves unqualified names against whatever search_path the caller has — a classic Postgres privilege-escalation vectorEvery function here sets search_path = public, pg_temp explicitly, visible in the file, checkable in review
Leaving the default PUBLIC EXECUTE grant on a new functionAny authenticated (sometimes anonymous) role can call it, whether or not that was intendedrevoke execute ... from anon, authenticated, public as its own line, the way 0004 does it
Treating the SQL Editor's query history as your migration recordStudio's history is personal and not guaranteed to survive a project change or team member leavingThe migration file is the record; the editor's history is scratch space
One migration doing five unrelated thingsA reviewer can't approve "the safe half" of a file — it's one unitSmall files, one concern each, the way 0004 is four lines and does one thing

Frequently asked questions

Is it safe to run schema changes through the SQL Editor in production? Nothing stops you, and Supabase doesn't warn you before you do it — but nothing reviews it either. The statement runs immediately against whatever project you're connected to, with no diff and no second reader. This schema's actual practice is to write the same statement as a migration file and apply it the same way any other code change ships.

Can I paste a migration file's contents into the SQL Editor instead of using the CLI? You can, and it will run — but you lose the point of having a file. The value isn't the .sql syntax; it's that the file sat in a reviewable pull request before anyone ran it. Pasting it into the editor to apply manually is fine for a one-off local check; it shouldn't replace the CLI/migration workflow for anything that ships.

Does Supabase track who ran what in the SQL Editor? Studio keeps a per-user query history in the dashboard, but it isn't a project-wide, git-tracked audit log the way a migrations folder is. If the question is "what changed this table and why," a migration file answers it directly; the editor's history is a personal convenience, not a record.

What's the difference between the SQL Editor and the Table Editor? The Table Editor is a spreadsheet-style UI over one table — browse and edit rows without writing SQL. The SQL Editor is the raw query console. Neither one is where this schema's structure comes from; both are for looking at or nudging data that already exists under a schema defined in supabase/migrations/.

Templates where this ships

ASoc Signal is an AI voice & image studio landing page, ASoc Sterling is a wealth-management marketing site, and ASoc Surge is an AI-startup landing page — all three ship on the same commerce backend this post describes, with every schema change behind them a file in supabase/migrations/, never a query typed live into a console.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the concurrency fix cited above, read gated file downloads in Next.js; for the backend-choice framing it feeds into, read Supabase vs Convex.

Keep reading

Tutorial9 min read

Tailwind Button: 15 Utilities, 3 Variants, and Zero Buttons

The component called Button renders a link all 22 times it is used, while 44 real button elements sit elsewhere. The full class list, and the disabled state it was missing.

Read more