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
- Inventory the schema and the queries.
- Dump the PlanetScale database.
- Load the dump into a local MySQL.
- Convert to Postgres with pgloader.
- Fix types, add foreign keys and constraints.
- Port the app's queries and ORM settings.
- 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),unsignedintegers,enum,set,datetimeversustimestamp,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.
grepfor backticks,IFNULL,GROUP_CONCAT,ON DUPLICATE KEY UPDATE,LIMIT x, yandREPLACE 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:
pscale database dump mydb main --output ./ps-dumpPlanetScale 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:
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"; doneSchema 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:
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';pgloader planetscale.loadUse 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:
| MySQL | Postgres after pgloader |
|---|---|
tinyint(1) | boolean |
int auto_increment | serial (or bigserial for large precision) |
int unsigned | bigint (widened to fit the range) |
datetime, timestamp | timestamptz |
Zero dates 0000-00-00 | NULL (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 prefertextwith acheckconstraint. - Identity columns.
serialworks, but modern Postgres style isgenerated 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.
-- 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:
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:
| MySQL | Postgres |
|---|---|
`column` | "column" (or no quotes at all) |
IFNULL(a, b) | coalesce(a, b) |
GROUP_CONCAT(x) | string_agg(x, ',') |
LIMIT 20, 10 | LIMIT 10 OFFSET 20 |
INSERT ... ON DUPLICATE KEY UPDATE | INSERT ... ON CONFLICT (key) DO UPDATE SET ... |
NOW() | now() (same), but timestamptz carries the time zone |
col = 'abc' case-insensitive | citext, 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
- Do the full process once on a copy. Time each step.
- Run your app against the converted database in staging.
- On cut-over day: stop writes, dump, load, convert, verify row counts per table, switch the connection string, resume.
-- 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
publicneeds 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.
#What about full-text search?
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.