All posts

We moved a live store from MySQL to Postgres. Here's what actually broke.

Published July 28, 2026 · by Majd Ghithan, written with the help of AI

We moved a live store from MySQL to Postgres. Here's what actually broke.

We run a multi-vendor e-commerce platform, one of our own projects: shops, catalogs, carts, orders, subscriptions, referrals, the whole thing. Ninety-plus migrations, real traffic, real money. And we moved it from MySQL to Postgres.

Here's the honest part nobody puts in the tutorial: the data moves fine. It's the assumptions baked into your app over the years that break. MySQL is forgiving. Postgres is strict. Every place you leaned on MySQL being lenient is a bug waiting for you on the other side.

This is the list I wish I'd had before I started.

First, the strategy: don't hand-move the data

Two roads work. Pick based on how much you trust your migrations.

  • pgloader — a single tool built for exactly this. It reads your MySQL database and writes it into Postgres, converting types as it goes (TINYINT(1)boolean, AUTO_INCREMENT → sequence, and so on). One command, most of the job done.
  • Laravel's query builder — because it's database-agnostic, you can read from the old connection and write to the new one in PHP, transforming rows as needed. More control, more code. Good when your schema has weird corners pgloader trips on.

But the real first test isn't the data at all. It's this:

And you don't delete MySQL the day you switch. You keep it running, in the corner, for days or weeks — until you're sure. Cutovers get reverted. Give yourself the road back.

Gotcha 1: search stopped matching (case sensitivity)

This is the one that bites an e-commerce app hardest. MySQL's default collation is case-insensitive. Postgres is case-sensitive.

So this query, which happily found "iPhone" on MySQL:

Worked on MySQL, silently breaks on Postgres
// MySQL: 'iphone' matches 'iPhone'
Product::where('name', 'LIKE', "%{$q}%")
    ->get();

// on Postgres, LIKE is
// case-SENSITIVE. no match.
Postgres-safe
// ILIKE = case-insensitive LIKE
Product::where('name', 'ILIKE', "%{$q}%")
    ->get();

// or normalize both sides:
// whereRaw('LOWER(name) LIKE ?', ...)

Every product search, every "find by email", every filter that a customer types by hand — audit all of it. ILIKE is the quick fix. For heavy search, the citext extension or a LOWER() index is the real one.

Gotcha 2: 0 and 1 are not true and false

MySQL has no real boolean — TINYINT(1) holds 0 or 1. Postgres has an actual boolean type holding TRUE/FALSE. Laravel casts smooth over most of this, but the moment you compare in raw SQL or feed a literal 1, it bites.

MySQL brain
// works on MySQL, errors on PG
DB::table('shops')
  ->whereRaw('is_active = 1')
  ->get();

// PG: operator does not exist:
// boolean = integer
Postgres brain
Shop::where('is_active', true)->get();

// let Eloquent + the cast
// handle the dialect for you
protected $casts = [
  'is_active' => 'boolean',
];

Gotcha 3: the auto-increment sequence that forgot where it was

This is the nastiest, because it passes every test and then explodes in production. When you bulk-import existing rows into Postgres, the table's id sequence does not advance to match. Your data has ids up to 40,000; the sequence still thinks the next id is 1.

The first time a customer creates an order, Postgres hands out id 1 — which already exists — and you get a duplicate-key crash on a brand-new record. On checkout. In production.

Gotcha 4: your raw SQL speaks MySQL

Anywhere you dropped down to DB::raw, whereRaw, selectRaw, or a raw MySQL function, Postgres may not understand it. We had these scattered across services and controllers — the exact places that never show up until they run:

  • FIND_IN_SET(), GROUP_CONCAT() → Postgres uses arrays / string_agg().
  • DATE_FORMAT() → Postgres uses to_char().
  • RAND() → Postgres is RANDOM().
  • Backtick quoting `column` → Postgres uses double quotes "column".

