← RESEARCHCase study9 MIN READ

2026 · 9 min read · Engineering case study

Ticket by Ticket

State, Recovery and Throughput in a Zendesk-to-Jira Migration

This is a retrospective on the Python tooling behind a Zendesk-to-Jira migration of more than 10,000 support tickets. I scoped, built and evolved major parts of it. The piece covers how each ticket was translated between the two systems, how the tooling came to keep track of its own progress and recover part-migrated tickets, and how it went from moving one ticket at a time to moving up to six.

By Muhammad Usman Mateen

10,000+ support tickets · dozens of organisations · ~99.8% migration success · ~5.4× throughput

01

A ticket is not a row

In the first version, moving one support ticket from Zendesk® to Jira® took about seventy-nine seconds.

The number makes more sense once you look at what a ticket involved. Before an issue could exist in Jira, the accounts the ticket depended on had to exist there too, so the tooling handled or created those first. Then it created the Jira issue. Then it migrated the ticket's comments, maintaining the relationships between them. Only when all of that had finished did the next ticket begin.

One ticket, first versionConceptual · simplified
throughout · logging · retries · validation
Each step depended on the one before, and the next ticket started only when this one was complete. A complete workflow took about 79 seconds.

So a ticket was a small workflow with an order to it, not a record to copy. Across more than ten thousand of them, that had two consequences. At the original sequential rate, simple arithmetic said that processing them all would take days, not hours. And anything that runs for days has to be able to stop and start again without losing track of what it has already done.

The tooling worked with both platforms through their REST APIs: Zendesk's Ticketing API on one side and Jira's REST API on the other. The migration covered dozens of organisations, and it was real operational data, which is why reliability mattered as much as it did.

The first version ran one ticket at a time and had no mature way to pick up where it left off. The later tooling tracked every ticket, recovered from being stopped part-way, and could run up to six ticket migrations at once. Most of this piece is about how it got from one to the other, and the trade-offs along the way.

A note on words: throughout, a ticket is the unit being migrated, and an issue is the record it became in Jira.

02

Two models of the same work

The source and target models did not line up one-to-one, so moving a ticket meant deciding what it should become in Jira. Custom fields and schema differences needed explicit mapping decisions, and they took a disproportionate share of the effort. Together, those decisions defined what the migrated data would look like to the people using it in Jira.

Two models of the same workIllustrative · not the real fields or mappings
Zendesk
rule
Jira
Source field
mapping rule
Target field
User reference
account resolution
Jira account
Custom value
translation rule
Target value
The two systems describe the same support work differently. The mappings between them were worked out with the teams who use them.

Identity took a similar share. A ticket refers to people, and a Jira issue can only refer to accounts that exist in Jira, so account handling came first in each ticket's workflow and had to be right before the rest could follow.

I didn't work the mappings out alone, and they weren't purely technical. I worked with support, product, QA and operations colleagues to understand the source and target workflows, the schema differences and the operational edge cases. The mappings had to represent how those teams actually used the two systems. Workflows are the clearest case, because a workflow describes how a team works rather than what a record contains.

03

When the script had to remember

The first version was sequential and comparatively simple: take one ticket through the workflow, finish it, then move to the next. It had basic logging and some retry handling and validation. With one ticket in flight at a time, any problem belonged to exactly one ticket.

First versionConceptual · not to scale
one ticket in flight~79 s per complete ticket workflow
One complete ticket workflow at a time, about 79 seconds each. Block lengths are illustrative; real tickets varied with their comments and account work.

It also had a limitation. Each ticket involved several separate operations against Jira: accounts, then the issue, then the comments. If a later step failed, the earlier ones had still happened, and an early version could leave a partially migrated issue behind, with some of its comments and not others.

As the migration became more operationally significant, the tooling matured together across reliability, observability, recovery and performance. Resume state and more robust recovery belonged to the later tooling, while the move to six concurrent ticket workflows was a major performance step.

The arithmetic from the opening made recovery important. A job measured in days has to expect interruption, and each interruption raises the same question: what has already been done? The next two sections deal with that question. Concurrency comes after them, although in practice these developed alongside each other.

04

Two records of what happened

I designed and implemented migration state and resumable execution as the tooling matured.

Progress was tracked per ticket, the same unit the rest of the workflow used, rather than per organisation or per step. It was persisted locally so that it survived the process stopping. Restarting the tooling no longer meant restarting the migration, and long runs did not have to be treated as disposable one-shot scripts.

On a restart, the tooling had two records to go on. Its own state described what it had recorded as done, and a process can only record what it knows it has done: if it stops between doing something and writing it down, its record falls behind. Jira showed what actually existed, but an issue being there did not, on its own, say whether the migration had finished with that ticket.

The tooling used both. It combined its per-ticket state with checks against Jira to decide what still needed processing.

Interrupt and restartConceptual · simplified
Local per-ticket state and a check against Jira feed the same decision. Completed tickets are not repeated; a partially migrated ticket is reprocessed with its full comment set.

