Auditing a Jira Migration: a Wrong Field ID Counts to Zero, Not to an Error
Mihai Perdum
Author
16 min readAugust 12, 2026
Key takeaways
A JQL clause referencing a custom field ID that does not exist returns HTTP 200 with count 0, not an error. Both `is EMPTY` and `is not EMPTY` return 0.
Zero is ambiguous in both directions: nine live fields on my site, four of them JSM SLA fields, also return 0 for both halves.
Gate on the catalogue with a name assertion — the paginated `GET /rest/api/3/field/search?type=custom`, fetched once per run.
Do not gate on `GET /rest/api/3/field`. It lists a custom field only after the field has been put on a screen, and it omitted 9 of 197 on my site.
Do not gate on `jql/parse?validation=strict` — it rejected both the `customfield_N` syntax and a real field that search resolved correctly.
The `is EMPTY` + `is not EMPTY` partition check is a warning, not proof. Ten live fields fail it and twenty more answer 400.
DC→Cloud: JCMA re-mints field IDs but exposes a serverId→cloudId mapping API from 1.11.4 — use it instead of matching by name.
Cloud→Cloud: JCMA is not involved. Both tenants mint IDs from 10000 up, so a wrong ID returns a plausible count for a different field, and only the name assertion catches it.
Every Atlassian migration ends with the same question and almost never with a real answer: did it actually land? The sync log tells you what your script intended. Only an audit tells you what happened. So you write the audit, and if you have migrated Jira before you write roughly this — for each custom field, count how many issues have a value, and decide what to do with the field based on the number:
javascript
1if(originalCount ===0&& migratedCount >0)return"RENAME migrated to canonical; delete original";2if(originalCount >0&& migratedCount ===0)return"DELETE migrated field; it is empty";3if(originalCount ===0&& migratedCount ===0)return"DELETE both — neither is used";
That is a reasonable decision matrix and I have shipped versions of it. It has one property that turns out to matter more than everything else in the script: every branch that deletes something is triggered by a zero.
So the only question worth asking about the audit is: what else, other than an empty field, can produce a zero?
I spent a day answering that against a live Jira Cloud site, and I got the answer wrong on the first pass. My first version of this article proposed a gate that I had tested against four fields. Then I swept all 197 custom fields on the site through it and found ten live fields that the gate condemned and twenty that made it crash. The gate I expected to win is not the one that works. That is the useful part, so it is the part I have written up.
Everything below was measured on 12 August 2026 against my own test tenant — a Jira Cloud site holding 8,462 issues across 17 projects. Where I could not run something, I say so.
The measurement
Here is the whole problem in four API calls. customfield_10015 is a real field on my site, called "Start date". customfield_99999 does not exist — I checked the catalogue first, and it is not there.
bash
1curl-s-X POST -u"$EMAIL:$TOKEN"\2-H'Content-Type: application/json'\3--data'{"jql":"cf[10015] is not EMPTY"}'\4"https://your-site.atlassian.net/rest/api/3/search/approximate-count"
JQL
HTTP
response
cf[10015] is not EMPTY
200
{"count":7398}
cf[10015] is EMPTY
200
{"count":1064}
cf[99999] is not EMPTY
200
{"count":0}
cf[99999] is EMPTY
200
{"count":0}
5 rows × 3 columnsHeader row enabled
A reference to a field that does not exist is not an error. It is a 200 with a zero. There is no errorMessages array, no warning, nothing in the response body to distinguish "this field is empty on every issue" from "I have no idea what field you are talking about".
It does not help to add scope, either. The bogus clause is not dropped — it poisons the whole query:
JQL
count
project = WFH
288
project = WFH AND cf[99999] is not EMPTY
0
project = WFH AND cf[99999] is EMPTY
0
project = WFH AND cf[99999] = "anything"
0
5 rows × 2 columnsHeader row enabled
A project with 288 issues reports 0 issues both with and without a value in a field that does not exist. Feed that into the decision matrix above and it returns DELETE both — neither is used.
This is the failure shape that costs people data in migrations, and it is worth being precise about why it is so much worse than a crash. A 400 stops the run and you fix the mapping. A zero completes the run, writes a clean CSV, and produces a recommendation column full of confident, wrong instructions — which somebody then executes, because that is what the CSV is for.
Caution
A count of zero is not evidence that a field is empty. It is evidence that the query matched nothing, and "the field reference is dead" is one of the ways a query matches nothing. Before a zero is allowed to authorise a destructive action, prove that the same query can see the field at all.
Why the endpoint is worth using anyway
To be clear about the trade, POST /rest/api/3/search/approximate-count is the right tool and I am not arguing against it. It exists because Atlassian removed total from the search response — the old /rest/api/3/search is deprecated and now answers 410 Gone, its replacement POST /rest/api/3/search/jql paginates with an opaque nextPageToken and returns no total, so counting by any other route means walking every page.
I measured both against the same query on the same site — every issue on the tenant:
calls
response bytes
wall clock
result
POST /search/approximate-count
1
14
247 ms
8462
POST /search/jql, 100/page, fields:["id"]
85 serial
138,812
24,643 ms
8462
3 rows × 5 columnsHeader row enabled
One call against 85, and about a hundred times faster. The approximate count was also exactly right here, and matched exact pagination on every scope I tried — but Atlassian calls it an estimate and warns that recent updates "might not be immediately visible" in the output, with indexing delay varying "from a few seconds to minutes". For an audit that error is noise. For "did exactly 12,400 issues migrate", pay for the pagination.
Two things about it worth knowing before you write the client. First, it refuses an unbounded query. Atlassian documents the constraint in a single line — the endpoint "requires JQL to be bounded" — but does not define what counts as bounded or publish the error you get, which is:
json
1{"errorMessages":["Unbounded JQL queries are not allowed here. Please add a search restriction to your query."],"errors":{}}
So order by created ASC gets you a 400, and you need a tautological bound instead. I use created >= "1970-01-01" to mean "everything". Second, it also rejects a trailing ORDER BY, which matters more than it sounds — plan files routinely carry a scope string with a sort on the end, and it will 400 the moment you wrap it. Strip it before you build the clause.
Where the wrong ID comes from, part one: Data Center to Cloud
The reason this matters in migration specifically, rather than as a general JQL curiosity, is that migration is the one activity guaranteed to invalidate your field IDs.
The Jira Cloud Migration Assistant re-mints the numeric IDs of the entities it moves. A custom field that was customfield_12345 on Data Center gets a different number on Cloud, chosen at import time. Any audit script that carries DC IDs forward is asking Cloud about fields that, from Cloud's point of view, do not exist — and getting a clean zero for every one of them.
The IDs are not simply lost, though, and this is the single most useful thing to know in this section: from JCMA 1.11.4 onward there is a documented API that returns the serverId → cloudId mappings for a completed migration, and Atlassian's own guidance for fixing broken integrations is to replace the server IDs with cloud IDs using it. Atlassian labels that API beta and warns it may be removed in a future JCMA version, so pull your mappings and persist them rather than calling it on demand — but do pull them. Rebuilding the map by name matching, which is what most people do, is where the wrong IDs come from in the first place.
The nastier version is JCMA's name-collision behaviour. When JCMA finds a matching custom field already on the destination it links to it rather than duplicating it — custom fields are the first entry on Atlassian's list of entities that get linked instead of re-migrated. But matching is stricter than people expect: the field has to agree on data type, context and even description text, and Atlassian is explicit that "even minor changes in the description text will mean that fields will not match". When it does not match, you get a second field with (migrated) appended:
DC field
Cloud after JCMA
Severity
Severity — the pre-existing Cloud field, still empty
Severity
Severity (migrated) — the new one, holding all the data
3 rows × 2 columnsHeader row enabled
Now every automation rule, board filter and dashboard on Cloud points at the empty one, and all the data is in the other one. Cleaning that up is exactly what the decision matrix at the top of this article is for. Run several batches that each fail to match and the suffixes stack up as (migrated 2) and beyond.
Automation rules are the worst of that set, because they are the only one where the platform quietly repairs the reference for you instead of leaving it broken. Create a rule whose scope ARI and trigger event filter disagree and Jira returns 201, then silently rewrites the trigger to match the scope — so a source-versus-target diff of the rule JSON passes on a rule that is pointing at the wrong project.
So the DC→Cloud audit is a script that (a) pairs fields by base-name, (b) counts both sides of each pair, and (c) recommends a winner. Step (b) is a JQL count against an ID, and step (c) deletes things when the count is zero. Every structural pressure in a DC→Cloud job pushes wrong IDs into step (b).
The source side of the comparison is different too, and I could not test it — I do not have a Data Center instance to run against, so take this from Atlassian's Server/DC REST reference rather than from me. DC still has the classic /rest/api/2/search resource, which returns a total, so you can get a count without walking the issues. Atlassian's pagination documentation says to treat total as optional on the grounds that they may omit it when it is too expensive to compute, so the DC half of your comparison needs to handle its absence rather than assume a number is there. The same response carries maxResultWindow — typically 10,000 — which caps how deep you can paginate, and a full-sweep audit on a large project will hit that wall. If you are still sizing the job rather than verifying it, the field-usage counting in our migration assessment guide is the DC-side inventory this audit later checks against.
Where the wrong ID comes from, part two: Cloud to Cloud
Cloud-to-cloud is a genuinely different job and the difference is not cosmetic. Start with the tool, because people get this wrong: JCMA does not do cloud-to-cloud. Atlassian's documentation for the assistant lists its sources as Jira Core, Jira Software, Jira Service Management and Advanced Roadmaps — all qualified as "Server or Data Center products". There is no Cloud source.
The official route is a separate feature in Atlassian Administration, reached through Data management → Data transfer → Create copy plan, then choosing Jira. (If you are following an older write-up that says Settings → Data transfer, or calls the feature "Copy product data" or "Migrate cloud site", those are all previous names and paths for the same thing — the current page is titled "Transfer data from one Atlassian app instance to another" and it covers Confluence as well as Jira.) The source and destination dropdowns show only the instances you are an organization admin on, so that is the access you need before you start. Two limits matter for the audit: Atlassian recommends the destination carry the same Marketplace apps as the source and directs you to the vendor for app data itself, and it publishes soft limits recommending you split large moves across several copy plans rather than one. Multiple plans means multiple import passes, which means IDs minted at different times.
Now the part that makes C2C worse than DC→Cloud rather than easier. On DC→Cloud, a stale ID is usually a dead ID — the DC numbering has no relationship to Cloud's, so you get the zero, and a gate can catch it. On C2C, both sides are Jira Cloud, and Jira Cloud mints custom field IDs sequentially from 10000 upward on every tenant. So a field ID copied from the source tenant is not dead on the target. It is almost certainly alive and pointing at a completely different field.
I measured the density on my site. 197 custom fields occupy the range 10000–10640:
ID band
occupied
10000–10099
97 / 100
10100–10199
19 / 100
10200–10299
8 / 100
10300–10399
47 / 100
10400–10499
0 / 100
10500–10599
1 / 100
10600–10699
25 / 100
8 rows × 2 columnsHeader row enabled
The first hundred slots are 97% full. Any source-tenant field ID in that band, pasted against a target tenant, resolves to a real field on the target with something close to certainty — a real field with a real, plausible, entirely unrelated count. No zero. No alarm. Just a wrong number that looks exactly like a right one.
I also checked how the numbering behaves. Creating a fresh custom field on that site returned customfield_10958, well above 10640, the highest ID actually in use — so the counter is not "highest live ID plus one" and something consumed the numbers in between. I confirmed IDs are never recycled: I created a probe, deleted it, created another, and got the next number up rather than the freed one, twice. Ascending, never reused, always starting from 10000 on every tenant, which is exactly the recipe for two sites of similar age having heavily overlapping ID ranges.
The practical consequence: on a cloud-to-cloud job, checking that the ID resolves is not enough. You have to check that it resolves to the field you meant — by name and by type.
The gate I expected to win, and why it does not
Here is the check I was confident about, and I am walking through it because being wrong about it is the most useful thing I learned.
For any field that genuinely exists, every issue in scope is either empty or not empty for that field. So:
text
1count(scope AND cf[N] is not EMPTY) + count(scope AND cf[N] is EMPTY) == count(scope)
A live reference partitions the scope. A dead reference matches nothing on both halves and sums to zero. It is a positive control on the same object the audit is about, which is the only kind that proves anything.
Against four hand-picked fields it looked perfect:
field
what it is
is not EMPTY
is EMPTY
sum
verdict
10015
real, populated, on screens
7398
1064
8462
live
10960
real, created seconds earlier, no context
0
8462
8462
live
99999
does not exist
0
0
0
dead
11111
does not exist
0
0
0
dead
5 rows × 6 columnsHeader row enabled
Row two is the row I was pleased about: a brand-new field with no data reports 0 populated — the exact number that panics the naive audit — and the control still passes it, because the 8,462 empties account for the whole scope. Empty and dead, distinguished.
Then I ran it against all 197 custom fields on the site instead of four:
outcome
fields
partitions cleanly, control passes
167
HTTP 400 — not JQL-searchable at all
20
live, but sums to zero — wears the "dead" signature
9
live, but sums to something else
1
5 rows × 2 columnsHeader row enabled
Nine live, catalogued, perfectly real fields produce exactly the signature I had just told you means "dead":
text
1customfield_10080 "Time to resolution" SLA CustomField Type 0 + 0
2customfield_10081 "Time to first response" SLA CustomField Type 0 + 0
3customfield_10626 "Time to close after resolution" SLA CustomField Type 0 + 0
4customfield_10627 "Time to approve normal change" SLA CustomField Type 0 + 0
5customfield_10082 "Responders" Responders Field 0 + 0
6customfield_10247 "Work item created forms" Forms 0 + 0
7customfield_10097 "Design" Design 0 + 0
8customfield_10098 "Vulnerability" Vulnerability 0 + 0
9customfield_10032 "Location" Custom Google Map Field 0 + 0
The SLA rows are the ones that should worry you, because on a real Jira Service Management tenant those fields are full of data. jql/parse is at least honest about it — "The operator 'is' can not be used on SLAs" — but approximate-count returns 200 and a zero, which is the same silent zero this entire article is about. An audit that trusts the arithmetic reads four populated SLA fields as unused.
Four subtasks in a team-managed project match neither half. Scoped to that project it is a live false alarm — 0 + 41 = 41 against a scope of 45.
Then there are the twenty that do not answer at all. Ordinary-looking text and date fields — "First name", "Employee ID", "Date of birth", "Phone number", "Approvals" — return HTTP 400 to any clause, because they are not JQL-searchable:
text
1["Processing of your search request has failed. Enable validation to ensure that ..."]
Note that GET /rest/api/3/field reports searchable: true for these. It is wrong. A gate that throws on a non-200, which mine did, dies on 10% of the catalogue.
So the honest scorecard for the arithmetic control across 197 fields: ten false alarms, and zero catches that the other check did not already make. It stays in the script, because a partition failure is a genuine signal worth surfacing, but it is demoted from proof to warning. It can tell you something is worth a look. It cannot tell you a field is dead.
The gate that actually works
The check that survived every case I could construct is the boring one: look the ID up in the destination's field catalogue and assert on the name.
Every one of the nine zero-summing fields is present in the catalogue, so the catalogue check clears them correctly while the arithmetic condemns them. Every dead ID I tried is absent from it. And it is the only check that catches the cloud-to-cloud case at all, where the ID resolves to a real but wrong field and every count is perfectly plausible.
There is one trap in it, which is which endpoint you ask.
text
1GET /rest/api/3/field -> 232 fields, 188 of them custom
2GET /rest/api/3/field/search?type=custom -> 197 custom fields
Nine custom fields exist on my site that GET /rest/api/3/field never mentions. I originally reported that I could not work out the rule behind it. I can now, because it is reproducible in three steps:
text
11. create a field, put it on no screen -> in GET /field: false
22. add it to a screen -> in GET /field: true (within ~5s)
33. remove it from that screen again -> in GET /field: true (sticky)
GET /rest/api/3/field lists a custom field only once it has been associated with a screen, and the inclusion is then permanent. That explains all nine omissions, including the apparent counterexamples — fields showing zero screens today that were screened at some point in the past, and one probe field sitting on a screen that belongs to no screen scheme, which never earned inclusion. It also kills the evidence I had originally offered for it not being a timing lag; twenty seconds of absence is evidence for a lag, not against. It is not a lag, it is a gate, and the way to prove that is the experiment above rather than a stopwatch.
For an audit the consequence is simple: a freshly migrated field that has not been put on a screen yet is invisible to GET /field, and a catalogue check built on it will report a real field as missing. Use GET /rest/api/3/field/search?type=custom, paginate it, fetch it once and reuse it.
Two things about that endpoint's payload. It gives you typeDisplayName — a human-readable type like SLA CustomField Type or Short text (plain text only) — which is what you want to assert on. Do not do what I first did and derive a type by splitting schema.custom on a colon; several fields on my site have no colon in that string at all, and app fields yield unusable values like com.herocoders.plugins.jira.issuechecklist-free__issue-checklist-templates. It also does not return clauseNames, so you cannot use it to discover a field's display-name clause forms. That is fine in practice because cf[N] is derivable from the id, but it is worth knowing before you go looking.
On which form to write: I checked the clauseNames of all 188 custom fields that GET /rest/api/3/field does return, and not one advertises a bare customfield_N. They advertise cf[N] and the display name.
The remaining obvious idea is to validate the JQL before running it, using POST /rest/api/3/jql/parse?validation=strict. It does correctly reject cf[99999]. I still would not build a gate on it, because it disagrees with the search endpoint in two directions.
First, on syntax:
JQL
approximate-count
jql/parse?validation=strict
cf[10015] is not EMPTY
200, count 7398
ok
"Start date" is not EMPTY
200, count 7398
ok
customfield_10015 is not EMPTY
200, count 7398
rejected
4 rows × 3 columnsHeader row enabled
The customfield_10015 form — the exact string GET /rest/api/3/field gives you as the field's id — is resolved correctly by search and rejected by the validator with "Field 'customfield_10015' does not exist or you do not have permission to view it".
Second, and worse, it rejects fields that genuinely exist. I created a throwaway field and asked both endpoints about it immediately. (I made several of these probes over the day and deleted each after use, which is why the IDs climb.)
text
1JQL: cf[10959] is EMPTY
2 approximate-count -> 200 {"count":8462} (correct: brand-new field, empty on all 8462 issues)
3 jql/parse strict -> rejected: "Field 'cf[10959]' does not exist or you do not have permission to view it."
The field exists. Search resolves it and returns the right answer. The validator says it does not. A gate with two independent false-alarm modes trains you to ignore it, so it stays out of the script.
The script
Zero dependencies, Node 18+. The catalogue check is the gate; the partition check is a warning that never blocks on its own.
javascript
1// field-gate.mjs — prove a custom field reference is LIVE before you trust a count.2importhttpsfrom"node:https";34constHOST=newURL(process.env.JIRA_BASE_URL).host;5constAUTH="Basic "+Buffer.from(6`${process.env.JIRA_EMAIL}:${process.env.JIRA_API_TOKEN}`,7).toString("base64");89functionrequest(method, path, body){10returnnewPromise((resolve, reject)=>{11const payload = body ?JSON.stringify(body):null;12const req = https.request({13host:HOST, path, method,14headers:{15Authorization:AUTH,Accept:"application/json",16...(payload ?{17"Content-Type":"application/json",18"Content-Length":Buffer.byteLength(payload),19}:{}),20},21},(res)=>{22let data ="";23 res.on("data",(c)=>(data += c));24 res.on("end",()=>resolve({25status: res.statusCode,json: data ?JSON.parse(data):null,26}));27});28 req.on("error", reject);29if(payload) req.write(payload);30 req.end();31});32}3334// Returns a number, or null when Jira refused the query. Never throws on a 400:35// 20 of the 197 custom fields on my site are not JQL-searchable at all and answer36// every clause with a 400. That is a BLOCKED verdict, not a crash.37asyncfunctioncount(jql){38const r =awaitrequest("POST","/rest/api/3/search/approximate-count",{ jql });39if(r.status===200)return r.json.count;40if(r.status===400)returnnull;41thrownewError(`approximate-count ${r.status}: ${JSON.stringify(r.json)}`);42}4344// GET /rest/api/3/field lists a custom field only after it has been put on a screen,45// so it omitted 9 of 197 on my site. field/search is complete. Fetch it ONCE.46exportasyncfunctioncustomFieldCatalogue(){47const out =newMap();48let startAt =0;49for(;;){50const r =awaitrequest("GET",51`/rest/api/3/field/search?type=custom&maxResults=50&startAt=${startAt}`);52if(r.status!==200)thrownewError(`field/search ${r.status}`);53for(const f of r.json.values) out.set(f.id, f);54if(r.json.isLast)break;55 startAt += r.json.values.length;56}57return out;58}5960// approximate-count rejects a trailing ORDER BY, and plan scopes routinely carry one.61conststripOrderBy=(jql)=> jql.replace(/\s+order\s+by\s+.*$/is,"").trim();6263exportasyncfunctiongateField({ fieldNum, scope, expectName, expectType, catalogue }){64const id =`customfield_${fieldNum}`;65const failures =[];66const warnings =[];6768// Check 1 — THE GATE. Present in the destination catalogue, under the name and69// type the plan expects. This is the only check that catches a right-shaped ID70// pointing at the WRONG field — the normal cloud-to-cloud failure.71const cat = catalogue ??(awaitcustomFieldCatalogue());72const field = cat.get(id);73if(!field){74 failures.push(`${id} is not in the destination's custom-field catalogue`);75}else{76if(expectName && field.name!== expectName){77 failures.push(`${id} is "${field.name}", the plan expected "${expectName}"`);78}79if(expectType && field.typeDisplayName!== expectType){80 failures.push(81`${id} is type "${field.typeDisplayName}", the plan expected "${expectType}"`);82}83}8485// Check 2 — a corroborating alarm, NOT a proof of death. Measured across all 19786// custom fields on my site: 167 partition cleanly, 20 are not searchable, and 1087// are live fields that fail this — four JSM SLA fields, Responders, Forms, Design,88// Vulnerability, Location and Team.89const bare = scope ?stripOrderBy(scope):null;90constscoped=(clause)=>(bare ?`(${bare}) AND ${clause}`: clause);91const[populated, empty, total]=awaitPromise.all([92count(scoped(`cf[${fieldNum}] is not EMPTY`)),93count(scoped(`cf[${fieldNum}] is EMPTY`)),94count(bare ||'created >= "1970-01-01"'),95]);9697if(populated ===null|| empty ===null){98 failures.push(`cf[${fieldNum}] is not JQL-searchable — Jira answers every clause with a 400`);99}elseif(populated + empty !== total){100 warnings.push(101`partition check failed: ${populated} not-EMPTY + ${empty} EMPTY = ${populated + empty}, `+102`scope holds ${total}. Either the reference is dead, or this is one of the field `+103`types that does not answer EMPTY clauses. Resolve by hand before acting.`);104}105106return{ id,name: field?.name, populated, empty, total, failures, warnings };107}108109// ---- CLI ----110if(import.meta.url===`file://${process.argv[1]}`){111constarg=(n)=>{112const i = process.argv.indexOf(`--${n}`);113if(i ===-1)returnundefined;114const v = process.argv[i +1];115if(v ===undefined|| v.startsWith("--")){116console.error(`--${n} needs a value`);117 process.exit(2);118}119return v;120};121const fieldNum =arg("field");122if(!/^\d+$/.test(fieldNum ??"")){123console.error("usage: node field-gate.mjs --field <number> [--scope JQL] "+124"[--expect-name NAME] [--expect-type TYPE]");125 process.exit(2);126}127const r =awaitgateField({128 fieldNum,129scope:arg("scope"),130expectName:arg("expect-name"),131expectType:arg("expect-type"),132});133console.log(134`${r.id}${r.name?`"${r.name}"`:"(not in catalogue)"} — `+135`${r.populated??"?"} populated / ${r.empty??"?"} empty / ${r.total} in scope`);136for(const w of r.warnings)console.log(` WARN ${w}`);137for(const f of r.failures)console.error(` FAIL ${f}`);138if(r.failures.length){139console.error(" → BLOCKED. The count above is not evidence of anything.");140 process.exitCode=1;141}elseif(r.warnings.length){142console.log(" → catalogue check passed, partition check did not. Review by hand.");143}else{144console.log(" → OK. Reference is live, the count is trustworthy.");145}146}
Two notes on things that are easy to get wrong. The tautological created >= "1970-01-01" is not decoration — it is there because approximate-count rejects an unbounded query. And catalogue is a parameter for a reason: without it, every field costs a full paginated catalogue re-fetch, and a 400-field audit spends 1,600 redundant calls against a documented limit of 300 points. Fetch it once in the caller and pass it down.
Wire it into the audit at the one place that matters, before any recommendation is computed:
javascript
1const catalogue =awaitcustomFieldCatalogue();// once, for the whole run23for(const pair of pairs){4const gate =awaitgateField({5fieldNum:idNum(pair.migrated.id),6scope:`project = ${projectKey}`,7expectName: pair.migrated.name,8 catalogue,9});10if(gate.failures.length){11 row.recommendation="BLOCKED — field reference could not be verified";12 row.notes= gate.failures.join("; ");13}elseif(gate.warnings.length){14 row.recommendation="REVIEW — counts did not reconcile";15 row.notes= gate.warnings.join("; ");16}else{17 row.recommendation=recommend(gate.populated,/* … */);18}19}
BLOCKED and REVIEW being distinct values is the important part. The instinct is to make everything unverifiable fall through to "needs manual review", which in a CSV of 400 rows is functionally identical to "delete it" because nobody reads the notes column. Give them their own recommendation values so they sort to the top and cannot be confused with a decision.
Running it
The cases that matter, against my live site — including the ones that must not block:
1customfield_10015 "Start date" — 64 populated / 224 empty / 288 in scope
2 → OK. Reference is live, the count is trustworthy. exit 0
34customfield_10015 "Start date" — 0 populated / 7 empty / 7 in scope
5 → OK. Reference is live, the count is trustworthy. exit 0
67customfield_99999 (not in catalogue) — 0 populated / 0 empty / 288 in scope
8 WARN partition check failed: 0 not-EMPTY + 0 EMPTY = 0, scope holds 288. …
9 FAIL customfield_99999 is not in the destination's custom-field catalogue
10 → BLOCKED. The count above is not evidence of anything. exit 1
1112customfield_10015 "Start date" — 64 populated / 224 empty / 288 in scope
13 FAIL customfield_10015 is "Start date", the plan expected "Sprint"
14 → BLOCKED. The count above is not evidence of anything. exit 1
1516customfield_10080 "Time to resolution" — 0 populated / 0 empty / 8462 in scope
17 WARN partition check failed: 0 not-EMPTY + 0 EMPTY = 0, scope holds 8462. …
18 → catalogue check passed, partition check did not. Review by hand. exit 0
1920customfield_10367 "Accessibility needs" — ? populated / ? empty / 8462 in scope
21 FAIL cf[10367] is not JQL-searchable — Jira answers every clause with a 400
22 → BLOCKED. The count above is not evidence of anything. exit 1
Case two is worth looking hardest at. SCRQ is a project where nobody has ever filled that field in, so the answer really is zero populated — and the gate passes it, correctly. It is the case that would break a naive "zero means broken" check, and it is the reason the arithmetic has to be a warning rather than a block. (Worth saying plainly, because I had this wrong in the first draft of this article: I assumed those seven issues were blank because the field had no context in that project. It does not — Start date has a single global context and is on SCRQ's screens. They are just empty. If you want the tour of what field contexts actually do control, the disappearing "None" option in single-select fields is the clearest example I know.)
Case four is the cloud-to-cloud failure in miniature. The ID resolves, the counts are real numbers, and it is the wrong field. Only the name assertion catches it. If your plan file does not carry the expected field name alongside the ID, you cannot detect this at all.
Case five is the SLA field, and note the exit code: 0, with a warning. That is deliberate. Blocking every JSM SLA field in a service-management migration would make the tool unusable, and the catalogue check has already established the field is real.
What this does not cover
I ran everything here against Jira Cloud on 12 August 2026, on a site licensed Free for Jira Software and Jira Service Management. The endpoints involved are core platform REST rather than edition-gated features, but I have not re-run any of it on Standard, Premium or Enterprise, and rate limits in particular should not be assumed identical — my site reported x-ratelimit-limit: 300. I could not test archived projects at all, because archiving is a Premium feature.
I did not test it on Jira Data Center; the DC comments above come from Atlassian's REST reference, not from a run. I have no Assets/CMDB fields on this site, so Assets attributes are untested and they have their own query semantics that cf[N] JQL does not cover.
The partition check compares three numbers that Atlassian describes as approximate, using exact equality. On my site every approximate count matched exact pagination, so I could not make that misfire — but on a large, busy tenant, which is the migration case, it is a structural source of false alarms. That is another reason it warns rather than blocks.
The name assertion is only as good as your plan file, and it cannot distinguish two fields that legitimately share a name — which, after a (migrated) collision, is close to the situation you are in. Assert on the full name including the suffix; Severity and Severity (migrated) are different strings, and that is the whole point of the suffix.
And the honest limit of the whole approach: this proves a field reference is live and points at the field you meant. It does not prove the values are right. That is a sampled row-level comparison against the source, which is a different script and a longer afternoon.
The general shape
The specific bug is a Jira quirk. The general shape is not, and it is worth naming because it recurs across every migration tool I have used.
An empty result is ambiguous between "there is nothing there" and "I could not see it". Those two mean opposite things, and the API returns the same bytes for both. Any time a zero, an empty list, a 404 or a "no results" is about to authorise a destructive action, the zero has to be proved rather than observed — and proved on the same object, because visibility in Atlassian is nearly always per-object. A check that some other field, project or space resolves correctly proves nothing about this one.
The part I would not have predicted is that my first attempt at proving it was itself a source of wrong answers. The arithmetic control is elegant, it is a genuine positive control, and against four fields it was flawless. Against all 197 it condemned ten live fields and crashed on twenty more. The only reason I know that is that I ran it against the whole catalogue instead of the examples I had chosen to demonstrate it — which, now that I write it down, is the same lesson as the article. A check that passes on the cases you picked has told you about the cases you picked.