Cleaning Up ServiceNow Numbering Issues with a Reusable Counter Reconciliation Script

Cleaning Up ServiceNow Numbering Issues with a Reusable Counter Reconciliation Script

Numbering issues in ServiceNow can be awkward to diagnose and even harder to fix safely. In most cases, the symptoms are easy to spot: a new Incident, Problem, Change or Catalog Task fails to insert correctly, or users report that a record number already exists. The underlying cause is often that the platform’s number counter has fallen behind the actual data.

This article walks through a reusable approach for cleaning up numbering issues across customer instances by reconciling sys_number_counter values against existing records and, where required, remediating duplicate task numbers. The supporting script reconciles counters, identifies duplicate task.number values, preserves the oldest record, and re-numbers later duplicates using the correct table prefix and padding.

The Problem

ServiceNow uses number definitions and counters to generate record numbers such as:

INC0010001
PRB0000106
CHG0030012
SCTASK0001234

The two key tables involved are:

sys_number
sys_number_counter

sys_number defines the number format, including the prefix and padding.

sys_number_counter tracks the current counter value for a table.

When these become misaligned, ServiceNow can attempt to generate a number that already exists. This is most commonly seen after data imports, clones, manual data correction, historic custom scripts, or poorly controlled migration activity.

A simple example:

Highest existing Incident number: INC0012500
Current sys_number_counter value: 1200
Next generated Incident number: INC001201

In this scenario, the counter is behind the data. Unless corrected, the platform may continue generating numbers that already exist or are lower than expected.

Why This Matters

Record numbers are not just display values. They are often used in:

  • User communication
  • Email notifications
  • Integrations
  • Reports
  • Audit evidence
  • Operational processes
  • External references

Because of this, numbering cleanup needs to be handled carefully. The goal is not to rewrite history unnecessarily. The goal is to make the platform safe for future record creation while resolving genuine duplicate number issues.

The Approach

The reusable script follows a controlled three-stage process.

First, it checks the affected tables and reconciles each sys_number_counter to the highest existing valid record number. This prevents replacement numbers from being generated below the current maximum.

Second, it checks for duplicate values on task.number. Where duplicates are found, it preserves the oldest record and re-numbers later records.

Third, it runs a final reconciliation after duplicate remediation to make sure the counters are still aligned.

The process is deliberately cautious. The script only moves counters forward. It does not move counters backwards, even if the current counter is higher than the highest existing number.

Duplicate Number Handling

Duplicate task numbers need a sensible rule for deciding which record keeps the original number.

The script uses this approach:

Keep the oldest record.
Re-number later records.

The oldest record is determined by:

sys_created_on
sys_id

Using sys_id as a secondary sort ensures deterministic behaviour where two records have the same creation timestamp.

For example:

PRB0000106 - Problem A - Created first
PRB0000106 - Problem B - Created later

The script preserves the original number on Problem A and assigns a new, valid Problem number to Problem B.

Why the Script Uses the Table’s Number Definition

When re-numbering duplicates, it is important not to hard-code prefixes such as INC, PRB or CHG.

Different customers may have different numbering formats. Some may use custom prefixes, different padding lengths or different numbering conventions.

The script looks up the relevant sys_number definition for the target table and uses:

prefix
maximum_digits
number

This allows it to generate a replacement number that matches the table’s configured format.

For example:

Prefix: PRB
Maximum digits: 7
Counter: 106
Next number: PRB0000107

The script includes several configuration options that make it reusable across customers.

DRY_RUN

Always start with:

var DRY_RUN = true;

Dry run mode logs what would happen without updating any records.

Only switch to live mode after the output has been reviewed:

var DRY_RUN = false;

AFFECTED_TABLES

For targeted testing, restrict the scope:

var AFFECTED_TABLES = ['incident'];

For multiple known tables:

var AFFECTED_TABLES = ['incident', 'problem', 'change_request', 'sc_task'];

To process all tables with a number counter:

var AFFECTED_TABLES = [];

For customer work, I would normally start with a targeted table list in sub-production, then only broaden the scope once the dry run output is understood.

REMEDIATE_TASK_DUPLICATES

This controls whether duplicate task numbers are remediated:

var REMEDIATE_TASK_DUPLICATES = true;

If you only want to reconcile counters and not touch record numbers, set this to:

var REMEDIATE_TASK_DUPLICATES = false;

LIMIT_DUPLICATE_REMEDIATION_TO_AFFECTED_TABLES

