Skip to main content
ASoc
Comparison

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.

The ASoc Team9 min read

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 usesCountMySQL / PlanetScale equivalentPort cost
references auth.users(id)7No such table — auth is not in the databaseRedesign
auth.uid() in a policy6No request-scoped user functionRedesign
enable row level security + create policy6 policiesNo row-level security in MySQLRedesign
text[] parameter2No array typeRewrite as JSON
inet column3No IP type; VARBINARY(16) + INET6_ATONRewrite
uuid column / gen_random_uuid()23 / 5No native UUID type; BINARY(16) + UUID()Rewrite
jsonb3JSON exists, without the operator/index surfaceMostly fine
timestamptz8TIMESTAMP normalises to UTC; DATETIME does notMostly fine
citext5Not needed — MySQL collations are case-insensitiveSimpler
pg_advisory_xact_lock1GET_LOCK(), with different release semanticsCorrectness 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 .sql files 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

MistakeWhat you seeFix
Comparing on benchmarks and pricing aloneThe migration stalls on auth, not on queriesInventory cross-service references first
Porting pg_advisory_xact_lock to GET_LOCK()Locks survive the transaction and stall a pooled connectionAdd explicit release, or redesign the limiter
Assuming RLS translatesPolicies silently have nowhere to liveMove authorization into application code, and review every query
Keeping citext thinking on MySQLAn unnecessary type and casts nobody needsPlain VARCHAR; the collation is already case-insensitive
Storing UUIDs as CHAR(36) on MySQLBloated indexes on every join columnBINARY(16) with UUID_TO_BIN/BIN_TO_UUID
Expecting DATETIME to behave like timestamptzTimestamps shift with the server's zoneUse 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.

Keep reading

Comparison9 min read

Supabase vs Prisma: An ORM Cannot See Your Row-Level Security

Point Prisma at a Supabase database and your policies stop working — in one of two directions, neither the one you wrote. Our own six policies, audited against that.

Read more
Comparison9 min read

SvelteKit vs. Remix: Same Primitives, Different Component Bill

Both answer routing, data loading and form posts the same way. The divergence is what reaches the browser — and how much of a page you can avoid shipping at all.

Read more
Comparison10 min read

Tailwind CSS v4 vs Bootstrap 5 for Dashboard UIs in 2026

A fair comparison of two mature CSS frameworks for admin UIs — component coverage, customization ceiling, bundle size, and the team each one suits.

Read more