The query cookbook: every GlideRecord pattern worth memorizing
One page of GlideRecord patterns done right: encoded queries, OR conditions without surprises, GlideAggregate, pagination, and the security layer everyone forgets.
The problem
Nobody sits down to learn GlideRecord. You absorb it one Stack Overflow answer at a time, and the result is a personal dialect full of habits you picked up from whoever wrote the first business rule you ever read. Then one day your "quick count" of a ten-million-row table takes the instance out for a coffee break, or an OR condition quietly matches half the records you meant to exclude. I keep one mental page of query patterns that I trust, and this recipe is that page written down.
The quick version
The everyday shapes, all correct:
// one record by a stable field, never by pasted sys_id
var inc = new GlideRecord('incident');
if (inc.get('number', 'INC0010005'))
gs.info(inc.getDisplayValue('assignment_group'));
// filtered set with operators, sorted, capped
var gr = new GlideRecord('incident');
gr.addQuery('active', true);
gr.addQuery('priority', '<=', 2);
gr.addNotNullQuery('assigned_to');
gr.orderByDesc('sys_updated_on');
gr.setLimit(50);
gr.query();
while (gr.next()) {
gs.info(gr.getValue('number'));
}
// count without fetching anything
var ga = new GlideAggregate('incident');
ga.addQuery('active', true);
ga.addAggregate('COUNT');
ga.query();
var openCount = ga.next() ? parseInt(ga.getAggregate('COUNT'), 10) : 0;
Everything else in this recipe is a variation on those three.
Step by step
One record: get(). Two forms. gr.get(id) when a sys_id arrives at runtime from a reference field or a parameter, and gr.get('number', 'INC0010005') when you're looking something up by a stable business key. It queries, positions on the first match, and returns a boolean, so always guard with if. What you should not do is hardcode a sys_id you copied from a URL: it will be different in the next instance you migrate to, and the failure will be silent.
Filters: addQuery and friends. Two arguments means equals. Three arguments puts an operator in the middle: >=, !=, IN, STARTSWITH, CONTAINS, and the rest. Stack multiple addQuery calls and they join with AND. For empty and not-empty, use addNullQuery('assigned_to') and addNotNullQuery('assigned_to') rather than comparing against an empty string, because NULL and empty string are not the same thing at the database layer and the platform handles the difference for you.
Steal your encoded queries. For anything with more than three conditions, stop composing addQuery chains and use addEncodedQuery. The trick that makes this painless: build the filter in a list view until it returns exactly what you want, then right-click the last breadcrumb condition and choose "Copy query". That string is the exact encoded query the platform ran, dot-walks, ORs and all.
var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true^priority<=2^assignment_group.nameSTARTSWITHNetwork');
gr.query();
Test-drive encoded queries in reverse too: paste one into a list URL as sysparm_query and eyeball the rows before you trust it in a script. The list is your free query debugger.
OR conditions without surprises. addOrCondition hangs off the query condition object that addQuery returns, and it ORs against that condition only:
var gr = new GlideRecord('incident');
var qc = gr.addQuery('priority', 1);
qc.addOrCondition('priority', 2);
gr.addQuery('active', true);
gr.query();
// (priority=1 OR priority=2) AND active=true
The trap is chaining it onto the wrong condition. gr.addQuery('active', true).addOrCondition('priority', 1) reads fine and means "active, or any P1 ever, including ones closed in 2019." I find the encoded form harder to get wrong, because ^OR sits exactly where the OR happens: 'active=true^priority=1^ORpriority=2'. When in doubt, write the encoded query.
Counting and grouping: GlideAggregate. Calling gr.getRowCount() on a huge table is a war crime. It looks like a count, but the platform has already dragged every matching row back to the application node just so it can measure the pile. GlideAggregate sends the counting to the database, which has been optimized for exactly this since before ServiceNow existed. Grouping comes free:
var ga = new GlideAggregate('incident');
ga.addQuery('active', true);
ga.addAggregate('COUNT');
ga.groupBy('assignment_group');
ga.orderByAggregate('COUNT');
ga.query();
while (ga.next()) {
gs.info(ga.getDisplayValue('assignment_group') + ': ' + ga.getAggregate('COUNT'));
}
SUM, AVG, MIN, and MAX work the same way. If a report could answer the question, GlideAggregate is how your script should answer it.
Sorting, limits, pagination. orderBy and orderByDesc sort; setLimit caps. Make setLimit a reflex whenever you sample, debug, or only need the top N, it converts an unbounded query into a bounded one. For real pagination, take a window of an ordered set:
var gr = new GlideRecord('incident');
gr.addQuery('active', true);
gr.orderBy('number');
gr.chooseWindow(100, 150); // rows 100 through 149
gr.query();
chooseWindow without orderBy is a lottery ticket: the database owes you no stable order, so page two may repeat rows from page one.
Client side is a different animal. Server-side, query() blocks and you iterate immediately. Client-side, query() is asynchronous and needs a callback:
// client script, and honestly, prefer GlideAjax instead
var gr = new GlideRecord('incident');
gr.addQuery('active', true);
gr.query(function(gr) {
while (gr.next()) {
// runs later, when the server responds
}
});
Code after that query() call runs before the callback does, which is the source of a thousand "my variable is undefined" questions. My actual advice is to treat client-side GlideRecord as deprecated in your own head: it isn't available to scoped apps, it doesn't work in the Service Portal, and a GlideAjax call to a script include gives you a controlled server-side query with a proper security check instead.
Querying on behalf of users. Plain GlideRecord runs as system and sees everything. The moment your query serves a user, someone's UI, an Ajax response, an integration acting for a person, switch to GlideRecordSecure, which applies ACLs and drops rows and fields the user cannot read. The alternative is checking gr.canRead() per record yourself, which is easy to forget and easier to get wrong.
What's actually happening under the hood
addQuery, addEncodedQuery, and the rest only build a condition tree in memory; nothing touches the database until query(), which compiles the tree to SQL and executes it. next() walks the result set. That's why getRowCount is expensive: the rows are already on the app node, and counting them means you paid for all of them. setLimit becomes a SQL LIMIT, so the database stops early. Dot-walking has a cost model worth knowing: gr.assignment_group.manager.name inside a loop makes the platform fetch the group record, then the manager record, per iteration. One or two hops on a handful of rows is nothing. Three hops inside a loop over fifty thousand rows is a meaningful chunk of your business rule's runtime, so either query the target table directly or push the dot-walk into the encoded query, where it becomes a join the database handles once.
Gotchas
The three that cause real incidents: getRowCount() on large tables (use GlideAggregate COUNT), addOrCondition chained onto the wrong condition (it ORs the condition it hangs off, not the whole query), and unbounded queries in scheduled jobs that were "fine in dev" against 200 rows and then met production. None of these fail loudly. They just get slower or wronger as data grows.
One more subtle one: reusing a GlideRecord variable across queries without re-initializing it. Conditions accumulate on the object, so your second query inherits the first query's filters. New query, new new GlideRecord().
Variations worth knowing
^NQ in an encoded query starts an entirely new query ORed with everything before it, which is how you express "(A and B) or (C and D)" in one string. while (gr.next()) versus if (gr.next()) is a review checklist item: if for single-record lookups, while for sets, and mixing them up either drops records or processes one when you meant all. For global-scope work on current releases, look at GlideQuery, the modern wrapper over GlideRecord with fail-fast behavior that catches typo'd field names instead of silently returning everything. And for deletes or mass updates, wrap the whole thing in the dry-run pattern: count with GlideAggregate, log what you would touch, and only act when an explicit flag says so.
The reference card
| You want | Reach for |
|---|---|
| One record by key | gr.get('number', value) inside an if |
| Equals filter | addQuery('field', value) |
| Operator filter | addQuery('field', '>=', value) |
| Complex filter | addEncodedQuery() copied from a list breadcrumb |
| OR inside one condition | addQuery().addOrCondition() on the same condition |
| OR across clauses | encoded query with ^OR or ^NQ |
| Count or group | GlideAggregate with COUNT and groupBy |
| Empty / not empty | addNullQuery / addNotNullQuery |
| Sort | orderBy / orderByDesc |
| Page N | orderBy then chooseWindow(first, last) |
| Sample or cap | setLimit(n) |
| User-context read | GlideRecordSecure or canRead() |
Memorize the table, and steal the encoded queries. That's ninety percent of the job.