For targeted testing, keep duplicate remediation scoped to the affected table list:

var LIMIT_DUPLICATE_REMEDIATION_TO_AFFECTED_TABLES = true;

For a wider remediation across the full task table, use:

var LIMIT_DUPLICATE_REMEDIATION_TO_AFFECTED_TABLES = false;

The safest way to use this script is to follow a structured execution process.

1. Confirm the Issue

Before running anything, confirm the symptoms.

Typical checks include:

Are new records failing to insert?
Is the generated number lower than expected?
Does the same number exist more than once?
Is the sys_number_counter lower than the highest existing record number?

You should also confirm whether the issue affects one table or multiple tables.

2. Run in Sub-Production First

Never start directly in production unless there is a clear operational reason and an approved change.

Run the script in a sub-production instance first with:

var DRY_RUN = true;

Review the logs carefully.

3. Review the Dry Run Output

The dry run should show:

Tables being processed
Current counter values
Highest existing record numbers
Counters that would be advanced
Duplicate task numbers found
Records that would be re-numbered
Proposed replacement numbers

Pay particular attention to duplicate remediation. Confirm the oldest record is the one you expect to preserve.

4. Execute in a Controlled Window

Once the dry run has been reviewed and approved, switch to:

var DRY_RUN = false;

Run the script in a controlled window, ideally with relevant support teams aware.

5. Validate New Record Creation

After execution, manually create a test record on each affected table.

For example:

Create a new Incident
Create a new Problem
Create a new Change Request
Create a new Catalog Task

Confirm that each new record receives the expected next number.

Logging

The script uses a consistent log prefix:

PLAT-28 Number Counter Reconcile:

This makes it easy to filter the system logs after execution.

Useful log messages include:

Counter behind data
DRY RUN would update sys_number_counter
Duplicate task.number values found
Preserving oldest record
DRY RUN would renumber
Renumbering
Completed

The dry run logs should be attached to the change record where this is being executed through formal change control.

Key Safeguards

There are several safeguards built into the approach.

The script validates that each table exists and has a number field before processing it.

It only moves counters forward.

It supports dry run mode.

It can be restricted to specific affected tables.

It uses the table’s own number definition when generating replacement numbers.

It preserves the oldest record where duplicates exist.

It uses setWorkflow(false) and autoSysFields(false) when applying updates, reducing the risk of triggering business rules or changing audit fields unnecessarily.

Things to Watch Out For

Number cleanup should still be treated carefully.

Before live execution, consider whether record numbers are used in external integrations or reports. If a duplicate record is re-numbered, any external system that references the old number may need to be reviewed.

Also check whether the affected table uses any custom numbering logic. If custom logic has been built outside of the standard sys_number and sys_number_counter approach, the script output should be reviewed in more detail before live execution.

For very large task tables, duplicate detection can be expensive. Where possible, start with a targeted affected table list and run during a suitable support window.

Example Use Cases

This approach is useful after:

Data migrations
Clone corrections
Manual data imports
Historic fix scripts
Custom integration defects
Number counter corruption
Duplicate task number incidents

It is particularly useful where a customer needs a repeatable and auditable cleanup method rather than a one-off manual correction.

For production execution, I would capture the following evidence:

Affected table list
Dry run logs
Before and after sys_number_counter values
List of duplicate numbers identified
List of records re-numbered
Post-execution test record numbers
Validation screenshots
Backout or recovery approach

This keeps the activity transparent and makes it much easier to support later if questions are raised.

Reusable ServiceNow Number Counter Reconciliation Script

ServiceNow Fix Script
/*
 * Reconcile sys_number_counter values against existing record numbers
 *
 * Purpose:
 * 1. For each affected table, reconcile sys_number_counter.number to the highest existing record number.
 * 2. Identify duplicate task.number values.
 * 3. Preserve the oldest created record for each duplicate number.
 * 4. Re-number later duplicate records using the correct prefix and padding from the associated sys_number record.
 * 5. Perform a final sys_number_counter reconciliation after duplicate remediation.
 *
 * Notes:
 * - Run first with DRY_RUN = true.
 * - Review logs.
 * - Run with DRY_RUN = false in a controlled window.
 * - After live execution, manually create a test record on each affected table to confirm the next number.
 */

