10 min read

What Silently Changes in Your ORM When You Move MySQL to PostgreSQL

Your ORM will connect to PostgreSQL on day one and still surprise you for weeks. These are the behavior changes I watch for: case sensitivity, booleans, upserts, RETURNING, and transaction aborts.

The promise of an ORM is that the database is an implementation detail. Change the connection string, run the migrations, and the same models keep working. Having run MySQL for years and PostgreSQL for more, I can tell you the promise is about 90% true. The query builder ports cleanly. The remaining 10% is semantics, and semantics is where production incidents live.

Whether the framework is Django, Rails, Laravel, Hibernate, or Prisma, the failure modes after a MySQL to PostgreSQL move are remarkably consistent, because they come from the databases, not from the frameworks. This article walks through the five that bite most often. None of them shows up in a smoke test. All of them show up in week two.

String comparisons stop being case-insensitive

This is the big one, and it is invisible in the code. MySQL's default collations carry the ci suffix for a reason: utf8mb4_0900_ai_ci compares strings accent-insensitively and case-insensitively. Every equality lookup your ORM generates, every unique index MySQL enforces, has been quietly ignoring case for the life of the application.

PostgreSQL's default collations are deterministic, and equality is case-sensitive. The same ORM call, a find-by-email with 'Alice@Example.com', matched a row in MySQL and returns nothing in PostgreSQL. Users cannot log in. Deduplication logic stops deduplicating. And because the query itself is valid, nothing errors; the result set just changes.

The fixes, roughly in order of my preference: normalize the value at write time in the model layer; use a functional index plus an explicit lower() comparison in the affected lookups; or use the citext extension so the column itself compares case-insensitively.

-- Support case-insensitive lookups without changing the column type
CREATE UNIQUE INDEX users_email_lower_idx ON users (lower(email));

-- The ORM-side lookup then needs to match the expression:
SELECT id, email FROM users WHERE lower(email) = lower('Alice@Example.com');

Most ORMs have a spelling for this: a case-insensitive lookup operator, an ILIKE variant, or a raw expression in the scope. The work is finding every lookup that silently relied on MySQL's collation. Grepping for equality filters on email, username, slug, and code columns is a good start, and it belongs on your migration review checklist before cutover, not after.

Booleans become real booleans

MySQL does not have a boolean type; BOOLEAN is an alias for TINYINT(1), and every ORM maps true to 1 and false to 0. That mapping leaks. Codebases accumulate raw fragments over the years: a where clause with active = 1, a report query with is_deleted = 0, a database view someone wrote by hand.

PostgreSQL has a genuine boolean type and does not implicitly cast integers to it in comparisons. Those fragments now fail with "operator does not exist: boolean = integer". This one at least fails loudly, which makes it the easiest of the five to fix, but the fragments hide in odd places: report builders, admin dashboards, ETL jobs, and conditions inside CASE expressions. The model attributes themselves are fine; it is everything written around the ORM that breaks.

The fix is mechanical: use true and false literals, or better, stop writing raw boolean comparisons and let the ORM generate them. While you are in there, check for the string forms too: '1' and '0' as bound parameters against a boolean column will also misbehave in ways MySQL forgave.

Upserts change shape and meaning

MySQL's upsert is INSERT ... ON DUPLICATE KEY UPDATE, and it has a property many teams do not realize they depend on: it fires on a conflict with any unique index on the table. PostgreSQL's INSERT ... ON CONFLICT requires you to name the conflict target, a specific column set or constraint.

-- MySQL: reacts to a conflict on ANY unique key
INSERT INTO counters (name, day, hits)
VALUES ('signup', '2026-07-07', 1)
ON DUPLICATE KEY UPDATE hits = hits + 1;

-- PostgreSQL: you must say WHICH uniqueness you mean
INSERT INTO counters (name, day, hits)
VALUES ('signup', '2026-07-07', 1)
ON CONFLICT (name, day) DO UPDATE SET hits = counters.hits + 1;

