Fix scripts vs background scripts
Same JavaScript, different discipline. When to explore in the background script box and when to ship a fix script that travels with your release.
You hit this at the end of a story, not the beginning. You prototyped a data migration in the background script box on a sub-prod instance, it works beautifully, and now it has to run in production as part of next week's release. The temptation is to paste the same script into production's background script box at 5pm on a Friday, run it, and call the story done. I have done it. Most of us have. And then three months later someone asks what exactly changed those 40,000 records, and the honest answer is "a text box, and it's gone now." The platform has a better container for this, and the difference between the two is not the JavaScript. It is the discipline wrapped around it.
The quick version
The decision in one table:
| Background script | Fix script | |
|---|---|---|
| Lives in | A text box, then nowhere | A sys_script_fix record |
| Travels in update sets | No | Yes |
| Gets code review | Only if someone is watching | Yes, it is a real artifact in the release |
| Runs in scope | Whatever the picker says that day | The application it belongs to |
| Rerun protection | Your memory | Your idempotency guard |
| Right for | Exploration, diagnosis, one-off cleanup | Repeatable data migration that ships with a release |
And a fix script skeleton worth stealing, with a run-once guard built in:
// Fix script: backfill service offering on legacy incidents
// Idempotent: safe to run twice, guarded by a marker property.
var MARKER = 'acme.fix.incident_offering_backfill.done';
var LOG = '[FIX_INC_OFFERING] ';
if (gs.getProperty(MARKER) === 'true') {
gs.info(LOG + 'Marker set, already ran on this instance. Skipping.');
} else {
var fixed = 0;
var gr = new GlideRecord('incident');
// Target by condition, never by sys_id list: this must work on every instance it lands on.
gr.addEncodedQuery('service_offeringISEMPTY^business_serviceISNOTEMPTY^active=true');
gr.query();
while (gr.next()) {
gr.setValue('service_offering', gr.business_service.getRefRecord().getValue('u_default_offering'));
gr.setWorkflow(false); // decided in review: no notifications for a backfill
gr.autoSysFields(false); // keep sys_updated_on honest for integrations
gr.update();
fixed++;
}
gs.info(LOG + 'Backfilled ' + fixed + ' incidents.');
gs.setProperty(MARKER, 'true'); // once, at the end. setProperty flushes cache.
}
Building it step by step
Step one: explore in the background script box, on purpose. The background script module is the right tool for the first phase. You are asking questions: how many records match, what do they look like, does the transform hold up against real data. Use the dry-run pattern (count with GlideAggregate, log samples, guard the destructive branch behind a flag) and keep the working copy in a git repo, not in your clipboard history.
Step two: harden it into something rerunnable. Production scripts get run twice. Someone reruns it "to be sure," or it ships in an app that gets upgraded, or a second admin runs it not knowing the first one did. So before promotion, make it idempotent: either the query itself only matches unfixed records (the skeleton above does this, empty service_offering stops matching once filled), or you add an explicit marker like the property guard, or both.
Step three: create the fix script in the right application. Navigate to System Definition, then Fix Scripts, and create the record inside the application the change belongs to. This matters more than it looks: a fix script executes in its application's scope, so cross-scope table access is governed by the same rules your app lives under every other day. A background script runs in whatever scope the picker happened to be set to, which is how "worked on dev" becomes "blocked on prod."
Step four: let it get reviewed. This is the cultural payoff. A fix script is a record with a name, a description, and a diff. It shows up in the update set review alongside your business rules and client scripts, and a colleague can read it before it exists anywhere near production data. Background scripts get pasted; fix scripts get reviewed. Write the description field like you mean it: what it changes, why, roughly how many records, and how to verify.
Step five: order it in the release. A fix script does not run when the update set commits. You commit the update set, then run the fix script by hand from the record (Related Links, Run Fix Script), in the order your runbook says. If the migration depends on a new field or choice list arriving in the same release, that ordering is the whole ballgame: commit first, then run. Put the sequence in the release notes, not in someone's head.
Step six: run, verify, and mark it done. After the production run, verify with the same GlideAggregate count you used in exploration, then record completion. The marker property does this in code; I also deactivate the fix script record and note the run date in the description. Future archaeologists will find a named artifact, a log trail, and a completion marker. That is what "without a trace" looks like when you do it right.
Name marker properties with a consistent prefix like acme.fix.*. A filtered list of sys_properties becomes a free ledger of every data migration that has ever run on the instance.
What is actually happening under the hood
A fix script is just a sys_script_fix record, and because that table extends sys_metadata, the platform treats it like any other configuration: it is captured by your current update set, it is packaged into a scoped app, and it moves through your instances the same way a business rule does. That single fact is where all the discipline comes from. The script becomes part of the release, so it inherits the release's review, testing, and rollback conversation.
There is one behavior worth knowing for scoped apps distributed through the app repo or store: fix scripts packaged in the app run as part of installation or upgrade. That is genuine run-once-with-the-release semantics, and it is also why idempotency is not optional, because "part of upgrade" means it will run again on the next version unless the script or the packaging says otherwise. Check the behavior on your release before you rely on the timing.
Background scripts, by contrast, execute as an interactive transaction under your session, in the scope the picker says, with admin-level reach. Newer releases do keep a script execution history you can consult after the fact, which is a gift for forensics, but a history entry is not a deployment artifact. It cannot be reviewed before the fact, moved between instances, or rerun on purpose.
Gotchas
gs.setProperty flushes the system cache. Call it once at the end of the script, never inside a loop, or you will turn a two-minute migration into an instance-wide slowdown. And never leave a packaged fix script active with work left in it unless you intend it to run again on the next app upgrade.
Two more from the field. First, volume: a fix script that runs during an app upgrade executes inside the upgrade window, so a migration touching millions of rows does not belong there. Ship the fix script as the trigger and let it delegate the heavy lifting to a scheduled job, or run it manually off-hours. Second, backout plans: everyone remembers to back out the update set and nobody remembers the fix script already rewrote 40,000 records. If the migration is not reversible, say so in the description, loudly, and capture a before-count so you can at least quantify what changed.
Variations worth knowing
The promotion checklist. When a background experiment graduates into a fix script, walk it through this list: dry-run flag removed or permanently defaulted safe, idempotency guard in place, no hardcoded sys_ids (conditions only, it must work on instances you have never seen), log prefix added, scope confirmed, description written for a stranger, captured in the update set, reviewed by someone who did not write it, rehearsed on a clone with production-sized data, verification query documented, and a decision recorded on whether it can be rerun.
The paired verification script. For high-stakes migrations I ship two fix scripts: the migration, and a read-only verifier that recounts and logs a pass or fail line. The verifier is cheap to write because it is mostly the dry-run branch you already had, and it gives the release manager a one-click answer instead of a Slack thread.
Know when neither is right. Pure data loads belong in import sets with transform maps, where you get error rows and reprocessing for free. Recurring maintenance belongs in a scheduled script execution. Fix scripts are for the one-time change that must travel with a release. If you find yourself running the same fix script every month, it stopped being a fix a while ago.