Making Atlassian Cloud Migrations Predictable
Making Atlassian Cloud Migrations Predictable
Cloud migrations have two sides that both demand serious attention. There is the data transfer itself -- moving issues, pages, attachments, users, and configuration from Data Center to Cloud -- and there is everything that comes before and around it. Neither side is simple.
The Cloud Migration Assistant handles the mechanics of moving data across, and Atlassian continues to invest in this area with programs like FastShift. But "handles the mechanics" does not mean you press a button and walk away. The assistant transfers data. It does not guarantee that every issue, every page, every attachment, every permission grant arrives intact and complete on the other side. Getting to zero data loss -- genuinely zero -- is the migration expert's real job, and it takes hands-on validation at every step. Content that silently fails to transfer, users that lose access because a group mapping broke, attachments that time out on large files, pages where macros render as blank boxes -- these things happen during the transfer itself, not just before or after it.
The other side is the assessment: understanding what you actually have, how tangled the configuration is, what will not migrate automatically, and how much effort the whole thing takes. When an assessment is thorough, the migration becomes predictable. You know the number of workflows that need manual rebuilding. You know which marketplace apps have no Cloud equivalent. You know the real licensed user count, not the inflated one from the wrong database table. You know how many terabytes of attachments are sitting there and which individual files might cause timeouts.
When the assessment is shallow -- which is the default -- the timeline keeps shifting because new things keep turning up, and the validation work balloons because you did not know what to check for.
This guide is about doing the assessment properly. It covers three ways to gather data: direct SQL queries against the Jira or Confluence database, REST API calls you can run from your own machine, and ScriptRunner Groovy scripts you can run from the ScriptRunner console if you have it installed. Between these three approaches, you can extract everything you need without relying on someone else's tooling or waiting for a vendor engagement.
The MAGIC Framework
Before gathering any data, it helps to know what you are looking for. The MAGIC framework organises the assessment into five areas. Each one addresses a different category of risk, and each one can be rated Low, Medium, or High complexity based on the data you collect.
M -- Migration Strategy. Are you migrating everything at once, or in batches? How many batches, and in what order? Which projects depend on each other and need to move together? The answer depends on the data you gather from the other four areas.
A -- Apps, Integrations & Customisations. This is usually where the biggest surprises are. Which marketplace apps have Cloud equivalents? Which ones require complete manual rebuilds? What happens to your ScriptRunner Groovy scripts, your JMWE post-functions, your JSU conditions? What integrations (webhooks, application links, REST-based automations) break when URLs change?
G -- Growth & Scalability. How large is the instance right now? How many issues, pages, attachments, users? What Cloud tier does that map to? Are there specific projects or spaces that are disproportionately large and might need special handling during migration?
I -- Identity Management. How is authentication configured today? LDAP, SAML, Crowd, internal directory? What changes when you move to Atlassian Cloud and Atlassian Access? How will user provisioning and deprovisioning work going forward?
C -- Compliance & Security. Data residency requirements, anonymous access review, permission model changes between Data Center and Cloud, and any regulatory constraints that affect where data can live.
Each rating is only useful if it is backed by specific numbers. A rating of "HIGH" for Apps complexity means nothing unless you can say "23 marketplace apps installed, 4 are DC-only, 3 require manual rebuilds totalling approximately 430 post-functions across 120 workflows." That specificity is what makes the migration predictable.
The MAGIC5 Document
The output of the assessment is what we call the MAGIC5 document. It is a structured summary that consolidates everything into a single view that stakeholders can read and act on.
It starts with an instance overview: product version, deployment type, database engine, the accurate licensed user count, project and space counts, issue and page counts, total attachment storage, and the number of marketplace apps installed. This section uses the exact numbers from the data gathering phase -- not estimates, not round numbers from the admin console.
Then it covers each of the five MAGIC areas with a complexity rating and the specific data that supports it. For the Apps section, this means listing every installed marketplace app alongside its migration path: does it migrate automatically, does it need manual configuration in Cloud, does it need a complete rebuild, or is there no Cloud version at all?
It includes a risk register with concrete entries based on the query results. Something like "47 workflows contain JMWE post-functions that will not migrate automatically and require manual analysis and rebuild, estimated 70-140 hours" is useful. Something like "some apps may need attention" is not.
And it ends with a recommended migration strategy -- big bang or phased batching -- along with the batch composition if phased, the pre-migration cleanup list, and rough effort estimates.
The rest of this guide covers how to gather the data that feeds into this document.
Gathering Data: SQL Queries
These queries run directly against the Jira or Confluence database. They are written for PostgreSQL. If you are running MySQL, Oracle, or SQL Server, you will need to adjust a few syntax details -- STRING_AGG becomes GROUP_CONCAT in MySQL, LISTAGG in Oracle -- but the table and column names are the same across all supported databases.
You need read access to the database. If you do not have it, skip ahead to the REST API and ScriptRunner sections -- those approaches work through the application layer and do not require database access.
Project Inventory (Jira)
This tells you how many projects exist, how large each one is, when it was last active, and which schemes are attached to it. The scheme information is particularly important because projects that share a workflow scheme generally need to migrate together in the same batch.
SELECT
p.pkey AS project_key,
p.pname AS project_name,
p.projecttype AS project_type,
pc.cname AS category,
(SELECT COUNT(*) FROM jiraissue ji WHERE ji.project = p.id) AS issue_count,
(SELECT COUNT(*) FROM jiraissue ji
JOIN issuestatus s ON ji.issuestatus = s.id
WHERE ji.project = p.id AND s.statuscategory = 2) AS open_issues,
p.created AS created,
(SELECT MAX(updated) FROM jiraissue WHERE project = p.id) AS last_activity,
(SELECT COUNT(*) FROM component WHERE project = p.id) AS component_count,
(SELECT COUNT(*) FROM projectversion WHERE project = p.id) AS version_count,
(SELECT ws.name FROM workflowscheme ws
JOIN nodeassociation na ON na.sink_node_id = ws.id
WHERE na.source_node_id = p.id
AND na.source_node_entity = 'Project'
AND na.sink_node_entity = 'WorkflowScheme'
LIMIT 1) AS workflow_scheme,
(SELECT ps.name FROM permissionscheme ps
JOIN nodeassociation na ON na.sink_node_id = ps.id
WHERE na.source_node_id = p.id
AND na.source_node_entity = 'Project'
AND na.sink_node_entity = 'PermissionScheme'
LIMIT 1) AS permission_scheme,
(SELECT ns.name FROM notificationscheme ns
JOIN nodeassociation na ON na.sink_node_id = ns.id
WHERE na.source_node_id = p.id
AND na.source_node_entity = 'Project'
AND na.sink_node_entity = 'NotificationScheme'
LIMIT 1) AS notification_scheme
FROM project p
LEFT JOIN app_user au ON p.lead = au.user_key
LEFT JOIN cwd_user u ON au.lower_user_name = u.lower_user_name
LEFT JOIN projectcategory pc ON p.pcategory = pc.id
ORDER BY issue_count DESC;
Projects with zero issues or no activity in over a year are candidates for archival before migration. Every project you skip is time saved, both during the migration and in validation afterwards.
The scheme columns use a table called nodeassociation that trips people up. Jira does not store most scheme relationships as foreign keys on the project table. Instead, it uses this generic junction table where source_node_entity = 'Project' and sink_node_entity is the scheme type. If you do not know this table exists, you will waste time looking for a workflow_scheme_id column that is not there.
Licensed User Count (Jira)
This is the single most important number for cost planning. It directly determines which Cloud license tier you need.
Do not count rows in cwd_user. That table contains everyone who has ever existed in any connected directory: inactive accounts, service accounts, users deleted from LDAP, people who logged in once three years ago. The real licensed user count comes from the licenserolesgroup table, which maps user groups to license roles. A user only consumes a license if they belong to a mapped group, and both the user and the directory are active.
-- Jira Software licensed users
SELECT COUNT(DISTINCT u.lower_user_name) AS jira_software_licensed_users
FROM cwd_user u
JOIN cwd_membership m ON u.id = m.child_id AND u.directory_id = m.directory_id
JOIN licenserolesgroup lrg ON LOWER(m.parent_name) = LOWER(lrg.group_id)
JOIN cwd_directory d ON m.directory_id = d.id
WHERE d.active = '1' AND u.active = '1'
AND lrg.license_role_name = 'jira-software';
-- Jira Service Management licensed users
SELECT COUNT(DISTINCT u.lower_user_name) AS jsm_licensed_users
FROM cwd_user u
JOIN cwd_membership m ON u.id = m.child_id AND u.directory_id = m.directory_id
JOIN licenserolesgroup lrg ON LOWER(m.parent_name) = LOWER(lrg.group_id)
JOIN cwd_directory d ON m.directory_id = d.id
WHERE d.active = '1' AND u.active = '1'
AND lrg.license_role_name = 'jira-servicedesk';
-- Breakdown by product
SELECT
lrg.license_role_name AS product,
COUNT(DISTINCT u.lower_user_name) AS licensed_users
FROM cwd_user u
JOIN cwd_membership m ON u.id = m.child_id AND u.directory_id = m.directory_id
JOIN licenserolesgroup lrg ON LOWER(m.parent_name) = LOWER(lrg.group_id)
JOIN cwd_directory d ON m.directory_id = d.id
WHERE d.active = '1' AND u.active = '1'
GROUP BY lrg.license_role_name
ORDER BY licensed_users DESC;
Getting this wrong -- and it is easy to get wrong -- means buying a Cloud tier that is either too expensive or too small. The difference between a 500-user tier and a 2,000-user tier is significant, and the answer is often not what people expect when they look at the raw cwd_user count.
Workflow Complexity (Jira)
This is where the real cost of a migration hides. Jira stores the entire workflow definition as an XML document in the descriptor column of the jiraworkflows table. That XML contains every transition, condition, validator, and post-function, including the Java class names of marketplace app extensions.
The Cloud Migration Assistant does not migrate post-functions from apps like JMWE, JSU, or ScriptRunner. Every one of those post-functions needs to be manually analysed, documented, and rebuilt in Cloud. If you do not count them before committing to a timeline, you have no idea how much work is actually involved.
SELECT
w.workflowname AS workflow_name,
(SELECT COUNT(DISTINCT p.id)
FROM project p
JOIN nodeassociation na ON na.source_node_id = p.id
AND na.source_node_entity = 'Project'
AND na.sink_node_entity = 'WorkflowScheme'
JOIN workflowscheme ws ON ws.id = na.sink_node_id
JOIN workflowschemeentity wse ON wse.scheme = ws.id
WHERE wse.workflow = w.workflowname) AS project_count,
(SELECT COUNT(*)
FROM jiraissue ji
JOIN os_wfentry wfe ON ji.workflow_id = wfe.id
WHERE wfe.name = w.workflowname) AS issue_count,
(LENGTH(w.descriptor) - LENGTH(REPLACE(w.descriptor, '<action ', '')))
/ LENGTH('<action ') AS transition_count,
(LENGTH(w.descriptor) - LENGTH(REPLACE(w.descriptor, '<condition ', '')))
/ LENGTH('<condition ') AS condition_count,
(LENGTH(w.descriptor) - LENGTH(REPLACE(w.descriptor, '<validator ', '')))
/ LENGTH('<validator ') AS validator_count,
(LENGTH(w.descriptor) - LENGTH(REPLACE(w.descriptor, '<function ', '')))
/ LENGTH('<function ') AS postfunction_count,
CASE WHEN w.descriptor LIKE '%com.onresolve%'
THEN 'Yes' ELSE 'No' END AS has_scriptrunner,
CASE WHEN w.descriptor LIKE '%com.innovalog%' OR w.descriptor LIKE '%jmwe%'
THEN 'Yes' ELSE 'No' END AS has_jmwe,
CASE WHEN w.descriptor LIKE '%com.googlecode.jsu%'
THEN 'Yes' ELSE 'No' END AS has_jsu
FROM jiraworkflows w
ORDER BY project_count DESC NULLS LAST;
The plugin key patterns are worth committing to memory. ScriptRunner uses com.onresolve, JMWE uses com.innovalog, and JSU uses com.googlecode.jsu. That last one is not intuitive and people regularly get it wrong, which means they miss JSU post-functions in their count and end up with a timeline that is too short.
Custom Fields (Jira)
Custom field proliferation is one of the most common problems in mature instances. Teams create new fields instead of reusing existing ones, and over years the instance accumulates hundreds or thousands of them.
SELECT
cf.id AS field_id,
cf.cfname AS field_name,
cf.customfieldtypekey AS field_type,
CASE
WHEN cf.customfieldtypekey LIKE '%select%' THEN 'Select'
WHEN cf.customfieldtypekey LIKE '%text%' THEN 'Text'
WHEN cf.customfieldtypekey LIKE '%date%' THEN 'Date'
WHEN cf.customfieldtypekey LIKE '%user%' THEN 'User Picker'
WHEN cf.customfieldtypekey LIKE '%number%' THEN 'Number'
WHEN cf.customfieldtypekey LIKE '%cascade%' THEN 'Cascading Select'
ELSE 'Other'
END AS field_category,
(SELECT COUNT(DISTINCT issue) FROM customfieldvalue cfv
WHERE cfv.customfield = cf.id) AS issues_with_value,
(SELECT COUNT(*) FROM customfieldoption cfo
WHERE cfo.customfield = cf.id) AS option_count,
(SELECT COUNT(*) FROM fieldconfigscheme fcs
WHERE fcs.fieldid = 'customfield_' || cf.id) AS context_count
FROM customfield cf
ORDER BY issues_with_value DESC;
The issues_with_value column is the one that matters most. Fields with zero usage can be deleted before migration. Fields where the field_type contains com.onresolve are ScriptRunner scripted fields that will not function in Cloud without a rewrite. Fields with the same name but different IDs should be consolidated before migration to avoid confusion.
Attachment Storage (Jira)
Large attachments slow down migration and can cause timeouts during transfer. You need to know both the total footprint and which specific files are problematic.
SELECT
COUNT(*) AS total_attachments,
ROUND(SUM(filesize) / 1024.0 / 1024.0 / 1024.0, 2) AS total_gb,
ROUND(AVG(filesize) / 1024.0, 2) AS avg_size_kb,
COUNT(*) FILTER (WHERE filesize > 10485760) AS files_over_10mb,
COUNT(*) FILTER (WHERE filesize > 52428800) AS files_over_50mb
FROM fileattachment;
SELECT
p.pkey || '-' || ji.issuenum AS issue_key,
fa.filename,
fa.mimetype,
ROUND(fa.filesize / 1024.0 / 1024.0, 2) AS size_mb,
fa.created
FROM fileattachment fa
JOIN jiraissue ji ON fa.issueid = ji.id
JOIN project p ON ji.project = p.id
WHERE fa.filesize > 10485760
ORDER BY fa.filesize DESC
LIMIT 50;
Schemes (Jira)
Scheme sprawl builds up quietly over years. People create new permission schemes, notification schemes, and workflow schemes for specific projects, and they never get cleaned up. The result is more schemes than projects, many of them near-identical copies.
SELECT
'Permission' AS scheme_type,
ps.name AS scheme_name,
(SELECT COUNT(*) FROM nodeassociation na
WHERE na.sink_node_id = ps.id
AND na.source_node_entity = 'Project'
AND na.sink_node_entity = 'PermissionScheme') AS project_count
FROM permissionscheme ps
ORDER BY project_count DESC;
SELECT
'Workflow' AS scheme_type,
ws.name AS scheme_name,
(SELECT COUNT(*) FROM nodeassociation na
WHERE na.sink_node_id = ws.id
AND na.source_node_entity = 'Project'
AND na.sink_node_entity = 'WorkflowScheme') AS project_count
FROM workflowscheme ws
ORDER BY project_count DESC;
Schemes with project_count = 0 are unused and can be deleted. Schemes used by only one project are worth comparing against similar schemes to see if consolidation is possible.
Filters and Dashboards (Jira)
Filters with subscriptions are particularly important because those subscriptions trigger email notifications. If a filter breaks after migration, people silently stop getting updates they depend on. Nobody notices until someone asks why their weekly report stopped arriving.
SELECT
sr.filtername AS filter_name,
u.display_name AS owner,
sr.reqcontent AS jql,
CASE
WHEN EXISTS (SELECT 1 FROM sharepermissions sp
WHERE sp.entityid = sr.id
AND sp.entitytype = 'SearchRequest'
AND sp.sharetype = 'global') THEN 'Global'
WHEN EXISTS (SELECT 1 FROM sharepermissions sp
WHERE sp.entityid = sr.id
AND sp.entitytype = 'SearchRequest'
AND sp.sharetype = 'group') THEN 'Group'
ELSE 'Private'
END AS share_type,
(SELECT COUNT(*) FROM favouriteassociations fa
WHERE fa.entityid = sr.id
AND fa.entitytype = 'SearchRequest') AS favourite_count,
(SELECT COUNT(*) FROM filtersubscription fs
WHERE fs.filter_i_d = sr.id) AS subscription_count
FROM searchrequest sr
LEFT JOIN app_user au ON sr.authorname = au.user_key
LEFT JOIN cwd_user u ON au.lower_user_name = u.lower_user_name
ORDER BY favourite_count DESC;
Confluence: Space Inventory
SELECT
s.spacekey,
s.spacename,
s.spacetype,
(SELECT COUNT(*) FROM content c
WHERE c.spaceid = s.spaceid
AND c.contenttype = 'PAGE'
AND c.content_status = 'current') AS page_count,
(SELECT COUNT(*) FROM content c
WHERE c.spaceid = s.spaceid
AND c.contenttype = 'BLOGPOST'
AND c.content_status = 'current') AS blog_count,
(SELECT ROUND(COALESCE(SUM(a.filesize), 0) / 1024.0 / 1024.0 / 1024.0, 2)
FROM attachments a
JOIN content c ON a.pageid = c.contentid
WHERE c.spaceid = s.spaceid) AS attachment_gb,
(SELECT MAX(c.lastmoddate) FROM content c
WHERE c.spaceid = s.spaceid) AS last_activity
FROM spaces s
WHERE s.spacestatus = 'CURRENT'
ORDER BY page_count DESC;
Personal spaces (spacetype = 'personal') tend to be the largest category by count but lowest in business value. Whether they all need to migrate is a decision worth making explicitly.
Confluence: Licensed Users
Confluence uses a completely different mechanism for counting licensed users than Jira does. Instead of licenserolesgroup, it uses the SPACEPERMISSIONS table and the USECONFLUENCE permission type.
There is also a difference that trips people up every time: Confluence uses 'T' and 'F' (character strings) for boolean active flags, not 1 and 0 (numbers) like Jira. Copy a Jira user query into Confluence without changing this and you get zero results.
SELECT COUNT(DISTINCT u.lower_user_name) AS confluence_licensed_users
FROM cwd_user u
JOIN cwd_membership m ON u.id = m.child_user_id
JOIN cwd_group g ON m.parent_id = g.id
JOIN SPACEPERMISSIONS sp ON g.group_name = sp.PERMGROUPNAME
JOIN cwd_directory d ON u.directory_id = d.id
WHERE sp.PERMTYPE = 'USECONFLUENCE'
AND u.active = 'T'
AND d.active = 'T';
Confluence: Macro Usage
Macros are the Confluence equivalent of workflow post-functions in Jira -- the thing most likely to break during migration and the thing most likely to be missed. Confluence stores page content as XML in the bodycontent table, and macros are embedded as XML tags within that content.
SELECT
macro_name,
COUNT(*) AS total_usage,
COUNT(DISTINCT spaceid) AS space_count,
COUNT(DISTINCT contentid) AS page_count
FROM (
SELECT
c.contentid,
c.spaceid,
UNNEST(REGEXP_MATCHES(
bc.body,
'<ac:structured-macro[^>]*ac:name="([^"]+)"',
'g'
)) AS macro_name
FROM content c
JOIN bodycontent bc ON c.contentid = bc.contentid
JOIN spaces s ON c.spaceid = s.spaceid
WHERE c.contenttype = 'PAGE'
AND c.content_status = 'current'
AND s.spacestatus = 'CURRENT'
AND bc.body LIKE '%<ac:structured-macro%'
) macros
GROUP BY macro_name
ORDER BY total_usage DESC;
Native macros like code, info, note, warning, panel, expand, toc, and children migrate without issues. Third-party macros -- anything with a com. prefix -- need to be checked against the vendor's Cloud marketplace listing individually. Pages using the HTML macro need particular attention because arbitrary HTML and JavaScript execution is heavily restricted in Cloud for security reasons.
Confluence: Anonymous Access
Confluence has a quirk in how it stores anonymous permissions. In the spacepermissions table, an anonymous permission is identified by checking that all three user identifier columns are NULL simultaneously: permusername, permgroupname, and permalluserssubject. Check only one or two and you get false results.
SELECT
s.spacekey,
s.spacename,
sp.permtype AS anonymous_permission
FROM spacepermissions sp
JOIN spaces s ON sp.spaceid = s.spaceid
WHERE sp.permusername IS NULL
AND sp.permgroupname IS NULL
AND sp.permalluserssubject IS NULL
AND s.spacestatus = 'CURRENT'
ORDER BY s.spacekey;
Confluence: The user_mapping Table
One more Confluence gotcha. You cannot join content.creator directly to cwd_user. There is an intermediate table called user_mapping that bridges between user keys and usernames. Skip it and you get zero results or wrong matches.
SELECT
c.title,
u.display_name AS creator
FROM content c
JOIN user_mapping um ON c.creator = um.user_key
JOIN cwd_user u ON um.lower_username = u.lower_user_name
WHERE c.content_status = 'current';
Gathering Data: REST API Scripts
If you do not have direct database access, or you want to supplement the SQL results with data that is easier to get through the application layer, you can use the Jira and Confluence REST APIs. These scripts can be run from any machine that has network access to the instance -- your laptop, a CI server, wherever.
The examples below use curl for clarity, but you can wrap these in any scripting language. The key is that they do not require you to install anything on the Jira or Confluence server itself.
All Projects With Metadata (Jira)
# Jira: list all user-installed plugins
curl -s -u "$USER:$TOKEN" \
"$BASE_URL/rest/plugins/1.0/?os_authType=basic" \
-H "Accept: application/json" \
| python3 -c " import json, sys data = json.load(sys.stdin) plugins = data.get('plugins', []) user_installed = [p for p in plugins if p.get('userInstalled')] for p in sorted(user_installed, key=lambda x: x.get('name', '')): print(f'{p.get(\"name\", \"??\"):50} {p.get(\"key\", \"\")}') print(f'\nTotal user-installed apps: {len(user_installed)}') "
All Custom Fields (Jira)
This gives you every custom field in the instance along with its type, which is useful for identifying ScriptRunner scripted fields and other marketplace-app-provided fields.
# Get all projects with issue counts
curl -s -u "$USER:$TOKEN" \
"$BASE_URL/rest/api/2/project?expand=description,lead,url" \
| python3 -m json.tool > projects.json
# For each project, get issue count via search
for KEY in $(cat projects.json | python3 -c " import json, sys for p in json.load(sys.stdin): print(p['key']) "); do
COUNT=$(curl -s -u "$USER:$TOKEN" \
"$BASE_URL/rest/api/2/search?jql=project=$KEY&maxResults=0" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['total'])")
echo "$KEY: $COUNT issues"
done
All Installed Apps (Jira/Confluence)
This extracts the full list of installed marketplace apps, which feeds directly into the MAGIC "A" (Apps) assessment.
# Jira: list all user-installed plugins
curl -s -u "$USER:$TOKEN" \
"$BASE_URL/rest/plugins/1.0/?os_authType=basic" \
-H "Accept: application/json" \
| python3 -c " import json, sys data = json.load(sys.stdin) plugins = data.get('plugins', []) user_installed = [p for p in plugins if p.get('userInstalled')] for p in sorted(user_installed, key=lambda x: x.get('name', '')): print(f'{p.get(\"name\", \"??\"):50} {p.get(\"key\", \"\")}') print(f'\nTotal user-installed apps: {len(user_installed)}') "
Workflow Export (Jira)
The REST API can export workflows in XML format, which lets you analyse post-functions without needing direct database access.
# Get all workflow names
curl -s -u "$USER:$TOKEN" \
"$BASE_URL/rest/api/2/workflow" \
| python3 -c " import json, sys workflows = json.load(sys.stdin) for wf in workflows: print(wf['name']) " > workflow_names.txt
# Count workflows with JMWE/JSU/ScriptRunner (requires DB or XML export)
# The REST API does not expose workflow XML directly, but you can get
# workflow scheme associations:
curl -s -u "$USER:$TOKEN" \
"$BASE_URL/rest/api/2/workflowscheme" \
| python3 -m json.tool > workflow_schemes.json
Confluence Spaces and Content Counts
# Get all spaces with metadata
curl -s -u "$USER:$TOKEN" \
"$BASE_URL/rest/api/space?limit=500&expand=description.plain" \
| python3 -c " import json, sys data = json.load(sys.stdin) for s in data['results']: print(f'{s[\"key\"]:15} {s[\"name\"]:50} {s[\"type\"]}') print(f'\nTotal spaces: {data[\"size\"]}') "
# Get page count per space
for KEY in $(curl -s -u "$USER:$TOKEN" \
"$BASE_URL/rest/api/space?limit=500" \
| python3 -c " import json, sys for s in json.load(sys.stdin)['results']: print(s['key']) "); do
COUNT=$(curl -s -u "$USER:$TOKEN" \
"$BASE_URL/rest/api/content?spaceKey=$KEY&type=page&limit=0" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('size', 0))")
echo "$KEY: $COUNT pages"
done
Gathering Data: ScriptRunner Groovy Scripts
If you have ScriptRunner installed on the Data Center instance, you can run Groovy scripts directly in the ScriptRunner console. This is often the most practical approach because it gives you access to the full Jira or Confluence Java API without needing database credentials or external network access.
Workflow Post-Function Audit (Jira)
This script iterates over every workflow in the instance, parses the XML descriptor, and counts post-functions from JMWE, JSU, and ScriptRunner. This is the same information as the SQL workflow query, but gathered through the application layer.
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.workflow.WorkflowManager
def workflowManager = ComponentAccessor.getWorkflowManager()
def results = new StringBuilder()
results.append("Workflow Name | Transitions | JMWE | JSU | ScriptRunner\n")
results.append("-" * 80 + "\n")
workflowManager.getWorkflows().each { workflow ->
def descriptor = workflow.getDescriptor()
def xml = descriptor.asXML()
def transitions = (xml =~ /<action /).count
def jmweCount = (xml =~ /com\.innovalog|jmwe/).count
def jsuCount = (xml =~ /com\.googlecode\.jsu/).count
def srCount = (xml =~ /com\.onresolve/).count
if (jmweCount > 0 || jsuCount > 0 || srCount > 0) {
results.append(
"${workflow.name.take(50).padRight(50)} | " +
"${transitions.toString().padLeft(5)} | " +
"${jmweCount.toString().padLeft(4)} | " +
"${jsuCount.toString().padLeft(3)} | " +
"${srCount.toString().padLeft(12)}\n"
)
}
}
return results.toString()
This is more practical than the SQL approach for many teams because it does not require database access and produces a result you can copy straight into the assessment document.
Custom Field Usage Report (Jira)
import com.atlassian.jira.component.ComponentAccessor
def cfManager = ComponentAccessor.getCustomFieldManager()
def searchService = ComponentAccessor.getComponent(
com.atlassian.jira.bc.issue.search.SearchService
)
def user = ComponentAccessor.getJiraAuthenticationContext().getLoggedInUser()
def results = new StringBuilder()
results.append("Field Name | Type | Issues Using It\n")
results.append("-" * 70 + "\n")
def unusedCount = 0
cfManager.getCustomFieldObjects().each { cf ->
def jql = "cf[${cf.idAsLong}] is not EMPTY"
try {
def parseResult = searchService.parseQuery(user, jql)
if (parseResult.isValid()) {
def count = searchService.searchCount(user, parseResult.getQuery())
if (count == 0) unusedCount++
results.append(
"${cf.name.take(40).padRight(40)} | " +
"${cf.customFieldType?.name?.take(20)?.padRight(20)} | " +
"${count}\n"
)
}
} catch (Exception e) {
results.append("${cf.name.take(40).padRight(40)} | ERROR: ${e.message}\n")
}
}
results.append("\nTotal custom fields: ${cfManager.getCustomFieldObjects().size()}")
results.append("\nUnused fields (0 issues): ${unusedCount}")
return results.toString()
Group Membership and License Impact (Jira)
This script shows which groups grant license access and how many active users are in each one, which gives you the same information as the licenserolesgroup SQL query but without needing database access.
import com.atlassian.jira.component.ComponentAccessor
def groupManager = ComponentAccessor.getGroupManager()
def licenseRoleManager = ComponentAccessor.getComponent(
com.atlassian.jira.license.LicenseRoleManager
)
def results = new StringBuilder()
results.append("License Role | Group | Active Users\n")
results.append("-" * 60 + "\n")
licenseRoleManager.getLicenseRoles().each { role ->
role.getGroups().each { groupName ->
def group = groupManager.getGroup(groupName)
if (group) {
def members = groupManager.getUsersInGroup(group)
def activeCount = members.count { it.isActive() }
results.append(
"${role.name.padRight(25)} | " +
"${groupName.padRight(25)} | " +
"${activeCount}\n"
)
}
}
}
return results.toString()
Confluence: Space Size and Activity Report
If you have ScriptRunner for Confluence, this script gathers space metrics without database access.
import com.atlassian.confluence.spaces.SpaceManager
import com.atlassian.confluence.pages.PageManager
import com.atlassian.sal.api.component.ComponentLocator
def spaceManager = ComponentLocator.getComponent(SpaceManager)
def pageManager = ComponentLocator.getComponent(PageManager)
def results = new StringBuilder()
results.append("Space Key | Space Name | Pages | Attachments\n")
results.append("-" * 70 + "\n")
spaceManager.getAllSpaces().findAll { it.isCurrentStatus() }.each { space ->
def pages = pageManager.getPages(space, true).size()
results.append(
"${space.key.padRight(15)} | " +
"${space.name.take(35).padRight(35)} | " +
"${pages.toString().padLeft(6)} | " +
"${space.type}\n"
)
}
return results.toString()
Parsing the Support Zip
If your Jira or Confluence admin can generate a support zip from the admin console (System > Troubleshooting and support tools > Create support zip), it contains a wealth of structured data that you can parse without needing database access or ScriptRunner.
The support zip contains several files that are directly useful for the assessment:
application.xml contains the full list of installed plugins with their keys, versions, vendors, and enabled/disabled status. Parsing this file gives you the complete marketplace app inventory. It also contains SSO configuration details if SAML or OAuth is set up.
dbconfig.xml (Jira) or confluence.cfg.xml (Confluence) contains the database type and connection details. You need to know the database engine (PostgreSQL, MySQL, Oracle, SQL Server) because it affects which SQL syntax variations you use and whether certain migration features are supported.
Health check results (JSON or text files) contain the output of Jira's built-in health checks. Failed health checks can indicate problems that need to be resolved before migration -- things like index corruption, attachment store inconsistencies, or database schema issues.
Cluster configuration files tell you whether the instance is running as a Data Center cluster and how many nodes are active. This affects the migration approach because you need to understand the deployment topology to plan the cutover.
You can parse these files with any scripting language. The plugin list from application.xml is XML and can be processed with standard XML parsers. The configuration files are either XML or Java properties format. The health check results are typically JSON.
Patterns Worth Looking For
Orphaned Configuration
Projects, schemes, custom fields, and filters that nobody owns or uses but are still sitting in the instance. They inflate your assessment numbers, add noise to every query result, and create unnecessary work during migration. Query for zero-usage items and flag them for cleanup before you start.
The Mega-Workflow
A single workflow with 40+ transitions, dozens of conditions, and post-functions from three different marketplace apps. These take the most time to migrate because they are usually undocumented and the original author is long gone. When you find one, budget time for analysis before rebuilding. Once you understand what the workflow actually does, the rebuild itself is usually straightforward.
Custom Field Sprawl
Hundreds of fields, many sharing the same name but with different IDs, others created for a one-off project and never used again. The custom field inventory query surfaces this, and the issues_with_value column tells you which fields are actually in use and which are dead weight.
Attachment Bloat
Database dumps, video recordings, log files, and other large binaries that nobody accesses anymore. Large attachments slow down migration, cause timeouts during transfer, and consume Cloud storage you pay for. Identify the largest files and the projects they belong to before migration starts.
Permission Complexity
Permission schemes that use dynamic grants -- Reporter, Current Assignee, Project Lead -- combined with project roles that reference groups that reference LDAP directories. This chain of dependencies needs to work correctly in Cloud, and verifying it requires testing specific users against specific projects. It is methodical work, but it is finite and predictable once you have the permission scheme inventory from the queries above.
Migration Strategy
The assessment data determines the strategy.
Big bang -- migrate everything at once -- works for small instances, under 50 projects and under 100K issues. It is simpler to plan but higher risk: if something breaks, everything is affected.
Phased batching is the right approach for medium to large instances. Plan 5-7 production batches plus an initial pilot batch. The composition of each batch matters: projects that share a workflow scheme should be in the same batch, projects with cross-project Agile boards must migrate together, and if you use a test management app with linkages between test cases and requirements across projects, those projects must co-migrate.
Start the pilot with 5-10 low-risk, low-complexity projects. The pilot validates the migration process, uncovers issues with Cloud environment setup, and gives the team experience before they tackle the complex batches.
Pre-Migration Cleanup
Delete unused custom fields. Archive inactive projects. Consolidate duplicate schemes. Remove orphaned components and versions. Install Cloud versions of marketplace apps that require pre-installation (some apps need to exist in Cloud before the migration assistant runs, or their data will not transfer). Configure SSO/SAML in the Cloud environment. Verify domain ownership. Run a test migration and validate the results. Document every JMWE, JSU, and ScriptRunner post-function that will need manual rebuilding. Review anonymous access on Confluence spaces. Contact owners of heavily-used filters and dashboards to coordinate post-migration testing.
Estimating Effort
These numbers come from real-world migrations. They are raw engineering hours with no contingency buffer -- add 20-30% on top for unknowns.
Basic project migration runs about 0.5 hours per project. Rebuilding a workflow that uses JMWE or JSU runs about 4 hours per workflow, plus 1-1.5 hours for each individual post-function within that workflow. ScriptRunner Groovy scripts take about 16 hours each because the Cloud API is fundamentally different from the Server/DC API and they require complete rewrites.
Reviewing automation rules takes about 0.5 hours each. Validating a critical marketplace app takes about 8 hours per app. Reviewing custom fields runs about 0.25 hours each. Confluence space migration is about 0.5 hours per space, and reviewing pages with HTML macros is about 0.25 hours per affected page.
Beyond per-unit work, budget about 5 days for SSO configuration, 3 days for Cloud environment setup, 10 days for a full pilot migration cycle, and 15 days for final cutover and stabilisation. And do not forget post-migration validation -- verifying that data arrived intact, attachments are accessible, permissions are correct, and nothing was silently dropped. The validation effort scales with the size of the instance, and it is not optional if zero data loss is the goal.
Wrapping Up
A migration becomes predictable when you know exactly what you are working with. The assessment gives you that knowledge, and the validation work afterwards confirms that nothing was lost along the way.
Count licensed users through licenserolesgroup for Jira and SPACEPERMISSIONS for Confluence -- not through cwd_user. Parse workflow XML for post-function counts. Map schemes to projects through nodeassociation. Remember the Confluence gotchas: 'T'/'F' for booleans, the user_mapping bridge table, the three-NULL-column check for anonymous access.
Use SQL if you have database access, REST APIs if you do not, and ScriptRunner if it is already installed. Between these three approaches, you can build a complete picture of the instance without depending on anyone else's timeline.
Clean up before you migrate. Every unused field, orphaned project, and empty scheme you remove makes the migration simpler and the target environment cleaner.
The goal is not a perfect assessment. The goal is zero data loss -- every issue, every page, every attachment, every permission arriving intact in Cloud. A thorough assessment tells you what to expect, and that means you know exactly what to validate once the data lands on the other side. No surprises during the transfer, no content silently left behind.
Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.
Top comments (0)