Supabase vs PlanetScale: 7 Foreign Keys Into a Table MySQL Doesn't Have
The dialect differences port. The 7 foreign keys into auth.users and 6 policies built on auth.uid() don't — inventoried line by line from this storefront's 8 migrations.
PlanetScale is a MySQL database. Supabase is a Postgres database plus the auth service this schema is wired into. Most comparisons stop at Postgres versus MySQL, which is the portable half. The half that does not port is seven foreign keys pointing at auth.users — a table that lives in the platform, not in the database you would be moving.
Start by reading your own schema, not the feature tables
Every comparison in this category is organised the same way: sharding ceiling, branching workflow, pricing tier, a benchmark chart. Those are real differences and they matter at the top end. They also assume the thing being moved is a set of tables, which is the assumption worth testing before anything else.
This storefront's entire backend is eight migration files in supabase/migrations/, defining six tables, six row-level security policies and four functions. Small enough to inventory line by line, which is the only way to answer the question honestly. Here is what that inventory returns, counted from the files themselves:
| What this schema uses | Count | MySQL / PlanetScale equivalent | Port cost |
|---|---|---|---|
references auth.users(id) | 7 | No such table — auth is not in the database | Redesign |
auth.uid() in a policy | 6 | No request-scoped user function | Redesign |
enable row level security + create policy | 6 policies | No row-level security in MySQL | Redesign |
text[] parameter | 2 | No array type | Rewrite as JSON |
inet column | 3 | No IP type; VARBINARY(16) + INET6_ATON | Rewrite |
uuid column / gen_random_uuid() | 23 / 5 | No native UUID type; BINARY(16) + UUID() | Rewrite |
jsonb | 3 | JSON exists, without the operator/index surface | Mostly fine |
timestamptz | 8 | TIMESTAMP normalises to UTC; DATETIME does not | Mostly fine |
citext | 5 | Not needed — MySQL collations are case-insensitive | Simpler |
pg_advisory_xact_lock | 1 | GET_LOCK(), with different release semantics | Correctness risk |
Three of those rows are ordinary dialect work — mechanical, tedious, done. One is genuinely easier on MySQL. One is a concurrency bug waiting to be introduced. And the top three are not database features at all.
The seven foreign keys that are not really about the database
Here is the first table in 0001_commerce_init.sql:
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
display_name text check (
display_name is null
or (char_length(display_name) between 1 and 40 and display_name !~ '[<>]')
),
created_at timestamptz not null default now()
);
The primary key is a foreign key into auth.users — a table owned by Supabase's auth service, in a schema this application never writes to. Four of the six tables are wired the same way, seven references in total across three migrations. on delete cascade means a user deletion in the auth service propagates through the commerce schema without any application code running.
PlanetScale has no auth.users, because PlanetScale is not in the authentication business. That is not a deficiency — it is a different product boundary. But it means the port is not "translate this DDL". It is: choose an auth provider, decide how its user identity reaches your database, and rebuild referential integrity across a boundary that is now a network call instead of a foreign key. Every on delete cascade becomes a webhook or a cleanup job you write and maintain.
The same boundary explains the policies. Four of the six ship in that first migration; the other two arrived with the tables they guard, in 0008_refund_requests.sql:
create policy "own profile" on public.profiles for select using ((select auth.uid()) = id);
create policy "own orders" on public.orders for select using ((select auth.uid()) = user_id);
create policy "own slots" on public.entitlement_slots for select using ((select auth.uid()) = user_id);
create policy "own downloads" on public.download_events for select using ((select auth.uid()) = user_id);
auth.uid() reads the verified user id out of the JWT the request carried. MySQL has no row-level security and no request-scoped identity function, so all six of these move into application code — every query that touches these tables grows a WHERE user_id = ? that a reviewer now has to check by eye, forever. What happens to policies like these when a layer that cannot see them sits in front of the database is the subject of Supabase vs Prisma; the point here is narrower and harsher, because on MySQL the policies do not get bypassed, they simply have nowhere to exist.
The one row where MySQL is straightforwardly better
Being fair about this cuts both ways. 0001_commerce_init.sql opens by installing an extension:
create extension if not exists citext with schema extensions;
and two columns use it — profiles.email and entitlement_slots.claim_email. citext is a case-insensitive text type, and it exists because Postgres compares text case-sensitively by default, which is wrong for email addresses. MySQL's default collations are case-insensitive already. On PlanetScale those two columns are plain VARCHAR and the extension, the dedicated extensions schema it lives in, and the five call sites that cast into it all disappear.
That is one row of the table, and it is worth stating plainly rather than burying: on this specific point, the Postgres version is carrying a workaround the MySQL version would not need.
The row that is a correctness problem, not a syntax problem
0007_atomic_download_rate_limit.sql closes a check-then-act race in the download limiter. Its first statement inside the function body is a lock:
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text, 0));
select count(*)::int into recent
from public.download_events
where user_id = p_user_id
and created_at > now() - interval '1 hour';
The word doing the work is xact. A Postgres transaction-scoped advisory lock is released automatically when the transaction commits or rolls back — including when it rolls back because something threw. The function cannot leak the lock.
MySQL's nearest equivalent, GET_LOCK(), is session-scoped. It is not released on commit; it is released by an explicit RELEASE_LOCK() or when the connection ends. Translate that line literally and you have a rate limiter that holds a per-user lock across a pooled connection's next request, which is a worse failure than the race it was written to fix. It is portable, but only if you notice that the semantics changed — and a mechanical port is exactly the process that does not notice. Why this codebase kept the lock rather than switching to a database whose transaction model removes the need for one is the Supabase vs Convex question; the race itself, and the download route around it, are in gated file downloads in Next.js.
What PlanetScale is actually for
None of the above argues PlanetScale is the weaker product. It argues that this schema is coupled to a platform, which is a different claim. PlanetScale's design centre is real and this application never touches it:
- Vitess sharding. Horizontal scale is the founding premise. This schema's largest table is an append-only download-audit log, which is nowhere near needing it.
- Branching and deploy requests. Schema changes go through a database branch and a review step, and land without locking tables. That workflow is genuinely better than anything in this repository's process — with the caveat that our process is eight reviewed
.sqlfiles applied through the CLI, described in the Supabase SQL Editor post, and eight files have never made a lock-free migration necessary. - A database, not a backend. If you already have auth, storage and an API layer you are happy with, the bundled services that make this schema hard to move are things you would be paying for and not using.
The honest summary is that the two products answer different questions. PlanetScale answers "how does this table survive growth." Supabase answered, for this codebase, "how do I get authenticated read-your-own-rows without writing an authorization layer" — and the answer is why seven foreign keys and six policies exist at all.
When this decision would flip here
If the entitlement model stopped being per-user — if downloads were keyed by a signed token instead of a session, which would remove auth.uid() from all six policies — most of the coupling in the table above evaporates, and the choice becomes an ordinary database comparison decided on scale and workflow. That is not a hypothetical: it is the same axis that decides whether a license key or a database row is the right primitive, worked through in why this storefront has no license keys.
The general rule this produces: count the objects in your schema that reference something outside the database before comparing two databases. If that count is zero, the feature tables are the right comparison. Here it is thirteen.
Mistakes and how they show up
| Mistake | What you see | Fix |
|---|---|---|
| Comparing on benchmarks and pricing alone | The migration stalls on auth, not on queries | Inventory cross-service references first |
Porting pg_advisory_xact_lock to GET_LOCK() | Locks survive the transaction and stall a pooled connection | Add explicit release, or redesign the limiter |
| Assuming RLS translates | Policies silently have nowhere to live | Move authorization into application code, and review every query |
Keeping citext thinking on MySQL | An unnecessary type and casts nobody needs | Plain VARCHAR; the collation is already case-insensitive |
Storing UUIDs as CHAR(36) on MySQL | Bloated indexes on every join column | BINARY(16) with UUID_TO_BIN/BIN_TO_UUID |
Expecting DATETIME to behave like timestamptz | Timestamps shift with the server's zone | Use TIMESTAMP, which normalises to UTC |
Frequently asked questions
Is PlanetScale a drop-in replacement for Supabase? No, and not because of MySQL versus Postgres. Supabase bundles authentication, storage and row-level security with the database; PlanetScale is the database. If your schema references the auth service — this one does seven times — those references have no target after the move.
Does PlanetScale support row-level security? MySQL has no row-level security primitive, so neither does PlanetScale. Per-row authorization becomes application code: a filter on every query, enforced by review rather than by the database.
Can I keep Postgres features like arrays and inet on PlanetScale?
No. MySQL has no array type — array parameters become JSON — and no IP address type; the usual substitute is VARBINARY(16) with INET6_ATON. jsonb maps onto MySQL's JSON reasonably well, minus the operator and indexing surface.
Which is better for a small project? For a project that needs authenticated users reading their own rows, the bundled model removes an entire layer you would otherwise write — that is what six policies replaced here. For a project that already has auth solved and expects to shard, the calculus reverses, and PlanetScale's branching workflow and Vitess lineage are the reason to choose it.
Templates on this backend
ASoc Axiom is an AI-consultancy landing page, ASoc Beacon is a mobile-device-management marketing site, and ASoc Beaker is a science-lab services site — all three sold through the commerce schema described above, the same eight migrations and six policies.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the same inventory exercise run against a Postgres-to-Postgres move, read Supabase vs Neon; for the permission-model axis against a different backend, read Supabase vs Appwrite.
