VS Vikesh Singh

Home Blog Case Study

CASE STUDY

Migrating 500,000+ Patient Records With Zero Downtime

· 21 min read

A journey through moving half a million live healthcare records from MongoDB to MySQL without a single service interruption.

I still remember the exact afternoon someone first said the number out loud in a meeting: five hundred thousand patient records, moving from MongoDB to MySQL, with zero acceptable downtime. I'd done migrations before. I had not done one where a mistake could mean someone's appointment history quietly disappearing, or a medication record showing the wrong date, in a system that real clinics were using that same week to see real patients. The weight of that sat with me the whole project, in a way that a normal CRUD migration never does.

The platform I'm describing here is a healthcare system — I'll keep it at that level of description, because the specifics of who it was matter less than what the work demanded. What matters is this: people were booking appointments, pulling up charts, and checking in patients on that database at the exact moment we needed to move its data underneath them to a completely different storage engine, with a completely different way of representing data. MongoDB doesn't care if a field is sometimes a string and sometimes an array across different documents. MySQL absolutely does. That mismatch alone was going to be the source of half our pain, and I knew it going in, even if I didn't yet know which specific fields would bite us.

There's also a smaller, quieter detail about this project that I think about more than it probably deserves: the first time I actually sat with the raw export of a single patient's full record, scrolled through every field, every appointment, every note, and realized I was looking at someone's entire relationship with a healthcare system laid out as data. Not an abstraction. Somebody's actual medical history, the kind of thing that, if handled carelessly, becomes a very real problem for a very real person who has no idea any of this migration is even happening. I don't think that feeling ever fully went away over the following months, and honestly, I don't think it should have. It's part of what kept the whole team honest about not cutting corners, even when the project ran longer than anyone originally planned.

How it started

The initial ask, as these things often go, was deceptively simple: "move the data over." As if it were a single afternoon of copying rows from one place to another. I pushed back gently in the first planning conversation, not because I wanted to make the project bigger than it needed to be, but because I'd seen what happens when a migration like this gets treated as a data-transfer problem instead of a trust problem. The actual question wasn't "how do we move the bytes." It was "how do we move the bytes while never once giving a doctor or a patient a reason to doubt what's on their screen."

So before writing a single line of migration code, I spent the first week just reading. Reading the shape of the Mongo collections, document by document, looking for the weird ones — because in a schemaless database, the weird ones are always there, you just haven't met them yet. I found date-of-birth fields stored as strings in one format for older records and as native date objects for newer ones. I found a chunk of patient documents with a medications field that was an array for most patients and a single embedded object for a batch that had apparently been imported from a different legacy system years earlier, before the platform I was working on even existed. I found appointment timestamps that, on closer inspection, seemed to be stored without a timezone indicator at all — just a number of milliseconds since epoch, with the actual timezone convention living only in a comment in an old code file, and a slightly different convention baked into one of the import scripts from an earlier era. That last one would come back to bite us later, and I'll get to it.

None of this was really mentioned anywhere formally. It was oral history, scattered across a few engineers who'd been there the longest, plus whatever I could reconstruct by just querying the data directly and squinting at the results. That part of the work — the unglamorous archaeology before the real engineering even starts — probably took as long as the migration logic itself, and I don't think that's unusual. I've come to believe that the biggest risk in any serious data migration isn't the migration code. It's not knowing what you don't know about the source data until it's too late to plan around it.

Choosing an approach, and the ones I rejected

I considered, and rejected, the obvious shortcut: write one big export-and-import job, run it during a quiet maintenance window at 2 a.m., and have the whole thing done by morning. For a lot of systems that's completely fine. For this one it wasn't, for two reasons. First, "quiet" isn't really a concept that applies cleanly to a platform serving patients across time zones and shift patterns — there's always someone, somewhere, trying to check in or pull up a record. Second, and more importantly, a single big-bang job gives you one shot. If something goes wrong three hours into a six-hour run, you're now debugging a partially migrated half-million-row dataset at 5 a.m. with people waking up to use the system in a couple of hours. I didn't want to be the person explaining that at a Monday stand-up, and more than that, I didn't want the platform's actual users to ever feel the consequence of a bad bet I made on an approach.

What I settled on instead was something closer to a slow, deliberate, and very boring process, by design. I built the migration as a set of small Laravel-based API endpoints that each handled a bounded slice of the source collection — a batch of a few thousand documents at a time, not the whole half-million at once. Each batch pulled its slice of records from MongoDB, transformed them into the shape the new MySQL schema expected, and wrote them in. The crucial design decision, the one I'd point to as the single highest-leverage choice in the whole project, was that every batch was completely independent and completely replayable. If batch two hundred and six failed for any reason — a document with a shape I hadn't anticipated, a database timeout, anything — I could re-run exactly that batch without touching the two hundred and five batches before it or the ones after it. Nothing about the process depended on strict sequential success. That one property turned what could have been a nerve-wracking, all-or-nothing operation into something I could run calmly, watch, pause, inspect, and resume, over days rather than hours.