This is stricter and, honestly, better: a table with two unique indexes has ambiguous upsert semantics in MySQL, and I have debugged exactly that ambiguity in anger. But it means your ORM's upsert helper now needs the conflict columns spelled out, and any table where the "natural" unique key is not the one the code assumed will behave differently.

Watch for REPLACE INTO as well. It is not an upsert; it deletes the conflicting row and inserts a new one, which fires delete triggers and cascades and resets the auto-increment identity of the row. PostgreSQL has no equivalent, and the correct translation is almost always ON CONFLICT DO UPDATE, not a delete-and-insert emulation.

RETURNING removes round trips your code may be counting

After an insert, MySQL applications recover the new primary key through LAST_INSERT_ID(), which only covers the auto-increment column. Anything else the database generated, defaults, trigger-populated columns, computed timestamps, requires a follow-up SELECT, and most ORMs on MySQL quietly issue one or simply hold stale values in memory.

PostgreSQL's INSERT, UPDATE, and DELETE all support RETURNING, and ORMs use it aggressively: one statement inserts the row and hands back every database-generated value, including for multi-row inserts. This is a strict improvement, but it changes observable behavior. Query logs and test expectations that counted statements will differ. Model instances that were subtly stale on MySQL are now correct, which can unmask code that depended on the staleness. And database defaults you never bothered to mirror in application code suddenly start appearing in the objects.

One error aborts the whole transaction

This is the change most likely to cause a production incident, because it is a control-flow difference, not a query difference. In MySQL, if a statement inside a transaction fails, the transaction generally remains usable; the application can catch the error and issue more statements. A very common pattern is: try the insert, catch the duplicate-key error, do a select or update instead, carry on.

In PostgreSQL, any error aborts the transaction. Every subsequent statement fails with "current transaction is aborted, commands ignored until end of transaction block" until you roll back. The catch-and-continue pattern does not just misbehave, it poisons everything after it in the same transaction, and the error your monitoring sees is the confusing downstream one, not the root cause.

There are two honest fixes. First, restructure so the expected error never happens: ON CONFLICT handles the duplicate-key case natively. Second, use savepoints, which is what ORM nested-transaction constructs actually emit on PostgreSQL; a failed statement inside a savepoint can be rolled back to the savepoint without killing the outer transaction. Audit every place your code rescues a database error inside a transaction block, because each one is a latent 25P02.

Smaller things that show up in week two

A few more, briefly. PostgreSQL has always enforced strict GROUP BY semantics; if you disabled ONLY_FULL_GROUP_BY in MySQL, those queries need rewriting. Integer division truncates in PostgreSQL, while MySQL's division operator returns a decimal, so avg-style arithmetic in raw SQL can shift. Sort order changes with collation, so paginated lists can reorder. Zero dates like '0000-00-00' do not exist in PostgreSQL and must be converted to NULL during data transfer. And implicit string-to-number casts that MySQL performed for you will now raise errors in comparisons between mismatched types. None of these is hard alone; the cost is that they arrive together. The broader PostgreSQL overview covers where the engine's strictness comes from, and why it is worth the adjustment.

How MonPG helps after you land on PostgreSQL

Every behavior change above has a workload signature. Case-sensitivity fixes turn index scans into sequential scans when the expression index is missing. A retried upsert loop changes a query family's call count. An abort storm shows up as rolled-back transactions and error spikes. The way to catch these in the first weeks is query-level history: what each query family looked like before the deploy and after.

MonPG is built for exactly that on PostgreSQL. It keeps pg_stat_statements history, surfaces lock waits and idle-in-transaction sessions, and ties error and rollback patterns to the queries that caused them, so post-migration weirdness becomes a diff instead of a mystery. MonPG monitors PostgreSQL only, so it starts paying off the day your traffic does cut over. The PostgreSQL monitoring guide shows the baseline I would put in place before the first production deploy on the new engine.