Grep your whole app for DB::raw, whereRaw, selectRaw before you cut over. Each hit is a manual review.

Gotcha 5: Postgres won't let sloppy queries slide

Two more places MySQL let you be lazy and Postgres won't:

  • GROUP BY is strict. Every non-aggregated column in your SELECT must appear in GROUP BY. MySQL would guess; Postgres refuses. Reporting and dashboard queries are where this hides.
  • No silent truncation. Insert a string longer than the column and MySQL quietly trims it; Postgres throws. Same for an invalid value in a uuid column — MySQL stores any string, Postgres validates it's a real UUID. This is Postgres protecting your data, but old code that relied on the trim will now error.

Was it worth it?

Yes. On the other side you get JSONB with real indexing (a gift for a product catalog full of attributes), stricter data integrity that catches bugs MySQL swallowed, and query planning that holds up as the data grows. But go in clear-eyed: the migration isn't a data transfer, it's an audit of every lazy assumption your app has made since day one. Postgres is the strict senior reviewer your codebase never had. It's going to find things. That's the point.

If your app has been on MySQL for years, do the empty-database migration test this week. You'll learn more about your own schema in one afternoon than in the last two years of shipping on it.

🤖 Heads up: this post was drafted with AI. Spot something wrong or off?Edit it on GitHub & open a PR
Majd Ghithan

Majd Ghithan

Full-Stack Engineer & Tech Lead

More posts

Eight months, no plan, nothing shipped. We shipped it in two.
War Stories

Eight months, no plan, nothing shipped. We shipped it in two.

A team had been building a health platform for eight, maybe nine months. The client had run out of money. And there was nothing to ship — not because the code didn't exist, but…

July 28, 2026Read more
GEO vs SEO: getting found when nobody googles anymore
Tech Lead Notes

GEO vs SEO: getting found when nobody googles anymore

Think about the last time you needed to know something technical. Did you open Google, scan ten blue links, and click through? Or did you just ask ChatGPT or Claude and read the…

July 28, 2026Read more
Laravel finally resizes images for you
Laravel in Practice

Laravel finally resizes images for you

Every Laravel app I've built that takes an avatar ended the same way: composer require intervention/image, wire up a facade, write a little service class, and hope the next dev…

July 26, 2026Read more
What actually breaks at 100,000 users (that never breaks in a demo)
Built at Scale

What actually breaks at 100,000 users (that never breaks in a demo)

The first time I shipped a dashboard to a hundred thousand active users, nothing I had tested was what broke. Everything that failed had passed every check I ran. It worked on m…

July 25, 2026Read more
Where your queue jobs go to die under load
Built at Scale

Where your queue jobs go to die under load

The support ticket said "I never got my invoice email." I checked the logs. The job ran. It succeeded. failedjobs was empty. Everything said the email went out. It did not.

July 18, 2026Read more
The indexes you're missing (and the one that's hurting you)
Built at Scale

The indexes you're missing (and the one that's hurting you)

A client sent me a query that took 4.2 seconds. One WHERE, one ORDER BY, a table with three million rows. I added a single index and it dropped to 11 milliseconds. They asked if…

July 11, 2026Read more
Caching in Laravel without the stampede
Built at Scale

Caching in Laravel without the stampede

We cached the homepage's "trending products" query for five minutes. It was our slowest query — about 900ms, a big aggregation across orders. Caching it took the homepage from s…

July 4, 2026Read more
Taking one endpoint from 800ms to 80ms
Built at Scale

Taking one endpoint from 800ms to 80ms

The order-details endpoint took 800 milliseconds. Not broken, just slow enough that the app felt heavy everywhere it was used. The team's instinct was "the server needs more mem…

June 27, 2026Read more
The N+1 you can't see (it's hiding in your accessors)
Built at Scale

The N+1 you can't see (it's hiding in your accessors)