The per-ticket logs sat alongside this. When someone needed to understand what had happened to a ticket, they read the logs; when the tooling started again, it read the state.

This was practical recovery logic: local state, a check against the destination, and a decision per ticket about whether it still had work to do.

05

Replaying the whole ticket

A restart could also find a ticket that had only half arrived, with the issue in Jira but not all of its comments. This was the same state an earlier version could leave behind.

Recovery worked at ticket level. If an issue existed but its comment migration was incomplete, the ticket was reprocessed with its complete comment set, rather than resumed from a particular comment.

Recovering a whole ticketIllustrative counts
An incomplete ticket was replayed with its full comment set. Comments already on the issue could be duplicated, and the duplicates were cleaned up afterwards.

Looking back at that design, the trade-off is clear. Working at ticket level meant the tooling only had to keep state per ticket, not per comment, and that kept the recovery behaviour relatively easy to reason about. The cost was that the replay covered the full comment set, so comments that had already reached the issue could be written a second time. Those duplicates had to be cleaned up afterwards.

Avoiding the duplicates at comment level would have meant keeping a record for every comment, and more recovery logic to maintain it. That is a cost too, just a different one.

The partially migrated issue that an early version could leave behind had become something the later tooling had a defined way to finish.

06

Six tickets in flight

Back to the seventy-nine seconds. In the first version, only one ticket workflow was ever active. Whatever a ticket spent its time on, nothing else moved while it did.

I implemented controlled rolling concurrency so that up to six ticket migrations could run simultaneously. When one ticket finished, the next began, so up to six workflows were in flight at once. Six was a ceiling rather than a target: the tooling never had more than six ticket workflows open at the same time.

Six in flightConceptual · not to scale
Up to six ticket workflows in flight
As one finishes, the next begins
effective time per ticket
~14.7 s
was ~79 s · elapsed time ÷ tickets completed
accountsissuecomments
ZendeskJira
completed
From sequential execution to controlled concurrency.~5.4× throughput
Each ticket still works through its own steps at the same pace. Throughput rises because up to six are in flight at once; as one finishes, the next begins.

The unit of concurrency was the ticket, and the order inside each ticket stayed the same: accounts first, then the issue, then the comments, because each step depended on the one before it. What changed was that several tickets could be at different stages at the same moment. One might be handling its accounts while another was creating its issue and others were migrating comments. In hindsight the ticket was a natural boundary for this, since each one already carried its own ordering.

With several ticket workflows interleaving, logs stayed tied to individual tickets. A single chronological stream was no longer enough to understand which operation belonged to which ticket's migration.

07

What 14.7 seconds does not mean

After the change, the effective processing time per ticket was about 14.7 seconds, against about 79 seconds for one complete workflow in the sequential version. That is roughly 5.4 times the throughput, measured on reasonably comparable workloads.

The 14.7 seconds needs reading carefully. It is the total elapsed processing time divided by the number of tickets completed. An individual ticket still had its accounts, its issue and its comments to get through, and could take longer from start to finish. What the concurrency changed was throughput, the number of tickets the tooling completed in a given time. It did not make any single ticket take 14.7 seconds.

Concurrency was a major part of the improvement, not necessarily all of it; the implementation as a whole was also improved over time.

Across the migration workload, the tooling achieved approximately 99.8% migration success. Failures were logged rather than stopping the run.

Results
support tickets
10,000+
of organisations
dozens
migration success
~99.8%
across the ticket workload
effective processing time per ticket
~79 s → ~14.7 s
sequential → up to six in flight; total elapsed time ÷ tickets completed
throughput
~5.4×
on reasonably comparable workloads
ticket workflows in flight
up to 6
rolling: as one finishes, the next begins

All figures approximate. ~14.7 s is an average across the run, not the time any single ticket took.

08

Knowing what I know now

A second version would start from a few different assumptions. The first two are things the later tooling already did; the change would be doing them from the start.

Migration state would be a first-class concern from the first version, not something the tooling grew into. A migration of this size is a long-running process from the beginning; it just doesn't look like one at small scale.

Logs would be structured around ticket identity from the start. Concurrency made that necessary, but it is useful long before there are six tickets in flight.

I would look at stronger protection against replaying comments that already exist, so that recovering a whole ticket keeps its simplicity without leaving duplicates to clean up afterwards. How far to take that is a judgement call, because per-comment tracking has costs of its own.

And I would think explicitly about shared dependencies, such as accounts that several tickets refer to, before running tickets side by side.

09

The unit that mattered

The decisions that mattered most in this migration were about what a ticket should become in Jira, which accounts it depended on, what the tooling should remember about it, how to recover it when it had only half arrived, and how many ticket workflows could be in flight at once. The API calls carried those decisions out.

The mappings came out of work with the teams who would use the result. As the migration became more operationally significant, the tooling also gained per-ticket state and resumable execution.

The unit that mattered was the ticket, not the API call. It was the unit of work, of state, of concurrency, of logging and of recovery.