There was a real tension, too, in deciding how big a single batch should be. Too small, and the whole migration would take so long to grind through that the dual-system transition window — already a source of operational risk just by existing — would stretch out even further than it needed to. Too large, and a single failure wasted more work and took longer to diagnose, because a bigger batch meant more candidate records to suspect when something in it didn't validate cleanly. I landed on batch sizes in the low thousands after a bit of trial and error on a staging copy of the data, which turned out to be the sweet spot where a failed batch could be fully re-diagnosed in well under an hour, while the whole migration still completed its historical pass in a handful of days rather than weeks. That number wasn't derived from any formula; it came from literally timing a few different batch sizes against a staging environment and watching where the curve of "time to diagnose a failure" started to bend upward uncomfortably.

I paired that batching approach with CSV exports as an intermediate, verifiable artifact rather than piping data directly from one database into the other in memory. That might sound like an old-fashioned extra step, and in a sense it is, but it meant I had a durable, inspectable record of exactly what had been read from the source at a given point in time, which I could re-diff against later if something looked off downstream. It also meant that if MongoDB itself had a hiccup — and large shared databases always do, eventually — I wasn't re-querying it unnecessarily; I already had the batch's data sitting in a file I could replay from.

The validation obsession

Here's where I want to slow down, because this is the part that actually mattered and the part that's easiest to wave your hands at if you're just describing the project instead of living through it.

"We validated the data" is a sentence that means almost nothing on its own. What did validation actually consist of, concretely? For every batch, before I'd mark it complete and move on, I ran a count check: how many documents did I read from the source collection for this slice, and how many rows ended up written to the destination table. If those numbers didn't match exactly, the batch was not done, full stop, no matter what the logs said about individual records succeeding. A mismatch there almost always meant either a duplicate legacy identifier that had silently merged two patients into one row — which happened, more than once, because of exactly the inconsistent document shapes I'd found during that first week of archaeology — or a record that had thrown a transformation error I hadn't caught and logged properly.

But count-matching is the easy, surface-level check. It tells you nothing about whether the content of a record that "successfully" imported is actually correct. So the deeper validation step was a field-by-field diff: after a batch imported, I'd pull the corresponding documents back out of a frozen shadow copy of the Mongo collection and compare them, field by field, against what now sat in MySQL. Not "does a record exist with this ID" — does the date of birth match exactly, does the last-updated timestamp match, do the medication entries, however they'd been restructured, represent the same actual medications.

This is the step that caught the scariest bug in the whole project. The appointment timestamps I mentioned earlier — the ones stored with an ambiguous or missing timezone convention — were being interpreted during transformation under an assumption that turned out to be wrong for a subset of older records, the ones imported from that earlier legacy system years back. The practical effect, had it shipped unnoticed, would have been every one of those patients' appointment times shifting by five and a half hours. Think about what that actually means for a person: a patient shows up for a 2 p.m. appointment, the system now thinks it's at 7:30 p.m., because somewhere in a system they never see, a timestamp got interpreted five and a half hours off from where it should have been. For a retail analytics dashboard, a five-and-a-half-hour offset might generate a shrug and a follow-up ticket. For a healthcare platform, it's the kind of error that creates real-world consequences — a missed appointment, a confused patient, a provider double-booked without realizing it — and it's exactly the kind of error that's genuinely hard to notice just by eyeballing a handful of records, because most of the data wasn't affected, just that one legacy-imported subset. I only caught it because the diff step compared every single field for every single record against the frozen Mongo snapshot, rather than spot-checking a sample and calling it good. If I'd trusted a sampling approach — check a hundred random records, see that they look fine, move on — there's a real chance that particular subset never gets sampled, because it was a relatively small slice of the half-million total. That's the moment, more than any other in the project, where I actually felt a kind of quiet, cold relief. Not triumph. Relief. The feeling of a near-miss you only recognize as a near-miss after you've already caught it.

Keeping two systems honest at once

Even with batches validated individually, I didn't trust the idea of an instant cutover — flipping a switch one evening and having every read and write in the entire platform suddenly pointed at MySQL instead of Mongo. Too much could be different between "this batch validated correctly in isolation, days ago" and "the live system, right now, under real traffic, behaves identically on the new database." So I built a transition window, and it ran longer than I originally expected it would — longer than I was comfortable with, if I'm honest, though in hindsight that discomfort was doing exactly the job it should have.

