Ch. 17 · ServiceNow

ServiceNow GlideRecord Queries and Bounded Results

Write GlideRecord queries that fetch only what you need: catch invalid fields, stop encoded-query injection, avoid N+1 lookups and use setLimit.

~8 min readintermediateupdated Oct 12, 2026

GlideRecord is the first server API every ServiceNow developer learns, and it’s forgiving in ways that hurt later. A typo in a field name doesn’t throw. A user-supplied value can change the shape of the query. A lookup inside a loop works fine with ten records and makes a business rule take eight seconds with ten thousand. Interviewers ask about GlideRecord to find out whether you know those failure modes, not whether you remember addQuery.

Anatomy of a query

A query is built up, run, then iterated:

var gr = new GlideRecord('incident');
gr.addQuery('active', true);                 // field, value  (operator defaults to =)
gr.addQuery('priority', '<=', 2);            // field, operator, value
gr.addEncodedQuery('assignment_group=287ebd7da9fe198100f92cc8d1d2154e');
gr.orderByDesc('sys_created_on');
gr.setLimit(100);
gr.query();
while (gr.next()) {
    gs.info(gr.getValue('number') + ' ' + gr.getDisplayValue('assignment_group'));
}
JavaScript

Conditions are ANDed. addOrCondition on the object returned by addQuery, or ^OR inside an encoded query, adds alternatives. An encoded query is the same string you get from a list filter’s “Copy query”, which makes it easy to prototype a filter in the UI. get(sysId) fetches one record; getValue() returns the stored string value, while dot-walking (gr.caller_id.manager) follows references. Remember that GlideRecord doesn’t apply ACLs; when the code acts on behalf of a user, use GlideRecordSecure.

A mock to make the costs visible

Without an instance, the behaviours worth discussing can still be demonstrated. This Node.js file is a minimal GlideRecord look-alike written for this article. It isn’t ServiceNow code, but it copies two real platform behaviours: conditions on unknown fields are dropped unless the glide.invalid_query.returns_no_rows property is on, and encoded queries treat ^OR as “or”. It also counts queries and rows returned. Save it as gliderecord-mock.js:

// A minimal GlideRecord look-alike over in-memory tables. It counts queries and rows read,
// and mimics two platform behaviours: invalid conditions are dropped (unless the
// glide.invalid_query.returns_no_rows property is on) and encoded queries understand ^OR.
const tables = {
  incident: Array.from({ length: 2000 }, (_, i) => ({ sys_id: `inc${i}`, number: `INC${10000 + i}`,
    active: i % 4 !== 0, caller_id: `usr${i % 50}`, state: i % 7 === 0 ? 1 : 2 })),
  sys_user: Array.from({ length: 50 }, (_, i) => ({ sys_id: `usr${i}`, name: `User ${i}`, manager: `usr${(i + 1) % 50}` })),
};
const stats = { queries: 0, rowsReturned: 0 };
const props = { 'glide.invalid_query.returns_no_rows': false };

class GlideRecord {
  constructor(t) { this.t = t; this.groups = [[]]; this.limit = Infinity; }
  addQuery(f, op, v) { if (v === undefined) [op, v] = ['=', op]; this.groups[this.groups.length - 1].push({ f, op, v: String(v) }); }
  addEncodedQuery(q) {
    q.split('^').forEach((part) => {
      const or = part.startsWith('OR'); if (or) { part = part.slice(2); this.groups.push([]); }
      const m = part.match(/^(\w+?)(IN|!=|=)(.*)$/); if (m) this.addQuery(m[1], m[2], m[3]);
    });
  }
  setLimit(n) { this.limit = n; }
  query() {
    stats.queries++;
    const fields = Object.keys(tables[this.t][0]);
    const test = (c, r) => c.op === 'IN' ? c.v.split(',').includes(String(r[c.f])) : c.op === '!=' ? String(r[c.f]) !== c.v : String(r[c.f]) === c.v;
    let invalid = false;
    const groups = this.groups.map((g) => g.filter((c) => fields.includes(c.f) || ((invalid = true), false)));
    if (invalid && props['glide.invalid_query.returns_no_rows']) { this.rows = []; this.i = -1; return; }
    this.rows = [];
    for (const r of tables[this.t]) {                       // scan until the limit is reached
      if (groups.some((g) => g.every((c) => test(c, r)))) { this.rows.push(r); stats.rowsReturned++; }
      if (this.rows.length >= this.limit) break;
    }
    this.i = -1;
  }
  next() { return ++this.i < this.rows.length; }
  getValue(f) { return this.rows[this.i][f]; }
  get(id) { this.addQuery('sys_id', id); this.setLimit(1); this.query(); return this.next(); }
}
const reset = () => { stats.queries = 0; stats.rowsReturned = 0; };
const count = (gr) => { let n = 0; while (gr.next()) n++; return n; };
module.exports = { GlideRecord, stats, props, reset, count };
JavaScript