(function executeFixScript() {

    var DRY_RUN = true;

    /*
     * Leave empty to process all tables that have a sys_number_counter.
     *
     * For targeted testing, restrict this list, for example:
     * var AFFECTED_TABLES = ['incident'];
     * var AFFECTED_TABLES = ['problem'];
     * var AFFECTED_TABLES = ['incident', 'problem', 'change_request', 'sc_task'];
     */
    var AFFECTED_TABLES = ['problem'];

    /*
     * Set to true to remediate duplicate numbers on task-derived records.
     */
    var REMEDIATE_TASK_DUPLICATES = true;

    /*
     * If AFFECTED_TABLES is populated:
     * - true  = duplicate task remediation only checks those sys_class_name values.
     * - false = duplicate task remediation checks the full task table.
     *
     * Recommendation:
     * - Use true for targeted dry runs.
     * - Use false or leave AFFECTED_TABLES empty for final full remediation.
     */
    var LIMIT_DUPLICATE_REMEDIATION_TO_AFFECTED_TABLES = true;

    var LOG_PREFIX = 'Number Counter Reconcile: ';

    var countersTouched = {};
    var simulatedCounters = {};

    gs.info(LOG_PREFIX + 'Started. DRY_RUN=' + DRY_RUN);

    var tables = getCounterTables();

    gs.info(LOG_PREFIX + 'Tables with counters to process: ' + tables.join(', '));

    /*
     * Step 1:
     * Move counters forward before duplicate remediation.
     * This ensures any generated replacement number starts after the current maximum.
     */
    for (var i = 0; i < tables.length; i++) {
        reconcileCounterToExistingMax(tables[i], 'initial');
    }

    /*
     * Step 2:
     * Fix duplicate task numbers.
     */
    if (REMEDIATE_TASK_DUPLICATES) {
        remediateDuplicateTaskNumbers();
    }

    /*
     * Step 3:
     * Final reconciliation after duplicate remediation.
     */
    for (var j = 0; j < tables.length; j++) {
        reconcileCounterToExistingMax(tables[j], 'final');
    }

    gs.info(LOG_PREFIX + 'Completed. DRY_RUN=' + DRY_RUN);


    function getCounterTables() {
        var result = [];
        var seen = {};

        var gr = new GlideRecord('sys_number_counter');
        gr.addNotNullQuery('table');

        if (AFFECTED_TABLES && AFFECTED_TABLES.length > 0) {
            gr.addQuery('table', 'IN', AFFECTED_TABLES.join(','));
        }

        gr.orderBy('table');
        gr.query();

        while (gr.next()) {
            var tableName = gr.getValue('table');

            if (!tableName || seen[tableName]) {
                continue;
            }

            if (!isValidNumberedTable(tableName)) {
                gs.warn(LOG_PREFIX + 'Skipping invalid or non-numbered table: ' + tableName);
                continue;
            }

            seen[tableName] = true;
            result.push(tableName);
        }

        return result;
    }


    function isValidNumberedTable(tableName) {
        var gr = new GlideRecord(tableName);

        if (!gr.isValid()) {
            return false;
        }

        if (!gr.isValidField('number')) {
            return false;
        }

        return true;
    }


    function getNumberDefinition(tableName) {
        var def = {
            table: tableName,
            prefix: '',
            maximumDigits: 0,
            startNumber: 0,
            found: false
        };

        var sn = new GlideRecord('sys_number');

        var qc = sn.addQuery('category', tableName);
        qc.addOrCondition('category.name', tableName);

        sn.orderByDesc('sys_updated_on');
        sn.setLimit(1);
        sn.query();

        if (sn.next()) {
            def.prefix = sn.getValue('prefix') || '';
            def.maximumDigits = parseInt(sn.getValue('maximum_digits'), 10) || 0;
            def.startNumber = parseInt(cleanNumeric(sn.getValue('number')), 10) || 0;
            def.found = true;
        } else {
            gs.warn(LOG_PREFIX + 'No sys_number definition found for table=' + tableName + '. Prefix/padding may be blank.');
        }

        return def;
    }


    function getCounterRecord(tableName) {
        var counter = new GlideRecord('sys_number_counter');
        counter.addQuery('table', tableName);
        counter.setLimit(1);
        counter.query();

        if (counter.next()) {
            return counter;
        }

        return null;
    }


    function reconcileCounterToExistingMax(tableName, phase) {
        var def = getNumberDefinition(tableName);
        var counter = getCounterRecord(tableName);

        if (!counter) {
            gs.warn(LOG_PREFIX + '[' + phase + '] No sys_number_counter found for table=' + tableName);
            return;
        }

        var currentCounter = parseInt(cleanNumeric(counter.getValue('number')), 10) || 0;
        var maxExisting = getMaxExistingNumber(tableName, def);

        if (maxExisting === null) {
            gs.info(LOG_PREFIX + '[' + phase + '] No existing numbered records found for table=' + tableName + '. Counter remains ' + currentCounter);
            return;
        }

        /*
         * In dry run, respect simulated counter movement caused by duplicate remediation.
         */
        if (DRY_RUN && simulatedCounters[tableName] && simulatedCounters[tableName] > currentCounter) {
            currentCounter = simulatedCounters[tableName];
        }

        if (currentCounter >= maxExisting) {
            gs.info(LOG_PREFIX + '[' + phase + '] OK table=' + tableName + ', counter=' + currentCounter + ', maxExisting=' + maxExisting);
            return;
        }

        gs.warn(LOG_PREFIX + '[' + phase + '] Counter behind data. table=' + tableName + ', counter=' + currentCounter + ', maxExisting=' + maxExisting);

        if (DRY_RUN) {
            simulatedCounters[tableName] = maxExisting;
            gs.warn(LOG_PREFIX + '[' + phase + '] DRY RUN would update sys_number_counter for table=' + tableName + ' to ' + maxExisting);
        } else {
            counter.setWorkflow(false);
            counter.autoSysFields(false);
            counter.setValue('number', maxExisting);
            counter.update();

            countersTouched[tableName] = true;

            gs.warn(LOG_PREFIX + '[' + phase + '] Updated sys_number_counter for table=' + tableName + ' to ' + maxExisting);
        }
    }


    function getMaxExistingNumber(tableName, def) {
        var max = null;

        var gr = new GlideRecord(tableName);
        gr.addNotNullQuery('number');

        if (def.prefix) {
            gr.addQuery('number', 'STARTSWITH', def.prefix);
        }

        gr.query();

        while (gr.next()) {
            var parsed = parseExistingNumber(gr.getValue('number'), def.prefix);

            if (parsed === null) {
                continue;
            }

            if (max === null || parsed > max) {
                max = parsed;
            }
        }

        return max;
    }


    function remediateDuplicateTaskNumbers() {
        gs.info(LOG_PREFIX + 'Starting duplicate task.number remediation.');

        var duplicateNumbers = getDuplicateTaskNumbers();

        gs.info(LOG_PREFIX + 'Duplicate task.number values found: ' + duplicateNumbers.length);

        for (var i = 0; i < duplicateNumbers.length; i++) {
            remediateSingleTaskNumber(duplicateNumbers[i]);
        }

        gs.info(LOG_PREFIX + 'Completed duplicate task.number remediation.');
    }


    function getDuplicateTaskNumbers() {
        var duplicates = [];

        var agg = new GlideAggregate('task');
        agg.addNotNullQuery('number');

        if (
            LIMIT_DUPLICATE_REMEDIATION_TO_AFFECTED_TABLES &&
            AFFECTED_TABLES &&
            AFFECTED_TABLES.length > 0
        ) {
            agg.addQuery('sys_class_name', 'IN', AFFECTED_TABLES.join(','));
        }

        agg.addAggregate('COUNT');
        agg.groupBy('number');
        agg.query();

        while (agg.next()) {
            var count = parseInt(agg.getAggregate('COUNT'), 10) || 0;

            if (count > 1) {
                duplicates.push(agg.getValue('number'));
            }
        }

        return duplicates;
    }


    function remediateSingleTaskNumber(duplicateNumber) {
        var records = [];

        var gr = new GlideRecord('task');
        gr.addQuery('number', duplicateNumber);

        if (
            LIMIT_DUPLICATE_REMEDIATION_TO_AFFECTED_TABLES &&
            AFFECTED_TABLES &&
            AFFECTED_TABLES.length > 0
        ) {
            gr.addQuery('sys_class_name', 'IN', AFFECTED_TABLES.join(','));
        }

        gr.orderBy('sys_created_on');
        gr.orderBy('sys_id');
        gr.query();

        while (gr.next()) {
            records.push({
                sys_id: gr.getUniqueValue(),
                sys_class_name: gr.getValue('sys_class_name') || 'task',
                sys_created_on: gr.getValue('sys_created_on'),
                number: gr.getValue('number')
            });
        }

        if (records.length <= 1) {
            return;
        }

        var keeper = records[0];

        gs.warn(
            LOG_PREFIX +
            'Duplicate number=' + duplicateNumber +
            '. Preserving oldest record sys_id=' + keeper.sys_id +
            ', class=' + keeper.sys_class_name +
            ', created=' + keeper.sys_created_on
        );

        for (var i = 1; i < records.length; i++) {
            var dup = records[i];
            var targetTable = dup.sys_class_name || 'task';

            if (!isValidNumberedTable(targetTable)) {
                gs.warn(LOG_PREFIX + 'Cannot renumber duplicate sys_id=' + dup.sys_id + '. Invalid numbered table=' + targetTable);
                continue;
            }

            var newNumber = reserveNextNumber(targetTable);

            if (!newNumber) {
                gs.warn(LOG_PREFIX + 'Could not reserve new number for duplicate sys_id=' + dup.sys_id + ', table=' + targetTable);
                continue;
            }

            gs.warn(
                LOG_PREFIX +
                (DRY_RUN ? 'DRY RUN would renumber' : 'Renumbering') +
                ' sys_id=' + dup.sys_id +
                ', table=' + targetTable +
                ', oldNumber=' + duplicateNumber +
                ', newNumber=' + newNumber
            );

            if (!DRY_RUN) {
                var rec = new GlideRecord(targetTable);

                if (!rec.get(dup.sys_id)) {
                    gs.warn(LOG_PREFIX + 'Could not open duplicate record on table=' + targetTable + ', sys_id=' + dup.sys_id);
                    continue;
                }

                rec.setWorkflow(false);
                rec.autoSysFields(false);
                rec.setValue('number', newNumber);
                rec.update();
            }
        }
    }


    function reserveNextNumber(tableName) {
        var def = getNumberDefinition(tableName);
        var counter = getCounterRecord(tableName);

        if (!counter) {
            gs.warn(LOG_PREFIX + 'No sys_number_counter exists for table=' + tableName + '. Cannot reserve next number.');
            return '';
        }

        /*
         * Ensure this table counter is not behind before reserving a number.
         */
        reconcileCounterToExistingMax(tableName, 'pre-reserve');

        counter = getCounterRecord(tableName);

        var currentCounter = parseInt(cleanNumeric(counter.getValue('number')), 10) || 0;

        /*
         * In dry run, simulate previous counter movement so multiple duplicate records
         * do not all report the same proposed number.
         */
        if (DRY_RUN && simulatedCounters[tableName] && simulatedCounters[tableName] > currentCounter) {
            currentCounter = simulatedCounters[tableName];
        }

        var nextCounter = currentCounter + 1;
        var nextNumber = formatNumber(nextCounter, def.prefix, def.maximumDigits);

        if (DRY_RUN) {
            simulatedCounters[tableName] = nextCounter;

            gs.warn(
                LOG_PREFIX +
                'DRY RUN would advance sys_number_counter for table=' + tableName +
                ' from ' + currentCounter +
                ' to ' + nextCounter
            );
        } else {
            counter.setWorkflow(false);
            counter.autoSysFields(false);
            counter.setValue('number', nextCounter);
            counter.update();

            countersTouched[tableName] = true;

            gs.warn(
                LOG_PREFIX +
                'Advanced sys_number_counter for table=' + tableName +
                ' from ' + currentCounter +
                ' to ' + nextCounter
            );
        }

        return nextNumber;
    }


    function parseExistingNumber(value, prefix) {
        if (!value) {
            return null;
        }

        value = String(value);

        if (prefix) {
            if (value.indexOf(prefix) !== 0) {
                return null;
            }

            value = value.substring(prefix.length);
        }

        value = cleanNumeric(value);

        if (!/^\d+$/.test(value)) {
            return null;
        }

        return parseInt(value, 10);
    }


    function formatNumber(number, prefix, maximumDigits) {
        var numeric = String(number);

        if (maximumDigits && maximumDigits > 0) {
            while (numeric.length < maximumDigits) {
                numeric = '0' + numeric;
            }
        }

        return (prefix || '') + numeric;
    }


    function cleanNumeric(value) {
        if (value === null || value === undefined) {
            return '';
        }

        return String(value).replace(/,/g, '').trim();
    }

})();

Summary

Numbering issues can create operational noise and user confidence issues, especially when new records start colliding with existing numbers. The safest remediation pattern is to reconcile counters first, remediate duplicates in a controlled way, and then validate that future record creation works as expected.

The reusable script provides a practical way to do that across customer instances. It supports dry run mode, targeted table execution, duplicate task remediation and final reconciliation, making it suitable for both investigation and controlled production cleanup.

The key rule is simple: run it dry first, review the output, then remediate in a controlled window.