MySQL to Postgres Migration: Moving Off PlanetScale (Vitess)

A MySQL to Postgres migration from PlanetScale comes down to three jobs: get a dump out of your Vitess database, convert schema and data with a tool like pgloader, and then fix the parts no tool converts for you, mainly SQL dialect differences in your app's queries. The data move is usually the easy part. Budget most of your time for testing queries.

This guide walks through a migration that works for small and mid-sized databases, and covers the type mapping and query changes that catch most teams.

#First, decide whether you need to change engines

PlanetScale now offers two products: Vitess (MySQL) and PlanetScale Postgres. As of November 2026, PlanetScale's pricing page lists Postgres starting at $5/month for a single node and $15/month for a three-node HA setup, and Vitess clusters starting at $39/month. If you like PlanetScale as a company and only want Postgres, moving to PlanetScale Postgres keeps your vendor and billing the same.

Teams that move to another Postgres host usually want something beyond the database itself: built-in Auth, a REST API generated from the schema, file storage or Realtime. That is where a backend like StitchDB fits. Whichever host you pick, the MySQL-to-Postgres conversion below is the same.

#The MySQL to Postgres migration plan at a glance

  1. Inventory the schema and the queries.
  2. Dump the PlanetScale database.
  3. Load the dump into a local MySQL.
  4. Convert to Postgres with pgloader.
  5. Fix types, add foreign keys and constraints.
  6. Port the app's queries and ORM settings.
  7. Rehearse, freeze writes, do the final copy, switch.

#Step 1: Inventory

Before any tool runs, list what will need attention:

  • Column types that differ: tinyint(1), unsigned integers, enum, set, datetime versus timestamp, json, zero dates.
  • Collations. MySQL's default collations are case-insensitive. Postgres comparisons are case-sensitive by default, so where email = '[email protected]' starts behaving differently.
  • Raw SQL in your app. grep for backticks, IFNULL, GROUP_CONCAT, ON DUPLICATE KEY UPDATE, LIMIT x, y and REPLACE INTO.
  • Foreign keys. Many Vitess schemas were built without them. Moving to Postgres is a good moment to add them.

#Step 2: Dump from PlanetScale

The PlanetScale CLI can dump a branch to a local folder:

Terminal
pscale database dump mydb main --output ./ps-dump

PlanetScale documents the output as SQL files compatible with mydumper: a schema file and one or more data files per table. Check pscale database dump --help for the flags your CLI version supports.

For the final copy at cut-over, writes must be stopped first, because the dump is a point-in-time snapshot and PlanetScale changes will not stream into Postgres afterward.

#Step 3: Load into a local MySQL

pgloader reads from a live MySQL server, not from dump files. The simplest setup is a local MySQL 8 instance on your laptop or a temporary VM:

