So the Cloud to Cloud bill is the GAPS — automation rules, global permissions, app data — plus tenant-scoped ids behind identical names.
Never parse an accountId. Up to 128 characters, formats vary, and some have no colon at all.
Two migrations, the same issues, and almost none of the same work.
Move a Jira site from Data Center to Cloud and most of the budget goes on working out who everybody is. Move one Cloud site to another and identity is handed to you — along with rather more than people expect — while a specific, enumerable set of things gets left behind for you to carry yourself.
People who have done one and are about to do the other tend to bring the wrong plan. This is the shape of the difference.
Data Center to Cloud: identity is the bill
Server and Data Center identify a person by username and userKey. Cloud does not work that way. Both fields still appear in the Jira Cloud v3 user schema, but they come back empty — which is worse than absent, because reaching for them fails silently rather than throwing.
What Cloud has instead is accountId. Get one thing right about it: it is opaque. Up to 128 characters, alphanumeric plus - and :. Some have a colon and a UUID, like 384093:32b4d9w0-f6a5-…, which is what accounts created by on-prem migrations tend to look like. Plenty have no colon at all — 5b10ac8d82e05b22cc7d4ef5 is an ordinary modern one. JSM customer accounts have their own shape again.
So never parse it, never truncate it, never lowercase it, and never size a database column to whatever length you happened to see first. Every transformation you apply to an accountId is a bug waiting for a tenant that doesn't match your sample.
Every reference to a person has to be resolved to one of these: assignees, reporters, watchers, comment authors, user-type custom fields, permission scheme actors, notification recipients, filter and dashboard owners, automation rule actors.
The matching key is email, and Atlassian is explicit: "If an email address already exists in your cloud site, it won't be migrated. Instead, we will link all data connected to that user to their account in cloud." That behaviour is what you want. The problem is the users it cannot apply to.
Email is nullable. A privacy-restricted user returns emailAddress: null, and that is normal rather than broken. Comparing emails without handling null silently mismatches exactly those people. Display names are not unique either — any tenant of size has two of them — so the users you cannot match by email are frequently the ones you cannot disambiguate by name.
Anonymised DC users are their own category, and the aliases are easy to get wrong because there are two of them. Jira DC anonymisation rewrites the username to something like jirauser80900 and the full name separately to something like user-ca31a. Grep your display names for jirauser and you will find nothing. Neither alias links back to the person, so there is no clever lookup that recovers them.
The documented remedy is upstream of your script: deleted users, and users in inactive directories, come across as Former user. If you need those references, reactivate the user or the directory before migrating. Afterwards is too late.
For everyone else, the answer is a CSV override — a hand-maintained mapping for the users automation cannot resolve, produced as an artefact of a dry run. Build the resolver to report multiMatch: true rather than guessing, and treat each one as a decision a human makes once and records.
A rehearsal that can actually fail
If you have read older migration advice you may have met the header x-atlassian-force-account-id: true, recommended as a way to make a site behave as though every user were privacy-restricted.
Do not build a rehearsal on it. It never did that — Atlassian's own guide scopes it to username and userKey only, and it never suppressed emailAddress or displayName, which are governed by profile visibility instead. It was an opt-in for a deprecation period that ended on 29 April 2019, and the same page says compliant behaviour is enforced afterwards "regardless of the x-atlassian-force-account-id: true header."
I had this wrong myself until I went back to the source. It is the worst possible shape of error for a migration: a rehearsal that cannot fail, followed by a green result you trust.
The honest version costs more and is worth it. Seed the dry run from real data — accounts that genuinely have emailAddress: null, and at least one pair of duplicate display names — then assert that your resolver reports a multi-match rather than picking one. That test can fail, which is the only property that matters.
Cloud to Cloud: identity is already solved
Now the reversal, and the part I want to be precise about because the obvious framing is wrong.
accountId is an attribute of the Atlassian account, not of a site or an organization. The REST reference defines it as the ID that "uniquely identifies the user across all Atlassian products". One email is one Atlassian account, and that account carries the same id wherever it is used. Atlassian's own cross-org copy documentation makes the consequence explicit: when you copy users, "we don't create new user accounts in the destination site."
So the accountId you read from the source is the accountId you write to the destination — and that holds across an organization boundary too, which is the opposite of what I assumed before checking. There is no identity remap to build.
The failure mode is the inverse of the one people plan for: not one human with two ids across tenants, but one human holding two Atlassian accounts — after an acquisition, a domain change, a personal address used somewhere it shouldn't have been. That is a real problem and Atlassian documents a merge-accounts procedure for it. JSM portal-only customers are worth checking separately as well; they are not ordinary global accounts.
One caveat on the org-level story. Sites sharing a single user base is a property of centralized user management. Under the older original user management, each site manages its own user base and user details are per-product. The accountId conclusion still holds — for the account-level reason above, not because of the org — but do not assume a single shared directory if you have not checked which mode you are in. And when orgs are consolidated, same-named groups from the source get renamed {groupName}-{SourceOrgName}, which will quietly break a group remap keyed on name.
Cloud to Cloud: what you actually pay for
Here is where I was most wrong, and it changes the plan.
There is a first-party Cloud-to-Cloud tool. JCMA is Server and Data Center only — that part is true — but Atlassian's Copy product data (admin.atlassian.com → Settings → Data transfer) has existed since early 2024 and covers Jira, Confluence and JSM. It moves projects, spaces, users and a great deal of configuration: workflows, screens, permission schemes, custom fields, and on the JSM side queues, request types and groups, customer organizations, SLA calendars, approvals and knowledge bases. It gives you a copy plan with statuses to watch — queued, running, complete, incomplete, failed. And it works to a site in the same organization or a different one.
So "the REST API is the only route in" is simply false, and if you plan a Cloud-to-Cloud migration as a from-scratch API build you will spend weeks reimplementing something Atlassian ships.
What you pay for instead is the gap list, and the gap list is the useful artefact. Automation rules, global permissions, workflows and permission schemes not linked to a copied project, and app data are among the things not carried. Read the current "what product data is copied" page as your first task, not your last, because that page is your scope document. Everything on it is free. Everything absent from it is your build.
The second thing you pay for is tenant-scoped identity of configuration. A priority called "P1" on tenant A and one on tenant B are different entities with different ids. Same for issue types, statuses, resolutions, link types and request types. Where the copy tool has created the destination objects for you, you still need the id mapping to do anything afterwards by API — and anything you carry across yourself, filters and boards and automation among it, references ids rather than names.
Three that catch people:
Hierarchy above epic is plan-gated. Epic is the default level-1 work type on every Jira plan, Free included — the common claim that Free has no epics is wrong. What is Premium and Enterprise only is custom levels above epic. Migrate a source with initiatives sitting above epics into a Standard destination and those parents have nowhere to land.
Link types carry two labels. A link has an inward and an outward description — "blocks" and "is blocked by" — and either can be renamed independently on either tenant. Compare both or you will match on half a link.
Group lookups want ids, not names. Jira deprecated the groupname parameter in February 2023 in favour of the immutable groupId; Confluence deprecated its group-name endpoints in March 2024 for the same reason, and its current shape is GET /wiki/rest/api/group/by-id, with GET /wiki/rest/api/group/picker?query=X to find the id. Note the /wiki prefix — Confluence Cloud needs it, and leaving it off is a 404 that looks like a missing group. Names are renameable, which is exactly why they are the wrong key for a remap table.
What is the same in both directions
Rate limits. As of 2 March 2026 the Beta- prefix on rate-limit response headers is gone and overages return 429, so anything parsing those header names needs auditing. Bulk endpoints cost one point rather than one per item. Identity reads cost two points against one for a core read, which is why user-heavy phases burn budget faster than their volume suggests.
The per-issue write limit is the one to size against, and it has two windows, not one: 20 write operations per 2 seconds, and 100 per 30 seconds. The short window suggests ten writes a second; the long window caps you at about 3.3. Size your limiter to the long one or a bulk comment import will throttle after the first burst with RateLimit-Reason: jira-per-issue-on-write. Serialise per issue and parallelise across issues.
For retries, run one policy rather than two. Exponential backoff with jitter, and where a Retry-After header is present treat it as a floor — Atlassian notes that some transient 5xx responses carry one too, and handling those "with similar retry logic" is their advice rather than inventing a separate path for each status.
The remap table wants to be data. Whichever half is free for you today, build the mapping as a file you can diff, review and re-run rather than as code. The half you get free today is the half you will pay for next time.
Which half are you paying for
Data Center to Cloud: staff for identity. Build the resolver, seed a dry run with real null-email and duplicate-display-name accounts, produce the override CSV as an artefact, and reactivate anything you need before you start rather than after.
Cloud to Cloud: start from Atlassian's copied-data list, treat it as your scope document, and spend the time on the gaps and on id mapping rather than rebuilding what the copy tool already does.
The mistake worth avoiding is the one that comes from experience — arriving at your second migration with the plan from your first. The work is not smaller the second time. It moved.