Undoing a Change on a ServiceNow Instance
You overwrote a Business Rule script, or a widget's server script, or a Script Include, and the new version is wrong. Do not reconstruct the old code from memory. The instance already kept a copy of the record as it was before you touched it, in the sys_update_version table. Recovering it is a read, a copy, and a write — no guessing.
The rest of this guide is about getting that read right, and about the three or four cases where no read will save you.
Which mechanism undoes what
| Mechanism | Undoes | Driven from |
|---|---|---|
Version records (sys_update_version) | One configuration record, field by field | This MCP (read the payload, write the field back), or the UI's Revert to this version |
| Back out an update set | Every change captured in one committed update set, including dictionary changes | ServiceNow UI only — there is no tool for it here |
Deleted Records (System Definition > Deleted Records) | One deleted record plus its cascaded deletes, on audited tables | ServiceNow UI only |
| Nothing | Emails already sent, flows already run, data destroyed by a dropped column | — |
Pick the smallest one that covers the damage. Reverting one field of one record is safe and boring. Backing out an update set touches everything in it.
Step 0: delete nothing
Two instincts are wrong and both are expensive.
Deleting the update set does not undo anything. sys_update_set is a container; the changes themselves are rows in sp_widget, sys_script, sys_script_include and friends, and the record of each change is a sys_update_xml row pointing at the set. Deleting the set record leaves every changed artifact exactly as it is, and destroys the only inventory of what changed — the inventory a real back-out needs. You are strictly worse off than before.
Deleting the artifact is not a rollback either. If the record existed before your change, deleting it turns a bad script into a missing script, plus every reference to its sys_id now dangles.
Setting an update set to state ignore is also not an undo: it stops the set from being promoted, and changes nothing on the instance it was built on.
Step 1: establish exactly what you changed
Before restoring anything, get the list. Two independent sources:
javascript// Configuration writes captured while your update set was current. // Reads sys_update_xml and returns the rows grouped by type: // name, target, action, updated_at, updated_by. snow_update_set_manage({ action: "preview", update_set_id: "<sys_id>" }) // Table API writes in a time window, from sys_audit. snow_inspect_mutations({ since: "30m", tables: ["sp_widget", "sys_script"] })
snow_update_set_manage action="preview" is the more trustworthy of the two for configuration: it reads what the platform recorded, one row per changed record. Pass include_payload: true only when you intend to read a payload — the rows are large. It reads at most 1000 sys_update_xml rows and tells you nothing when there are more, so on a big set treat the list as a floor rather than an inventory.
snow_inspect_mutations reads sys_audit, which stores truncated values (the tool warns you when a value hits the 255-character mark). An audit row is a diagnostic, never a restore source. Reconstructing a script from oldvalue gives you the first 255 characters of it and no warning that the rest is missing. That is the mistake this guide exists to prevent.
Flow Designer mutations are not recorded in sys_audit, so snow_inspect_mutations will not show them — see the debugging-mutations guide for the verification path that does work.
Step 2: find the version records
The Update Versions
[sys_update_version]table contains records that represent the state of a customizable object at a particular time.A new version record is created automatically whenever a user changes a customizable record or changes the application file for the customizable record.
— ServiceNow product documentation, Version records
That includes changes made through the Table API by this server's service account, and the platform does it without being asked. The row your write created holds the state after it and is now Current; the state you destroyed is in the row that was Current a moment earlier and has been demoted to Previous. Nothing was lost — you just have to pick the right row.
Version rows are keyed by update name, which for most records is <table>_<sys_id>. Query on the sys_id and let LIKE handle the prefix:
javascript// 1. List the candidates WITHOUT payloads. Cheap, readable. snow_query_table({ table: "sys_update_version", query: "nameLIKE<record_sys_id>^ORDERBYDESCsys_created_on", fields: ["name", "state", "source", "sys_created_on", "sys_created_by"], limit: 10, })
If that returns nothing, the record's updates are not keyed by its sys_id. Open the record in the ServiceNow UI and use its Versions related list instead (right-click a version for Compare to Current and Revert to this version).
Choosing the right row
- The newest row is the state you just wrote.
stateisCurrent. - The row below it is the state before your last write — not necessarily the state before your session. If you saved three times, you want the fourth row down.
stateshowsCurrent,PreviousorHistory.Historymeans the version was never loaded on this instance. Restoring aHistorypayload is not a rollback; it is installing something that never ran here. Read the column rather than filtering on it — the label you see and the value stored in the column are not guaranteed to be the same string.sourcesays how the version arrived, and has three documented values:System Upgrade(the baseline version an upgrade shipped),Update Set(created or committed on this instance),Pull History(a Team Development pull). It does not tell you whose change it was — yours and a colleague's both land asUpdate Set. That is whatsys_created_byis for, which the query above already selects.sys_created_onhas one-second resolution. Two writes in the same second sort arbitrarily. Confirm withsys_created_byand the payload itself before you trust the order.
Step 3: read the payload, and read it whole
javascript// 2. Fetch exactly one payload, untruncated. snow_query_table({ table: "sys_update_version", query: "sys_id=<version_sys_id>", fields: ["payload"], truncate_output: false, })
truncate_output defaults to true, and payload is on this tool's large-content list. Without truncate_output: false you get the first 200 characters followed by ... [truncated, 41216 chars total]. That stub looks like data. An agent that pastes it into a restore has just replaced a 41k script with a 200-character fragment, and every tool in the chain will report success.
The payload is ServiceNow's update XML:
xml<record_update table="sys_script_include"> <sys_script_include action="INSERT_OR_UPDATE"> <access>public</access> <active>true</active> <api_name>global.IncidentUtils</api_name> <name>IncidentUtils</name> <script><![CDATA[var IncidentUtils = Class.create(); IncidentUtils.prototype = { initialize: function() {}, type: 'IncidentUtils' };]]></script> <sys_id>7f3c9b2a4f8a1200a1b2c3d4e5f6a7b8</sys_id> <sys_mod_count>12</sys_mod_count> <sys_updated_on>2026-08-14 09:41:22</sys_updated_on> </sys_script_include> </record_update>
Three rules for handling it:
- Take only the field you broke. One element, usually
script/template/client_script/css. - Unwrap it. Script and other long fields are wrapped in
<![CDATA[ ... ]]>— strip the wrapper, keep the content verbatim including leading whitespace. Shorter values are XML-escaped instead; decode<>&"'before writing them back. Writing<into a script field ships a broken script that compiles as garbage. - Never turn the whole payload into a field map and write that. It carries
sys_id,sys_mod_count,sys_created_on,sys_updated_by— none of which you want in an update body.
Step 4: write the field back
You will hit two guards before the write lands, and both are working as intended:
- No active update set:
"snow_artifact_manage" is a configuration write but no update set is active for this session…. Callsnow_ensure_active_update_set({ name: "Fix: revert IncidentUtils to pre-<date> version" })once, then retry. - Production instance:
…is a write against a PRODUCTION ServiceNow instance and is blocked by default.Show the user the exact restore you intend to make, and only after they approve, re-issue the identical call with"__confirmProd": true.
javascriptsnow_artifact_manage({ action: "update", type: "script_include", sys_id: "<record_sys_id>", script: "<decoded contents of the payload's script element, verbatim>", })
Two traps in this tool's update path:
Only a fixed set of keys is read inline: script, template, server_script, client_script, css, option_schema, description, active. Anything else you need to restore — condition, when, order, filter_condition, collection, role_conditions — is ignored unless you put it in config:
javascriptsnow_artifact_manage({ action: "update", type: "business_rule", sys_id: "<sys_id>", config: { script: "<restored script>", condition: "current.state == 6" }, })
For a Service Portal widget, restore script, not server_script. On action: "create" the tool maps server_script onto the widget's script column. On action: "update" it does not: server_script goes into the PATCH body under that name, sp_widget has no such column, and you get back updated: true with updated_fields: ["server_script"] while the widget's server script is untouched. The widget's body columns are template, script, client_script, css, option_schema.
Verify, do not trust the success object
javascriptsnow_query_table({ table: "sp_widget", query: "sys_id=<sys_id>", fields: ["script"], truncate_output: false, })
Compare the first and last lines against the payload you extracted. Character counts can differ legitimately: the update path runs every string through a sanitiser first, which folds CRLF and lone CR to LF and strips C0/C1 control characters and zero-width characters. A payload with Windows line endings comes back shorter by one byte per line and is still a correct restore. A payload that comes back with different lines is not.
If you pulled the artifact to disk before editing (stdio only), snow_artifact_manage({ action: "verify", ... }) does the comparison for you and reports the first differing line — but pass the files explicitly as script_file / template_file. artifact_directory looks for server.js and template.html, while snow_pull_artifact writes <name>.server.js and <name>.html, so pointing verify at a pulled widget directory resolves nothing and errors out. Verify also trims both sides before comparing, so a difference in leading or trailing whitespace is reported as a match.
Your restore is itself a tracked change: it creates a new version row and lands in the currently active update set. That is correct and desirable — if the bad change was already promoted, the target instance needs the same undo, and now it can be shipped the same way.
When the Table API will not take the field
A few reference fields are silently dropped by the Table API (sys_ui_policy_action.ui_policy is the known one) — the write returns 200 and the field is unchanged. For those, snow_execute_script runs real ES5 server-side and a GlideRecord write goes through. Use it only after a plain update has demonstrably failed, and keep the script ES5.
The UI's own revert
For anything you would rather not reassemble by hand, the platform does it: open the record's Versions related list, right-click the version, Revert to this version, confirm. The current version becomes a previous version and a new version duplicating the selected one is created. Documented limitation: you can revert to the most recent baseline version, but not to an older baseline version. There is no REST endpoint for this action and no tool here wraps it — when the record is a flow, hand the user this path rather than writing sys_hub_* rows yourself.
Do not use snow_rollback_deployment
It reads like the tool for this job. It is not: every way it fails is silent, and you find out when someone runs the code.
action: "revert"can corrupt the record and report success. It parses the version payload with/<(\w+)>([^<]*)<\/\1>/g. That pattern cannot cross a<, so a CDATA-wrapped field (<script><