Terminal
mysql -u root -e "create database mydb"
for f in ps-dump/*-schema.sql; do mysql -u root mydb < "$f"; done
for f in ps-dump/*.0*.sql; do mysql -u root mydb < "$f"; done

Schema files first, then data files. Adjust the glob patterns to the file names in your dump folder.

#Step 4: Convert with pgloader

pgloader migrates schema and data from MySQL to Postgres in one command. A load file gives you control over casting:

Text
LOAD DATABASE
  FROM mysql://root@localhost/mydb
  INTO postgresql://p_<ref>_owner:<password>@<db-host>:5432/p_<ref>?sslmode=require

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

CAST type datetime to timestamptz drop default drop not null using zero-dates-to-null,
     type tinyint when (= precision 1) to boolean using tinyint-to-boolean

ALTER SCHEMA 'mydb' RENAME TO 'public';
Terminal
pgloader planetscale.load

Use a session-mode connection (port 5432 on StitchDB) for the load. Transaction-mode pooling on 6543 is for short app transactions, not bulk tools. See Connect to Postgres.

#What pgloader does by default

From pgloader's MySQL documentation:

MySQLPostgres after pgloader
tinyint(1)boolean
int auto_incrementserial (or bigserial for large precision)
int unsignedbigint (widened to fit the range)
datetime, timestamptimestamptz
Zero dates 0000-00-00NULL (with zero-dates-to-null)
enum('a','b')A Postgres enum type named after table and column

Review the output. Two common tweaks:

  • Enums. Postgres enums are fine, but adding a value later needs alter type ... add value. Many teams prefer text with a check constraint.
  • Identity columns. serial works, but modern Postgres style is generated always as identity. You can convert after the load if you care.

#Step 5: Fix types and add constraints

After the load, tighten the schema. This is where Postgres starts paying for itself.

SQL
-- Case-insensitive e-mail matching, as MySQL did by default
create extension if not exists citext;
alter table public.users alter column email type citext;

-- Foreign keys Vitess did not have
alter table public.orders
  add constraint orders_user_fk foreign key (user_id)
  references public.users (id) not valid;
alter table public.orders validate constraint orders_user_fk;

-- json to jsonb, for indexing and operators
alter table public.events alter column payload type jsonb using payload::jsonb;

Adding a foreign key as not valid and validating it separately avoids a long write lock on large tables. StitchDB's SQL Editor and stitchdb db push flag statements like these and suggest the safer form; see Safe schema changes.

Orphaned rows (an order whose user no longer exists) will make validation fail. Find them first:

SQL
select o.id from public.orders o
left join public.users u on u.id = o.user_id
where u.id is null;

#Step 6: Port your queries

These are the changes that come up in almost every codebase:

MySQLPostgres
`column`"column" (or no quotes at all)
IFNULL(a, b)coalesce(a, b)
GROUP_CONCAT(x)string_agg(x, ',')
LIMIT 20, 10LIMIT 10 OFFSET 20
INSERT ... ON DUPLICATE KEY UPDATEINSERT ... ON CONFLICT (key) DO UPDATE SET ...
NOW()now() (same), but timestamptz carries the time zone
col = 'abc' case-insensitivecitext, lower(col) = lower('abc') or ilike
LAST_INSERT_ID()INSERT ... RETURNING id

ORMs hide most of this. Switch the dialect or provider (Prisma provider = "postgresql", Drizzle's pg-core, Sequelize dialect: 'postgres'), regenerate migrations from the current Postgres schema, and run your test suite. Raw SQL is where the bugs hide.

Postgres is stricter about GROUP BY: every selected column must be grouped or aggregated. Queries that MySQL accepted may now fail. That is usually a latent bug surfacing.

#Step 7: Rehearse, then cut over

  1. Do the full process once on a copy. Time each step.
  2. Run your app against the converted database in staging.
  3. On cut-over day: stop writes, dump, load, convert, verify row counts per table, switch the connection string, resume.
SQL
-- Compare with SELECT COUNT(*) per table on the MySQL side
select relname, n_live_tup from pg_stat_user_tables order by relname;

n_live_tup is an estimate; use count(*) for the tables that matter most.

#If StitchDB is the destination

A few StitchDB-specific notes:

  • RLS. If you plan to use the generated REST API from browsers or mobile apps, every table in public needs Row Level Security and policies. RLS Guard turns RLS on for new tables in new projects by default, so tables created by pgloader return no rows through the API until you add policies. Your server code, connecting as the owner role, is not affected. See Row Level Security.
  • Migrations from now on. Capture the converted schema as your first migration with stitchdb db pull, then keep changes in files. See CLI migrations.
  • Trade-offs. StitchDB runs in EU data centers only, so pick it if your users or compliance needs are in Europe, or if latency from your app servers to the EU is acceptable. It is a younger platform than PlanetScale, and it does not offer Vitess-style horizontal sharding. Large databases scale by moving to a bigger dedicated server.

For a wider comparison, see PlanetScale alternatives and Postgres vs MySQL.

#FAQ

#Can I stream changes from PlanetScale to Postgres to avoid downtime?

Not with the tools in this guide. They take a snapshot, so you need a write freeze for the final copy. Measure it during the rehearsal.

#Will my ORM migrations still work?

The migration history will not. Start a fresh history from the converted Postgres schema and keep the old MySQL migrations for reference.

MySQL FULLTEXT indexes do not convert. Rebuild search with Postgres tsvector and a GIN index, or with pg_trgm for fuzzy matching.

#Does pgloader handle very large databases?

It is widely used for large migrations, but the time scales with data size and index builds. Test with your real data.

Ready to try Postgres with Auth and APIs included? Start a free StitchDB project and load a test copy.