During that window, both systems ran in parallel. Reads continued to be served from MongoDB — nothing about what a doctor or a front-desk worker actually saw on their screen changed yet. But every write went to both databases, MongoDB as the system of record it had always been, and MySQL as the new destination quietly catching up and staying current in real time. That let me treat the live, ongoing stream of new and updated records as a continuous second validation pass, layered on top of the batch migration of historical data. If something about live writes revealed a transformation bug that the static historical batches hadn't exposed — a new patient record with a field combination the import logic hadn't anticipated — I'd see it immediately in the dual-write logs, as a write succeeding on one side and failing or behaving unexpectedly on the other, rather than discovering it weeks later as a support ticket from someone staring at a wrong number on a chart.

Only once that dual-write period had run clean for what felt like a genuinely comfortable stretch, and only after another full diff pass across the entire live dataset — not just the original historical import, the whole thing, current as of that moment — did reads get switched over to MySQL. And even then, it wasn't flipped for the whole platform all at once. It moved in slices too, mirroring the same philosophy as the batch import itself: start small, watch closely, be ready to reverse course on any single slice without having to reverse course on everything.

What it actually felt like, day to day

I want to be honest about the texture of this project, because case studies tend to flatten the experience into a clean narrative of decisions, when the actual day-to-day was slower and more uncertain than that. There were stretches where I'd spend a full day writing validation logic and find nothing wrong, which is simultaneously the best possible outcome and a slightly deflating one — you can't always tell if "nothing wrong" means the system is actually fine or means your validation isn't looking in the right place yet. I second-guessed the diff logic itself more than once, wondering if I was comparing fields correctly, whether a false negative was hiding behind an off-by-one issue in how I was pulling the shadow copy. I'd run small reproduction cases by hand against a handful of known-tricky documents just to convince myself the comparison logic itself was sound before I trusted its verdict on half a million records I couldn't personally eyeball.

There was also a very real, very human pressure running underneath all of this, which was time. Nobody was shouting deadlines at me, to be clear — the team understood exactly why this needed to go slowly. But I put a fair amount of that pressure on myself. Every week the migration ran in its dual-write, not-yet-cut-over state was a week of added operational complexity, two systems to monitor instead of one, more surface area for something subtle to go wrong. I had to consciously remind myself, probably weekly, that the slow and careful version of this project was the fast version, in the sense that matters — because the alternative, a fast cutover followed by a slow and painful cleanup of corrupted patient data in a compliance-heavy environment, would have cost far more time and far more trust than the extra weeks of careful validation ever did.

The colleague who almost talked me into the shortcut

There was a point, maybe three weeks in, where a colleague I worked closely with on that project — I'll call him Tony, because he had that particular brand of confident, move-fast energy that always made me think of Tony Stark pacing around a lab convinced he already knows the answer — sat down next to me and asked, not unkindly, why I was spending so much time on diffing when the batch counts already matched. His argument wasn't unreasonable on its face: we were burning days on validation that, so far, had turned up nothing in most batches, and the project timeline was starting to get attention from people above us. Couldn't we sample, say, five percent of each batch for the deep diff instead of all of it, and ship faster?

I didn't have a slick answer in the moment. What I had was a gut feeling that I couldn't fully justify yet, and a request: give me until the end of the week, let me keep running the full diff, and if it keeps turning up nothing, I'll consider his sampling approach for the remaining batches. He agreed, mostly I think because he respected that I wasn't being stubborn for its own sake. Four days later, the timezone issue showed up in a batch that would absolutely not have been in a five percent random sample, just by the arithmetic of how small that affected slice was relative to the whole dataset. I walked over to his desk and didn't say much, just showed him the diff output. He read it for a few seconds, exhaled, and said something like "okay, yeah, we're doing it your way." We laughed about it afterward, but at the time it was a genuinely tense moment, two people who respected each other disagreeing about how much caution a project actually needed, with real patient data sitting in the balance. It's one of the clearer memories I have from the whole migration, honestly clearer than most of the purely technical decisions, because it's the moment the abstract argument for thoroughness turned into a concrete, undeniable example sitting right there on a screen.

The part nobody asks about: rollback