I've fixed hundreds of N+1 queries. The easy ones are right there in the controller — a foreach with $order-customer inside it, obvious the moment you read the code. Those aren'…

June 20, 2026Read more
The Friday deploy that charged customers twice
War Stories

The Friday deploy that charged customers twice

It was 4:40 on a Friday. The change was tiny — a one-line tweak to how we called the payment provider, plus a bump to the queue worker's timeout. Small, tested, reviewed. I depl…

June 13, 2026Read more
Code review people don't dread
Tech Lead Notes

Code review people don't dread

I once left forty-one comments on a junior's pull request. I was proud of it. Thorough, I told myself. The next day he barely made eye contact, and his next PR sat open for a we…

June 6, 2026Read more
Hiring a mid-level Laravel dev: the signals that actually matter
Tech Lead Notes

Hiring a mid-level Laravel dev: the signals that actually matter

The best hire I ever made failed my first question. I asked him to explain service containers and he stumbled, went quiet, then said "honestly I use them every day but I've neve…

May 30, 2026Read more
Saying no to a feature without being the 'no' person
Tech Lead Notes

Saying no to a feature without being the 'no' person

For about a year I was the engineer everyone learned to route around. Not because I was wrong, I was usually right, but because my answer to new ideas was a flat "no, that'll br…

May 23, 2026Read more
My first 90 days as a tech lead (and the habit I had to break)
Tech Lead Notes

My first 90 days as a tech lead (and the habit I had to break)

Three weeks into leading my first team, I stayed late to "help" by rewriting a junior's feature myself. It was faster my way. I pushed it, felt productive, went home. The next m…

May 16, 2026Read more
Breaking down the task that scares you
Tech Lead Notes

Breaking down the task that scares you

There's a specific kind of ticket that makes my stomach drop. Not the hard ones, hard is fine. It's the ambiguous ones. "Migrate billing to the new provider." "Add multi-tenancy…

May 9, 2026Read more
1:1s that aren't just status updates
Tech Lead Notes

1:1s that aren't just status updates

For my first few months as a lead, my 1:1s were thirty minutes of me asking "so, what are you working on?" and nodding at answers I already knew from standup. We both left sligh…

May 2, 2026Read more
Get the business logic out of your controllers
Laravel in Practice

Get the business logic out of your controllers

I once opened a OrderController@store method that was 240 lines long. Validation, a discount calculation, three model writes, a Stripe charge, two emails, a Slack notification,…

April 25, 2026Read more
Form Requests are the most underrated thing in Laravel
Laravel in Practice

Form Requests are the most underrated thing in Laravel

Most Laravel devs meet Form Requests once, in a tutorial, use them to hold a rules() array, and never look deeper. That's a shame, because a Form Request is the cleanest place i…

April 18, 2026Read more
Testing Laravel without mocking everything
Laravel in Practice

Testing Laravel without mocking everything

I inherited a codebase once with 900 unit tests and no confidence. Every test mocked the repository, mocked the model, mocked the mailer, mocked the thing three layers down. The…

April 11, 2026Read more
Three Eloquent features that clean up your models
Laravel in Practice

Three Eloquent features that clean up your models

The messiest Laravel model I ever wrote wasn't messy because of Eloquent. It was messy because I ignored the parts of Eloquent that exist specifically to keep it clean. The same…

April 4, 2026Read more
The migration that locked the table for eight minutes
War Stories

The migration that locked the table for eight minutes

The deploy looked boring. One migration, adding a lastseenat column to the users table with a default of now(). I'd written a hundred like it. I ran it at 2pm on a Tuesday becau…

March 28, 2026Read more
Chasing a memory leak in a Laravel queue worker
War Stories

Chasing a memory leak in a Laravel queue worker

The alert came in at 4am: one of our queue:work processes had been OOM-killed. The supervisor restarted it, it processed jobs for about forty minutes, and got killed again. It w…

March 21, 2026Read more