“migrations that work on both MySQL and Postgres” is mostly about choosing the common subset, and when you can’t, splitting by DB in a controlled way. If you try to force 100% identical SQL, you’ll either lose important constraints or ship fragile hacks.
1) Prefer “portable SQL” (the intersection of both)
Stick to patterns both engines handle similarly:
- Simple DDL:
CREATE TABLE,ADD COLUMN,ADD CONSTRAINT(careful),CREATE INDEX(without vendor options). - Types: map to neutral types:
VARCHAR(n),TEXT,BOOLEAN(see nuance below),BIGINT,DECIMAL(p,s),TIMESTAMP
- Avoid: partial indexes (PG), functional indexes (both can, but syntax differs), generated columns (diff), check constraints behavior (MySQL older versions),
CREATE INDEX CONCURRENTLY(PG-only),LOCK=NONE/ALGORITHM=INPLACE(MySQL-only).
Type mapping rules of thumb
- Auto-increment PK
- Postgres:
BIGSERIALorGENERATED ... AS IDENTITY - MySQL:
BIGINT AUTO_INCREMENT
→ Don’t pretend there’s one SQL for this. Split it.
- Postgres:
- Boolean
- Postgres: real
BOOLEAN - MySQL:
BOOLEANis an alias forTINYINT(1)(works, but semantics differ)
→ If you need strictness, split or standardize onTINYINT(1)with app mapping.
- Postgres: real
- JSON
- Postgres:
jsonb - MySQL:
json
→ Split or store asTEXT(but you lose indexing/ops).
- Postgres:
2) Design for “online schema changes” separately
Zero-downtime patterns differ a lot:
- Postgres:
CREATE INDEX CONCURRENTLY,NOT VALIDconstraints +VALIDATE CONSTRAINT - MySQL: online DDL varies by engine/version; you might rely on
ALGORITHM/LOCKor external tools (gh-ost/pt-online-schema-change)
So: don’t bake vendor-specific “online” syntax into a single universal migration. Either:
- keep migrations minimal and do “online” operations via separate per-DB steps, or
- branch migrations by DB.
3) Use the tool’s DB-aware branching (recommended)
Trying to do this purely with SQL is painful. Use tooling features:
Liquibase (best for multi-DB)
- Use
dbmsattribute:- apply this changeset only on
postgresqlormysql
- apply this changeset only on
- Use
preConditions+onFail="MARK_RAN"carefully - Use
modifySqlfor small syntax tweaks
Example pattern
- One logical change is represented as:
- a Postgres changeset
- a MySQL changeset
- Both share the same semantic intent.
This is the cleanest “works on both” story in interviews: same changelog, DB-specific changesets.
Flyway
Flyway is more “file-driven” SQL. You typically:
- Maintain separate locations per DB:
db/migration/postgresdb/migration/mysql
- Or use callbacks to select locations / config per environment.
- Keep version numbers aligned across folders (so V12 means same logical step).
Interview-quality line:
“With Flyway, I keep separate per-DB migration directories but enforce the same version sequence and review them together.”
4) Keep “data migrations” portable by moving logic to the app
Cross-DB SQL for backfills is where portability goes to die (CTEs, UPDATE ... JOIN, INSERT ... ON CONFLICT, LIMIT in updates, etc.).
Best practice:
- Schema changes in migrations
- Large/complex data backfills in application code (batch job), using:
- paging
- idempotency
- retries
This also helps zero-downtime.
5) Avoid the biggest portability traps
Here are the usual “it worked on PG but died on MySQL” (or vice versa):
- Upserts
- PG:
INSERT ... ON CONFLICT (...) DO UPDATE - MySQL:
INSERT ... ON DUPLICATE KEY UPDATE
→ split.
- PG:
- DDL transactional behavior
- PG is broadly transactional; MySQL often isn’t (depending on statement/engine).
→ don’t rely on rollback semantics; keep each migration small.
- PG is broadly transactional; MySQL often isn’t (depending on statement/engine).
- Quoting identifiers
- PG uses
"; MySQL commonly uses`
→ avoid quoting; pick safe snake_case names.
- PG uses
- Timestamp defaults
- PG:
DEFAULT now() - MySQL:
DEFAULT CURRENT_TIMESTAMP
→ split or set defaults in app.
- PG:
- Constraint validation
- PG supports
NOT VALIDthen validate; MySQL doesn’t the same way
→ design constraints rollout per DB.
- PG supports
6) Practical “recipe” that actually works in teams
- Define a portability contract (the allowed SQL subset).
- For anything outside it: DB-specific migrations (Liquibase
dbms, Flyway folder split). - Keep migrations small and forward-only.
- Run CI on both DBs (Testcontainers): apply from empty DB + upgrade from previous release snapshot.
Interview-ready answer (what to say)
“To support both MySQL and Postgres, I stick to a portable SQL subset for simple DDL and avoid vendor-specific features. When the databases differ—identity columns, upserts, online index creation—I branch migrations per DB: in Liquibase via
dbms-scoped changesets, or in Flyway via separate migration locations with aligned version numbers. For complex data backfills, I prefer application jobs to avoid non-portable SQL and to keep deployments safe.”