Something that doesn't come up enough when people talk about migrations is what happens if you have to go backward. I treated rollback planning as seriously as the forward migration itself, which in retrospect is probably the single most under-appreciated piece of the whole project. For every stage of the cutover — every slice of reads that got moved from Mongo to MySQL — I made sure there was a clean, tested, and rehearsed way to point that slice's reads back at MongoDB within minutes, not hours, if something looked wrong after the fact. That meant the dual-write period couldn't just end the moment reads switched over; it had to continue for a buffer period afterward too, so that MongoDB stayed a live, current, trustworthy fallback rather than an increasingly stale snapshot that would itself need its own catch-up migration if we ever actually needed to use it.

I actually triggered that rollback path once, for a single slice, early in the staged cutover — not because of a data correctness issue, but because of a performance regression. A particular kind of patient-history query that had been fast against MongoDB's document structure turned out to be unexpectedly slow against the new MySQL schema for a specific subset of patients with unusually long historical records, the kind of edge case that a normal-sized test dataset just doesn't surface. Front-desk staff at one clinic started noticing a lag pulling up certain patient histories, and rather than let that sit and debug it live under production pressure, I rolled that slice's reads back to Mongo within about fifteen minutes of the report coming in, bought myself a calm afternoon to add the missing index and restructure the query, then re-cut that slice over a day later once I'd confirmed the fix under realistic load. Nobody outside the immediate team ever knew that happened, because the rollback path worked exactly the way it was supposed to: quietly, quickly, and without any patient-facing disruption. That one incident, more than any success, convinced me rollback planning wasn't optional insurance — it was load-bearing infrastructure for the whole approach.

What the data actually looked like under the hood

It's worth dwelling for a moment on just how uneven real-world healthcare data turns out to be once you actually go looking, because I think people who haven't done this kind of migration imagine source data as mostly clean with a few exceptions, when the truth is closer to the opposite. Patient records that had been created through the platform's own normal intake flow over the past couple of years were, generally, fine — consistent shapes, consistent field types, predictable. But woven in among them were pockets of records that had arrived through older bulk-import processes, or through manual data-entry corrections made directly against the database by support staff years earlier when the product was younger and had fewer safeguards. Those pockets were where almost every genuinely interesting problem lived: the inconsistent medication field shapes, the ambiguous timestamp conventions, a handful of patient documents that, I eventually realized, had been manually merged by a support engineer at some point in the platform's history to fix a duplicate-account issue, in a way that left subtle inconsistencies between related fields that only showed up under a sufficiently thorough diff.

Finding those pockets meant I couldn't treat the half-million records as one uniform population to migrate with one uniform transformation. I ended up building what amounted to a small internal classification step before the real migration began — not a formal machine-learning classifier, just a set of checks that flagged which "era" or "source" a given patient document most likely originated from, based on its field shapes and certain telltale signatures, like whether a particular field existed at all or which date format it used. That classification determined which specific transformation rules got applied to a given record. It sounds like overkill for what's nominally "just a migration," but it's precisely because of that classification step that the inconsistent eras of data each got handled on their own terms instead of being forced through one generic transformation that would have quietly mishandled the oldest and most fragile records — which, not coincidentally, were often the ones with the longest, most clinically important histories attached to them.

How it actually ended

The cutover finished in stages, slice by slice, over a period that was considerably longer than the original "let's just move the data" framing from that first meeting had implied. By the end, every read and write for every patient record was flowing through MySQL, MongoDB sat as a frozen, retained historical snapshot rather than a live system, and — this is the part I'm genuinely proud of — not one appointment was missed, not one patient record was corrupted, and not one clinic noticed anything had happened at all, except that things felt, if anything, a little more responsive than before. That silence, that complete absence of drama, was the actual deliverable. Nobody throws a party for a migration that nobody noticed. But I did, quietly, for myself, because I knew exactly how much quiet, careful work had gone into making sure nobody had to notice.

Looking back, the thing I took away from this wasn't really a lesson about MongoDB or MySQL specifically — the particular databases involved are almost incidental to what actually mattered. What stuck with me is a kind of discipline about trust: you don't get to decide your migration is correct. The data gets to decide that, and your only job is to build enough verification into the process that the data has no way of lying to you quietly. Every shortcut I was tempted to take — sampling instead of full diffs, a single big cutover instead of staged slices, trusting a batch because its count matched instead of checking its content — was a place where the data could have lied to me and I wouldn't have known until much later, in a context where "later" meant a real person's medical record being wrong. I don't think I'll ever again treat "we validated the data" as a sentence I'm allowed to say without being able to describe, in detail, exactly what that validation checked and exactly what it would have caught if something had been wrong. That timezone bug taught me that lesson more thoroughly than any amount of cautious planning ever could have, and I still think about it sometimes when I'm moving far less consequential data between far less consequential systems — a little voice asking what the equivalent five-and-a-half-hour shift might be hiding in this dataset, waiting to be found.