Migrate from MySQL

Move a MySQL or MariaDB database to Postgres on SupaHost with pgloader: type differences to expect, loading over the SSH tunnel or via a local Postgres, and checking the result.

The usual tool for MySQL to Postgres is pgloader: it reads the MySQL schema, converts types, creates tables and copies the data in one run. pgloader needs a direct Postgres connection as its target. On Business and Enterprise that's your project through the SSH tunnel. On other plans you run pgloader into a local Postgres 17 first, then move the result to SupaHost.

Plan

  • Size. The converted database must fit your plan's shared quota (8 GB Builder, 25 GB Pro, 50 GB Business).
  • Read the type differences below and grep your app for MySQL-only SQL (backticks, LIMIT x, y, ON DUPLICATE KEY UPDATE, IFNULL, GROUP_CONCAT).
  • Downtime. Stop writes to MySQL before the final run.

Type and behaviour differences

MySQL Postgres on SupaHost
tinyint(1) boolean (pgloader converts by default)
int unsigned bigint (Postgres has no unsigned types)
AUTO_INCREMENT generated ... as identity or serial
datetime timestamp; consider timestamptz
0000-00-00 dates not valid; pgloader turns them into null
enum('a','b') a Postgres enum type or text with a check constraint
case-insensitive = on strings case-sensitive; use citext or lower() indexes
ON DUPLICATE KEY UPDATE insert ... on conflict (...) do update

Export and convert

Write a pgloader command file, bookings.load:

LOAD DATABASE
  FROM mysql://app:[email protected]:3306/bookings
  INTO postgresql://postgres:<password>@127.0.0.1:6543/p_app_one

WITH include drop, create tables, create indexes, reset sequences, foreign keys

ALTER SCHEMA 'bookings' RENAME TO 'public';

The ALTER SCHEMA line matters: pgloader otherwise creates a schema named after the MySQL database, and the Data API only exposes public, storage, graphql_public and pgmq_public.

Load

Business and Enterprise. Open the tunnel, then run pgloader against it:

ssh -N -L 6543:127.0.0.1:5432 [email protected] &
pgloader bookings.load

Get the password from Dashboard → your project → Connect → Database → Show connection string.

Builder and Pro. Point INTO at a local Postgres 17 (for example docker run -p 6543:5432 -e POSTGRES_PASSWORD=dev postgres:17), run pgloader, check the result, then:

  • dump it (pg_dump -Fc --no-owner --no-privileges -n public), restore it into a temporary Supabase Cloud project and use the one-click import, or
  • for small databases, run the schema from pg_dump --schema-only in Studio's SQL editor and import each table as CSV in the table editor.

After loading

-- make the new tables safe to expose, then refresh planner stats
alter table public.reservations enable row level security;
alter table public.rooms enable row level security;
analyze;

Add policies for each table. If you didn't use reset sequences, see duplicate key after importing data.

Switch the app

Replace the MySQL driver with supabase-js:

const { data, error } = await supabase
  .from('reservations')
  .upsert({ room_id: 12, night: '2026-12-24', guest_id: userId }, { onConflict: 'room_id,night' })
  .select()

Logic that used stored procedures becomes Postgres functions in PL/pgSQL, called with supabase.rpc().

Verify

Compare SELECT COUNT(*) per table on both sides, compare a few sums or max IDs, and check that text with accents and emoji came through intact (Postgres here is UTF-8; MySQL tables in latin1 sometimes hide double-encoded text).

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us