Four bugs, measured

This script, saved next to it and run with node, exercises the mock with four patterns that turn up in real code reviews:

const { GlideRecord, stats, props, reset, count } = require('./gliderecord-mock.js');

// 1. A typo in a field name
let gr = new GlideRecord('incident');
gr.addQuery('actve', 'true');          // should be 'active'
gr.query();
console.log('typo, default behaviour:      ', count(gr), 'rows');
props['glide.invalid_query.returns_no_rows'] = true;
gr = new GlideRecord('incident'); gr.addQuery('actve', 'true'); gr.query();
console.log('typo, returns_no_rows = true: ', count(gr), 'rows');

// 2. User input concatenated into an encoded query
const input = 'usr7^ORactive=true';     // what an attacker types instead of a sys_id
gr = new GlideRecord('incident'); gr.addEncodedQuery('caller_id=' + input); gr.query();
console.log('concatenated input:           ', count(gr), 'rows (caller usr7 has 40)');
gr = new GlideRecord('incident'); gr.addQuery('caller_id', input); gr.query();
console.log('same input via addQuery:      ', count(gr), 'rows');

// 3. Manager of each open incident's caller: N+1 versus one IN query
reset();
gr = new GlideRecord('incident'); gr.addQuery('state', 1); gr.query();
while (gr.next()) { const u = new GlideRecord('sys_user'); u.get(gr.getValue('caller_id')); }
console.log('N+1 lookups:                  ', stats.queries, 'queries');
reset();
gr = new GlideRecord('incident'); gr.addQuery('state', 1); gr.query();
const ids = new Set(); while (gr.next()) ids.add(gr.getValue('caller_id'));
const users = new GlideRecord('sys_user'); users.addQuery('sys_id', 'IN', [...ids].join(',')); users.query();
console.log('one IN query:                 ', stats.queries, 'queries for', ids.size, 'distinct callers');

// 4. "Is there any active incident for usr7?"
reset(); gr = new GlideRecord('incident'); gr.addQuery('caller_id', 'usr7'); gr.addQuery('active', 'true'); gr.query();
console.log('existence check, no limit:     rows returned =', stats.rowsReturned);
reset(); gr = new GlideRecord('incident'); gr.addQuery('caller_id', 'usr7'); gr.addQuery('active', 'true'); gr.setLimit(1); gr.query();
console.log('existence check, setLimit(1): rows returned =', stats.rowsReturned);
JavaScript
typo, default behaviour:       2000 rows
typo, returns_no_rows = true:  0 rows
concatenated input:            1500 rows (caller usr7 has 40)
same input via addQuery:       0 rows
N+1 lookups:                   287 queries
one IN query:                  2 queries for 50 distinct callers
existence check, no limit:     rows returned = 40
existence check, setLimit(1): rows returned = 1
Text

A misspelled field returns everything

actve isn’t a field, and by default the platform drops the invalid condition and runs the rest of the query. Here that leaves no conditions at all, so all 2,000 incidents come back. In a read that’s a performance bug. Followed by deleteMultiple() or a loop of updates, it’s an incident of its own. Setting the system property glide.invalid_query.returns_no_rows to true makes invalid queries return nothing instead, which is the safer failure, and the system log records an invalid query warning naming the unknown field. Test the property in sub-production first, because it will expose existing broken queries that were silently returning too much.

Concatenated input changes the query

The second case builds caller_id= plus a value from outside: a Scripted REST parameter, a GlideAjax sysparm_ value, an inbound email. The value usr7^ORactive=true turns “incidents for usr7” into “incidents for usr7 or any active incident”, 1,500 rows instead of 40. In the mock, addQuery treats the same input as one value for one condition and finds nothing. On the platform, the rule is the same: pass outside input through addQuery, never by concatenating it into an encoded query, and validate its format first (a sys_id is 32 hex characters).

One lookup per row

