Most MySQL-to-PostgreSQL migration checklists cover schema types, SQL dialect, and drivers. Very few cover the item that has caused the most subtle production bugs I have seen after a cutover: the default transaction isolation level changes underneath the application. InnoDB defaults to REPEATABLE READ. PostgreSQL defaults to READ COMMITTED. If your application code was written against MySQL defaults and nobody thought about isolation, some of your assumptions are about to become quietly wrong.
The good news is that neither default is broken. Both are reasonable engineering choices, and after operating both engines I would argue PostgreSQL's default is easier to reason about for typical web workloads. But "easier" is not "identical," and the failure mode here is not a crash. It is a report that shows slightly different totals, a balance that is off by one concurrent update, or a batch job that sees rows it did not see a moment ago inside the same transaction.
This article walks through what each default actually guarantees, where InnoDB's behavior is stranger than its name suggests, and what to do about read-modify-write patterns before you migrate.
Two defaults, two philosophies
InnoDB's REPEATABLE READ builds one consistent snapshot per transaction. PostgreSQL's READ COMMITTED builds one snapshot per statement. That single sentence explains most of the visible differences, but both engines have important footnotes.
Under InnoDB REPEATABLE READ, the snapshot is established by the first consistent read in the transaction, not by BEGIN itself. Every plain SELECT after that sees the same version of the database, no matter what other sessions commit. Under PostgreSQL READ COMMITTED, every statement sees everything committed before that statement started. Two identical SELECTs inside one PostgreSQL transaction can legitimately return different rows if another session committed in between.
If your application has a transaction that reads a value, does some work, then reads it again expecting the same answer, MySQL was silently protecting you. PostgreSQL at its default will not. Whether that matters depends entirely on whether the code relies on it, and in my experience nobody knows until they look.
The InnoDB footnote: locking reads ignore your snapshot
Here is the part of InnoDB REPEATABLE READ that surprises engineers who have used it for years. The consistent snapshot only applies to non-locking reads. UPDATE, DELETE, SELECT ... FOR UPDATE, and SELECT ... FOR SHARE always read the latest committed version of each row and lock it. So inside a single InnoDB REPEATABLE READ transaction you can observe two different states of the world: your plain SELECT says a row does not exist, and your UPDATE modifies it anyway because another session committed it after your snapshot.
-- Session A (MySQL, REPEATABLE READ)
START TRANSACTION;
SELECT count(*) FROM orders WHERE status = 'pending'; -- returns 5
-- Session B commits a 6th pending order here
-- Session A again:
SELECT count(*) FROM orders WHERE status = 'pending'; -- still 5 (snapshot)
UPDATE orders SET status = 'processing'
WHERE status = 'pending'; -- 6 rows updated
SELECT count(*) FROM orders WHERE status = 'processing'; -- now sees 6
After the UPDATE, the transaction's own snapshot includes the rows it just touched, so subsequent reads see them. The result is a hybrid view that is neither the original snapshot nor the current database. It is well documented, it is intentional, and it still catches people. REPEATABLE READ in InnoDB also brings gap locks and next-key locks on locking reads, which is why some MySQL shops run READ COMMITTED deliberately to reduce lock contention.
PostgreSQL READ COMMITTED: simpler, but per-statement
PostgreSQL's default has no equivalent split personality. Every statement, locking or not, sees one consistent snapshot taken at statement start. When an UPDATE hits a row that a concurrent transaction has already modified, PostgreSQL waits for that transaction to finish. If it commits, PostgreSQL re-checks the WHERE clause against the new row version and proceeds if it still matches. No error, no snapshot anomaly within the statement.
The trade you make is between statements. A multi-statement transaction in PostgreSQL READ COMMITTED is not a point-in-time view. Reports, reconciliation jobs, and any logic that reads the same data twice should either tolerate movement between statements or explicitly ask for a stronger level for that transaction. On PostgreSQL, that is a per-transaction decision, which is a nicer operational posture than changing a server-wide default.
Lost updates: neither default protects you
The most dangerous assumption I see is "REPEATABLE READ means my read-modify-write is safe." It does not, in either engine. In InnoDB, a plain SELECT takes no locks, so two transactions can both read balance = 100, both compute 90, and both write it; the second write wins and an update is lost. PostgreSQL READ COMMITTED behaves the same way for the naive pattern. Migrating does not make this worse, but it also does not fix it, and migration is exactly the moment to fix it.
The portable fixes are the same on both engines. Make the write atomic, take an explicit row lock, or use compare-and-set:
-- Best: push the arithmetic into the statement
UPDATE accounts SET balance = balance - 10
WHERE id = 42 AND balance >= 10;
-- Or: lock the row before reading it
BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- application logic here
UPDATE accounts SET balance = 90 WHERE id = 42;
COMMIT;
-- Or: optimistic concurrency with a version column
UPDATE accounts SET balance = 90, version = version + 1
WHERE id = 42 AND version = 7; -- retry if 0 rows affected
PostgreSQL's REPEATABLE READ, unlike InnoDB's, is true snapshot isolation and will abort the second writer with a serialization error rather than silently letting it proceed against a newer row. That is stronger protection, but it converts silent anomalies into explicit errors your code must retry.
Should you set PostgreSQL to REPEATABLE READ to match?
Tempting, and usually wrong. The names match; the semantics do not. PostgreSQL REPEATABLE READ throws "could not serialize access due to concurrent update" (SQLSTATE 40001) when two transactions update the same row, and your application must catch that and retry the whole transaction. InnoDB REPEATABLE READ never raises that error; it just locks and proceeds against the latest version. Flip the default globally and an application with no retry logic will start surfacing 40001 errors under concurrency it previously absorbed silently.
My recommendation after several of these migrations: keep READ COMMITTED as the PostgreSQL default, audit read-modify-write paths and fix them with the patterns above, and reserve REPEATABLE READ or SERIALIZABLE for the specific transactions that genuinely need a stable snapshot, with retry logic wrapped around them.
What to watch after the cutover
Isolation regressions rarely announce themselves. They show up as data-quality tickets weeks later, or as new lock waits when someone adds FOR UPDATE in the hot path. After the cutover, watch for rows updated by "impossible" code paths, totals that disagree between two reads in one job, growing lock waits on the tables where you added explicit locking, and serialization failure rates if any code runs at REPEATABLE READ. Query-level history matters here: a FOR UPDATE added to fix a lost update can double the latency of a hot transaction, and you want slow query monitoring in place before you start turning those knobs, not after.
How MonPG helps once you are on PostgreSQL
MonPG monitors PostgreSQL only, so it does not help while you are still on MySQL. But it is built for exactly the weeks after a migration, when isolation and locking behavior is the thing you are least sure about. MonPG keeps pg_stat_statements history so you can see whether adding FOR UPDATE or moving a transaction to REPEATABLE READ changed a query family's latency, tracks lock waits and blocking chains so new row-lock contention is visible immediately, and keeps session and wait-event evidence around long enough to answer "what changed" instead of guessing. The PostgreSQL monitoring guide covers the full signal set. Migrate the schema first, but migrate your assumptions about isolation with it, and keep the evidence trail on from day one.