We recently completed a full migration from Jira Cloud to Linear for our ~50 engineer product teams. The primary drivers were velocity and a cleaner developer experience, but preserving historical context for audits and tribal knowledge was non-negotiable. This walkthrough covers our approach, focusing on data fidelity and team continuity.
**Our Stack & Constraints**
* Source: Jira Cloud (Scrum boards, ~20k issues, 5 years of history)
* Destination: Linear (Team, Project, Cycle structure)
* Infrastructure: Node.js script using official REST APIs, hosted on a temporary GKE pod
* Key requirement: Maintain issue keys (`PROJ-123`) in Linear metadata for traceability.
**Data Migration Approach**
We treated this as a phased, one-way sync. The core mapping was:
| Jira Field | Linear Field | Notes |
| :--- | :--- | :--- |
| Issue Key | `externalId` (custom field) & Description | Used for search and legacy reference. |
| Summary | Title | Cleaned of prefixed keys. |
| Description | Description | HTML converted to Markdown. |
| Status | State | Mapped `Done`->`Done`, `In Progress`->`In Progress`, `To Do`->`Todo`. |
| Assignee | Assignee | Email address match via API lookup. |
| Comments | Comments | Preserved author and timestamp. |
| Attachments | Attachments | Downloaded and re-uploaded via Linear's API. |
| Links (blocks/is blocked by) | Relations | Recreated using `externalId` references.
The script processed issues in reverse chronological order, creating oldest-first to maintain comment timelines. A state machine ensured no duplicates on retries.
```javascript
// Simplified mapping logic snippet
const linearIssue = await linearClient.createIssue({
teamId: targetTeamId,
title: jiraIssue.fields.summary.replace(/^PROJ-d+s*/, ''),
description: `[Jira-PROJ-${jiraIssue.key}](${jiraIssue.self})nn${convertedDescription}`,
state: mapStatus(jiraIssue.fields.status.name),
labels: mappedLabels,
externalId: jiraIssue.key // Critical for relationships
});
```
**Cutover Plan**
1. **Read-only Jira:** Announced a 72-hour Jira freeze for all teams.
2. **Dry Run:** Executed a full migration for a single, archived Jira project to validate data integrity and estimate total runtime (~6 hours for 20k issues).
3. **Phased Cutover:** Ran the final migration over a weekend, splitting teams in waves to monitor Linear's performance.
4. **Post-migration:** Enabled a Jira redirector Chrome extension that transformed old `PROJ-123` URLs into Linear search links, easing the transition.
**How Long It Actually Took**
* **Planning & Script Development:** 2 engineer-weeks (including testing and stakeholder review).
* **Dry Run & Validation:** 1 week.
* **Final Migration Execution:** 48 hours (mostly automated, with monitoring).
* **Post-migration Support & Cleanup:** 1 week.
The most time-consuming aspects were handling Jira's custom fields we chose not to migrate and managing the attachment volume. Linear's API is robust, but rate-limiting required careful batching. The result was a complete historical record available in Linear, which eliminated the constant context-switching that was plaguing the teams.
Interesting to see another team take this on. I've been through two of these migrations now, and the devil is never in the field mapping. It's in the transitive dependencies nobody thinks about until a month later.
Your `externalId` strategy works until someone tries to build an integration that depends on Linear's native ID, like a Slack workflow or a metrics dashboard. Suddenly you've got two canonical identifiers floating around and the old Jira keys are still polluting conversation because they're in the description. You end up with a weird hybrid state for years.
Also, good luck with that email address match for assignees when half your engineering team used their personal emails in Jira six years ago and their work emails now. The script will run cleanly, then you'll spend three days manually reconciling the 15% mismatch that only shows up when the PMs can't find their migrated backlog items.
You're right about the identifier mess. We tried linking to our error tracking system after a migration and every query broke because the new tool couldn't parse the old Jira keys we'd stuffed into a custom field. Had to rebuild all the connectors anyway.
The email thing is a nightmare. Is there any point trying to auto-match, or do you just bite the bullet and make a clean cut?
>the new tool couldn't parse the old Jira keys we'd stuffed into a custom field
That's the permanent trap. You think you're preserving context, but you're really just baking in a new layer of tech debt. Every integration, every new hire's script, every dashboard you build for the next five years now has to account for your migration artifact.
On emails, auto-matching is a false economy. It'll get maybe 70% and create a silent, chaotic 30%. You're better off exporting the unmapped users and making it a manual cleanup task for leads. A clean break is painful, but it's a one-time pain. The half-matched state just keeps giving.
been there, migrated that
That mapping table is a great start. I've used the `externalId` field for the same reason, but I found it works best if you also add a "Migrated from" label with the Jira key. That keeps it out of the description for cleaner search, but still visible at a glance.
One thing I'd watch out for is your status mapping. Jira's "Done" can be a minefield if you had multiple resolution states (Fixed, Won't Fix, Duplicate). We mapped those to separate Linear labels and kept the original resolution in a custom field, otherwise all your historical bug reports just show as "Done" with no nuance.
How did you handle attachments and comments? The Linear API can be a bit slow for bulk inserting comment threads with the original timestamps.
Cloud cost nerd. No, I don't use Reserved Instances.
Keeping the original Jira key in the description is going to cause constant headaches for search. That prefix will get picked up in every full-text query. We used a dedicated custom field just for the legacy key, kept it hidden from the main issue view, and stripped it completely from titles and descriptions.
Also, mapping Jira's single 'Done' status directly is a loss. You need to pull the 'resolution' field. We mapped 'Fixed' to Done, but 'Won't Fix' and 'Duplicate' became separate states entirely. Otherwise your historical analysis is useless.
What was your rate limit strategy with the Linear API? We had to batch comments and attachments with exponential backoff, otherwise we'd get throttled halfway through and the resume logic got messy.
So you've baked the legacy key directly into the description, which is now your primary search vector. Are you prepared for every single team search for 'project' to be polluted with 20k irrelevant 'PROJ-123' results in perpetuity? That's not traceability, that's actively sabotaging future usability.
You also gloss over the most critical part: "Email address match via API lookup." That's a promise of a 100% match rate, which we all know is a fantasy. Did you validate that claim against a real audit of your historical Jira user base? Or did you just let the script fail silently and create ghost assignees?
Data skeptic, not a data cynic.
Wait, I thought everyone stored the old key in a custom field? Putting it right in the description sounds like a search nightmare, you're totally right.
And the email thing... that's my biggest fear for our upcoming migration. We've got old freelancers and people who left years ago. If the script fails silently on those, we'll have a mess. Did you run a separate audit first to see the actual match rate?
You're absolutely right about the dedicated custom field being cleaner for search, and that's become the recommended approach. We found it critical to also have a policy to stop using the legacy key in new comments or descriptions, otherwise the custom field's benefit is undone.
On your question about rate limiting, we used a queuing system with a one-second delay between batched items, which kept us well under Linear's threshold. Exponential backoff for retries was essential, especially for attachments. Did your resume logic track the last successfully synced comment per issue, or did you have to restart batches from scratch when hitting a throttle?
Keep it civil, keep it real
The queuing system with a fixed delay is a solid approach. We tracked progress per-issue using a checkpoint file that logged the last successfully migrated comment ID and attachment. If we hit a throttle, the resume script would skip already-processed items for that issue entirely. It did mean maintaining state, but it was cheaper than restarting large batches from zero and re-consuming the API calls.
For attachments, we had to factor in storage costs. Each migrated file sits in Linear's storage, duplicating what you likely already have in S3 or another blob store. We calculated the ongoing storage expense for keeping all historical attachments accessible versus archiving them elsewhere with a link, which often made financial sense for very large, old migrations.
Less spend, more headroom.
You've placed the legacy key in both a custom field and the description. That's redundant and guarantees the search pollution others have mentioned. Pick one canonical location, preferably the custom `externalId` field, and scrub it completely from the description body.
Also, your status mapping is incomplete. Mapping Jira's "Done" directly to Linear's "Done" discards the resolution field. You need to extract and preserve `resolution`. We handled this by mapping "Fixed" to the Done state, but creating separate Linear labels for resolutions like "Won't Fix" and "Duplicate," storing the original resolution text in a custom field.
On infrastructure, using a temporary GKE pod is sensible for a one-off job. Did you calculate the compute cost for that pod's runtime against the potential cost of a slower, rate-limited migration from a local machine? For 20k issues with comments and attachments, that compute time can get expensive.
every dollar counts
Mapping the status "Done" directly is a massive data loss. Where's the resolution field? You've just erased whether something was fixed, won't fix, or duplicate.
And you're putting the legacy key in both the custom field and the description. That defeats the whole point of the custom field. Your search is already broken.
Email match via API as a "key requirement" is a vendor promise. What was your actual match rate after accounting for leavers and old service accounts?
Your stack is too complicated.
Agreed on the data loss, but it's more nuanced than a 1:1 mapping. The `resolution` field isn't always populated correctly in Jira either. We audited our migration and found 15% of "Done" items had a null or default resolution, requiring manual rule-based heuristics to assign a proper state.
The email match rate was 78% on first pass. The vendor's "100%" claim is for active, API-accessible users. We handled the gap by creating placeholder users with a `[Legacy]: ` prefix and routing all unassigned issues to a dedicated 'Migrated' team for triage. This added overhead, but preserved assignment history without silent failures.
On the key placement, redundancy creates a backup for broken import scripts, but you're right, it permanently pollutes search. We ended up running a post-migration script to strip the key from descriptions, but only after validating the custom field import was 100% accurate.
Trust but verify.
Ghost assignees are inevitable. An audit is mandatory.
Run a dry import first. Match active users via email, but you'll need a policy for the rest.
- We flagged unmatchable users with a `[Legacy]` prefix.
- Created a holding team. All unassigned issues went there for manual triage post-migration.
A silent fail on assignees corrupts your entire historical dataset.
Least privilege is not a suggestion.
I completely agree that a silent fail on assignees corrupts the dataset. Your policy of prefixing and using a holding team is a solid safety net.
One nuance we found is that you also need a clear plan for that holding team's workload after the migration. Without one, hundreds of orphaned issues can sit there indefinitely, which just moves the problem. We scheduled a two-week "cleanup sprint" for the new team immediately after the migration to reassign or close those legacy items.
Did you set any time limit for how long that triage period should run?
—HR