Looking up each caller inside the loop costs one query per incident: 287 queries for 286 open incidents. Collecting the distinct caller sys_ids and fetching them with a single IN condition costs two. Dot-walking (gr.caller_id.manager) reads more nicely but still fetches each referenced record as you touch it, so in a hot loop over many rows the batched query is the one to reach for. A related trap is calling getRowCount() just to learn a count; use GlideAggregate for that.

Existence checks that fetch everything

“Does this caller have any active incident?” only needs one row. Without a limit, the query returns all 40 matches; with setLimit(1) it returns one. The habit is cheap and adds up in business rules that run on every save:

var open = new GlideRecord('incident');
open.addQuery('caller_id', callerId);
open.addActiveQuery();
open.setLimit(1);
open.query();
var hasOpenIncident = open.hasNext();
JavaScript

Building filters from outside input

When callers need to choose a filter (an API consumer asking for “open” or “resolved” tickets), give them names, not query syntax. Map each allowed name to a condition you wrote, reject everything else, and cap the result size. An illustrative Scripted REST resource (not run on an instance):

// GET /api/x_acme/support/incidents?status=open
(function process(request, response) {
    var FILTERS = {
        open:     'active=true',
        resolved: 'state=6',
        p1:       'active=true^priority=1'
    };
    var status = String(request.queryParams.status || 'open');
    if (!FILTERS.hasOwnProperty(status)) {
        response.setStatus(400);
        return { error: 'status must be one of: ' + Object.keys(FILTERS).join(', ') };
    }
    var gr = new GlideRecordSecure('incident');
    gr.addEncodedQuery(FILTERS[status]);          // only strings we wrote ourselves
    gr.addQuery('caller_id', gs.getUserID());     // scope comes from the session, not the request
    gr.orderByDesc('sys_created_on');
    gr.setLimit(50);
    gr.query();
    var out = [];
    while (gr.next()) {
        out.push({ number: gr.getValue('number'), short_description: gr.getValue('short_description') });
    }
    return { result: out };
})(request, response);
JavaScript

Three decisions in there are worth saying out loud in an interview: the encoded queries are constants, so input can only pick one; ownership comes from gs.getUserID(), so a caller can’t ask for someone else’s tickets by changing a parameter; and setLimit(50) with a stable sort bounds the response even if the filter matches 100,000 rows. Paging, if needed, should use chooseWindow() with the same sort rather than letting the client fetch everything.

Other habits that keep queries bounded

  • Filter on indexed fields. Conditions on active, state, references and dates are usually cheap. CONTAINS on a long text field like short_description can’t use a normal index and gets slower as the table grows; for text search, use the platform’s search features.
  • Ask for the rows, not the table. Every unfiltered query() on a large table in a business rule is a future performance ticket. If the code needs “the latest one”, sort and setLimit(1).
  • Consider GlideQuery for new server code. The newer GlideQuery API throws an error when you reference a field that doesn’t exist, instead of silently dropping the condition, and its chained style makes limits and selected fields explicit.

Interview practice

A nightly script is meant to close resolved incidents older than seven days. It contains gr.addQuery('state', 6); gr.addQuery('resolved_at_date', '<', gs.daysAgo(7)); gr.query(); followed by a loop that sets state to 7 and updates. The first night, it closed thousands of incidents that had been resolved that same afternoon. What happened, and how do you make the script safe?

Answer

resolved_at_date isn’t a field on incident (the field is resolved_at), so with default settings the platform dropped that condition and ran the query with only state=6. Every resolved incident matched, regardless of age, and the loop closed them all. Nothing threw an error because an invalid condition isn’t an exception by default.

To fix the script: use the correct field, and check field names with gr.isValidField('resolved_at') before running a bulk operation, failing loudly if it returns false. Turn on glide.invalid_query.returns_no_rows so a future typo returns zero rows instead of all of them (after testing what else that changes). Make bulk jobs defensive by design: log the count that matched before changing anything, abort if the count exceeds an expected ceiling, and process in bounded batches with setLimit. Writing new jobs with GlideQuery, which rejects unknown fields, removes the whole class of bug. Finally, the affected incidents need reopening to Resolved, which the audit history makes possible if you can identify the run.

More in ServiceNow

read ✓ServiceNow · mid

ServiceNow GlideAggregate for Summary Queries

Count and summarise ServiceNow records in the database with GlideAggregate instead of looping over every row, and avoid its string-typed results.

~6 min readread →
read ✓ServiceNow · hard

ServiceNow Caching and Invalidation

What a ServiceNow instance caches on each node, why a system property change triggers a costly flush, and where changing settings belong.

~7 min readread →
esc