ServiceNow query optimization: Fastest query methods

Fetching related data in ServiceNow may seem straightforward, but different query methods can lead to very different performance results. In this article, Appmore’s IAM Consultant Niko Sääski compares four common approaches and benchmarks their performance to reveal which method provides the fastest and most efficient outcome.

ServiceNow Query Optimization: Looking into dot-walk, addExtraField, addJoinQuery, and RLQUERY

Picture of Niko Sääski

Niko Sääski

ServiceNow Consultant, Appmore

Introduction

When fetching related records in ServiceNow, developers often use the simple dot-walk notation for its familiarity and convenience. But is there room to optimize for performance using specialized GlideRecord methods like addExtraField()addJoinQuery() or RLQUERY syntax? This blog post explores the performance characteristics of these four approaches through rigorous benchmarking and SQL analysis. The testing was performed in a scoped application on a personal developer instance running Zurich (glide-zurich-07-01-2025__patch3-10-16-2025) and MariaDB. For transparency, I have used AI tools to write up this blog post, but all the testing was performed by me.

 

In my example I am retrieving nested data from incident.assignment_group.manager.department. Each approach handles this differently, resulting in different database query patterns and execution times. My testing compared these methods using 100 iterations of incident lookups. Cache is flushed between each query to ensure fair measurement. If cache is not flushed, all the results flatten to be pretty much the same after a couple of iterations.

Performance results

I introduce the findings of my testing early on in this article, because not every reader may find the nitty-gritty details of the tests interesting.

Based on 100 iterations per method, testing on 1 and 5 incident records with cache flushed between queries:

Key Finding: addExtraField() consistently outperforms all other methods by delivering results in ~7.5 ms regardless of record count, while the baseline dot-walk approach delivers results in ~26ms – a massive improvementaddJoinQuery() is also something to keep in mind, since it provides significant improvement over dot-walking and it is available in scoped applications.

The performance difference correlates directly with database round-trips: fewer round-trips mean faster execution. addExtraField() achieves one round-trip with eager-loaded JOINs, while addJoinQuery() reduces the typical seven lazy-loaded queries to approximately 2–3 batched queries through intelligent query optimization. addJoinQuery()‘s slight performance penalty is acceptable for scoped applications where addExtraField() is unavailable.

Conclusion about ServiceNow query optimization

The choice of query method dramatically impacts ServiceNow application performance. My benchmarking for ServiceNow query optimization demonstrates that:

  1. For global-scope applications: Use addExtraField() when traversing relationships

  2. For scoped applications: Use addJoinQuery() as the default, avoiding dot-walk

  3. Reserve dot-walk only for simple, single-field access (not chains)

  4. RLQUERY should only be used if filtering relationships, not for performance

 

For production implementations, prioritize eager-loading approaches (addExtraField for global scope, addJoinQuery for scoped apps) whenever you need to traverse multiple relationships. The performance gains justify the slight increase in code complexity, especially in high-volume or latency-sensitive integrations. In a separate test not covered in this article, I found that even when wrapped in a global Script Include, addExtraField() remains faster than addJoinQuery() in this use case.

 

As for me, I will be using addExtraField() or addJoinQuery() instead of dot-walk. I don’t see any reason to omit these methods since they add very little boilerplate to the script.

ServiceNow query optimization deep dive: Query methods in detail

About the testing methods

 

For doing the speed testing I have my own scoped application built for that purpose.

The SQL statements I extracted using ServiceNow’s SQL debugger.

 

1. Dot-walk (Baseline): The Familiar but Costly Approach

 

Dot-walk notation is how most ServiceNow developers intuitively access related records. The method is simple and requires no special API calls—just traverse the relationship hierarchy:

				
					dot_walk_baseline: function () {
    var gr = new GlideRecord('incident');
    gr.addQuery('sys_id', 'IN', [ this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id() ]);
    gr.setLimit(5);
    gr.query();
    while (gr.next()) {
        var agManager = String(gr.assignment_group.manager);
        var agManagerDepartment = String(gr.assignment_group.manager.department);
        gs.info('dot-walk --- INC: ' + gr.number + ', AG Manager: ' + agManager +
                ', AG Manager\'s Department: ' + agManagerDepartment);
    }
}
				
			

While convenient, dot-walk triggers automatic lazy-loading at each relationship boundary. The database generates separate queries for each relationship traversal:

				
					-- Query 1: Initial incident fetch
SELECT FROM task task0
WHERE task0.`sys_class_name` = 'incident'
    AND task0.`sys_id` IN (
        '86ac394d535d7a10ceedf301a0490e8b',
        'caacf54d535d7a10ceedf301a0490edf',
        '471eb058a9fe198100f89592e1ea93d3',
        'e8acb54d535d7a10ceedf301a0490e26',
        '0eacf54d535d7a10ceedf301a0490ef1'
    )
LIMIT 0, 5;

-- Query 2: Single assignment_group lookup (individual)
SELECT FROM sys_user_group sys_user_group0
WHERE sys_user_group0.`sys_id` = '8a5055c9c61122780043563ef53438e3'
ORDER BY sys_user_group0.`sys_id`;

-- Query 3: Batch assignment_group lookup (IN clause)
SELECT FROM sys_user_group sys_user_group0
WHERE sys_user_group0.`sys_id` IN (
    '287ee6fea9fe198100ada7950d0b1b73',
    '8a5055c9c61122780043563ef53438e3',
    'd625dccec0a8016700a222a0f7900d06'
)
ORDER BY sys_user_group0.`sys_id`;

-- Query 4: Individual sys_user (manager) lookup
SELECT FROM sys_user sys_user0
WHERE sys_user0.`sys_id` = '9ee1b13dc6112271007f9d0efdb69cd0';

-- Query 5: Another individual sys_user lookup
SELECT FROM sys_user sys_user0
WHERE sys_user0.`sys_id` = '46d44a23a9fe19810012d100cca80666';

-- Plus additional queries (6-7 total)
				
			

This results in 6–7 separate database round-trips, each carrying network latency overhead. For every loop iteration, the GlideRecord engine must traverse to assignment_group, fetch that record, then traverse to manager, fetch that record, and finally traverse to department. Each traversal triggers a separate database query.

 

 

2. addExtraField(): Eager Loading with JOINs

 

The addExtraField() method is only available in global scope (not scoped applications), but it provides the fastest execution by eagerly loading all related data in a single query with JOINs:

				
					add_extra_field: function () {
    var gr = new GlideRecord('incident');
    gr.addQuery('sys_id', 'IN', [ this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id() ]);
    gr.setLimit(5);
    gr.addExtraField('assignment_group.manager.department');
    gr.query();

    while (gr.next()) {
        var agManager = String(gr.assignment_group.manager);
        var agManagerDepartment = String(gr.assignment_group.manager.department);
        gs.info('addExtraField --- INC: ' + gr.number + ', AG Manager: ' + agManager +
                ', AG Manager\'s Department: ' + agManagerDepartment);
    }
}
				
			

The resulting SQL demonstrates the power of eager loading with LEFT JOINs:

				
					-- Single query with 2 LEFT JOINs
SELECT FROM (
    (
        task task0
        LEFT JOIN sys_user_group sys_user_group1
            ON task0.`assignment_group` = sys_user_group1.`sys_id`
    )
    LEFT JOIN sys_user sys_user2
        ON sys_user_group1.`manager` = sys_user2.`sys_id`
)
WHERE task0.`sys_class_name` = 'incident'
    AND task0.`sys_id` IN (
        '12ac394d535d7a10ceedf301a0490eaf',
        '158c790d535d7a10ceedf301a0490e8b',
        '6d8c790d535d7a10ceedf301a0490ee3',
        '949c714d535d7a10ceedf301a0490e1f',
        'd6ac394d535d7a10ceedf301a0490ecf'
    );
				
			

All related data is fetched in a single database query with two LEFT JOINs, eliminating the overhead of multiple round-trips. Notice how the addExtraField('assignment_group.manager.department') call causes ServiceNow to construct a query that eagerly joins all three relationship tables simultaneously. The JOINs are LEFT JOINs, ensuring that even incidents without assignment groups are returned (with NULL values for group/manager/department fields).

 

This explains the consistent 7.5ms execution time across different record counts—no matter if you’re fetching 1 record or 5 records, you’re still performing just one database operation.

 

3. RLQUERY: Encoded Relationships Without JOIN Optimization

 

RLQUERY (Related List Query) is a specialized syntax that encodes relationship queries using a caret-delimited format (^). While RLQUERY provides powerful relationship filtering capabilities, my testing shows it does not translate to actual SQL JOINs:

				
					rl_query: function () {
    var randomSysIds = [ this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id() ];

    var rlQuery = "sys_idIN" + randomSysIds.join(",") +
        "^RLQUERYassignment_group.manager.department.sys_id,>=0^ENDRLQUERY";

    var gr = new GlideRecord('incident');
    gr.addEncodedQuery(rlQuery);
    gr.setLimit(5);
    gr.query();

    while (gr.next()) {
        var agManager = String(gr.assignment_group.manager);
        var agManagerDepartment = String(gr.assignment_group.manager.department);
        gs.info('RLQUERY --- INC: ' + gr.number + ', AG Manager: ' + agManager +
                ', AG Manager\'s Department: ' + agManagerDepartment);
    }
}
				
			

The generated SQL reveals that RLQUERY still executes the initial incident query without JOINs, then follows up with individual queries for related records:

				
					-- Query 1: Initial incident fetch with RLQUERY encoded filter
SELECT FROM task task0
WHERE task0.`sys_class_name` = 'incident'
    AND task0.`sys_id` IN (
        '92ac794d535d7a10ceedf301a0490e01',
        '0b8c7d0d535d7a10ceedf301a0490ec2',
        '809c314d535d7a10ceedf301a0490e79',
        '9eac394d535d7a10ceedf301a0490ed7',
        'ceac394d535d7a10ceedf301a0490e2a'
    )
LIMIT 0, 5;

-- Query 2: Duplicate incident fetch (second iteration)
SELECT FROM task task0
WHERE task0.`sys_class_name` = 'incident'
    AND task0.`sys_id` IN (
        '0b8c7d0d535d7a10ceedf301a0490ec2',
        '809c314d535d7a10ceedf301a0490e79',
        '92ac794d535d7a10ceedf301a0490e01',
        '9eac394d535d7a10ceedf301a0490ed7',
        'ceac394d535d7a10ceedf301a0490e2a'
    );

-- Query 3-5: Individual and batch lookups for related records
SELECT FROM sys_user_group sys_user_group0
WHERE sys_user_group0.`sys_id` = 'd625dccec0a8016700a222a0f7900d06'
ORDER BY sys_user_group0.`sys_id`;

SELECT FROM sys_user sys_user0
WHERE sys_user0.`sys_id` = '46d44a23a9fe19810012d100cca80666';

-- Plus additional batch lookups...
				
			

The RLQUERY encoding optimizes query filtering logic (the assignment_group.manager.department.sys_id,>=0 portion applies a filter on the relationship), but it does not eliminate multiple database round-trips. The lazy-loading behavior still applies once the initial query completes, resulting in performance nearly identical to dot-walk (28.4ms).

 

4. addJoinQuery(): Hybrid Approach with Subqueries and Batching

 

addJoinQuery() adds a subquery filter to return records based on relationships in related tables. Importantly, it is not a true database JOIN; rather, it adds a subquery that filters the result set. Only fields from the base table (the table with which the GlideRecord was initialized) are accessible — related fields still require subsequent queries to populate.

				
					add_join_query: function () {
    var gr = new GlideRecord('incident');
    gr.addQuery('sys_id', 'IN', [ this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id(), this.get_random_sys_id() ]);
    gr.setLimit(5);

    // Chain addJoinQuery calls to traverse relationships
    var agJoin = gr.addJoinQuery('sys_user_group', 'assignment_group', 'sys_id');
    var mgrJoin = agJoin.addJoinQuery('sys_user', 'assignment_group.manager', 'sys_id');
    var mgrDptJoin = mgrJoin.addJoinQuery('sys_user', 'assignment_group.manager.department', 'sys_id');

    gr.query();

    while (gr.next()) {
        var agManager = String(gr.assignment_group.manager);
        var agManagerDepartment = String(gr.assignment_group.manager.department);
        gs.info('addJoinQuery --- INC: ' + gr.number + ', AG Manager: ' + agManager +
                ', AG Manager\'s Department: ' + agManagerDepartment);
    }
}
				
			

The generated SQL shows a hybrid architecture with subqueries:

				
					-- Query 1: Initial query with subquery filter
SELECT FROM task task0
WHERE task0.`sys_class_name` = 'incident'
    AND task0.`sys_id` IN (
        'd8acb54d535d7a10ceedf301a0490e17',
        '7a8c7d0d535d7a10ceedf301a0490e72',
        'd6ac394d535d7a10ceedf301a0490eed',
        '92ac794d535d7a10ceedf301a0490e01',
        'a83820b58f723300e7e16c7827bdeed2'
    )
    AND task0.`assignment_group` IN (
        SELECT ... FROM sys_user_group sys_user_group0
        ORDER BY sys_user_group0.`sys_id`
    )
LIMIT 0, 5;

-- Query 2: Follow-up query (second iteration)
SELECT FROM task task0
WHERE task0.`sys_class_name` = 'incident'
    AND task0.`sys_id` IN (
        '7a8c7d0d535d7a10ceedf301a0490e72',
        '92ac794d535d7a10ceedf301a0490e01',
        'a83820b58f723300e7e16c7827bdeed2',
        'd6ac394d535d7a10ceedf301a0490eed',
        'd8acb54d535d7a10ceedf301a0490e17'
    );

-- Query 3: Batch assignment_group lookup (IN clause)
SELECT FROM sys_user_group sys_user_group0
WHERE sys_user_group0.`sys_id` = 'd625dccec0a8016700a222a0f7900d06'
ORDER BY sys_user_group0.`sys_id`;

-- Query 4: Individual manager lookup
SELECT FROM sys_user sys_user0
WHERE sys_user0.`sys_id` = '46d44a23a9fe19810012d100cca80666';

-- Query 5: Batch manager lookup
SELECT FROM sys_user_group sys_user_group0
WHERE sys_user_group0.`sys_id` IN (
    '287ee6fea9fe198100ada7950d0b1b73',
    '8a4dde73c6112278017a6a4baf547aa7',
    '8a5055c9c61122780043563ef53438e3'
)
ORDER BY sys_user_group0.`sys_id`;

-- Plus additional lookups for department...
				
			

While this approach generates 5 queries instead of addExtraField()‘s single query, the use of IN clauses for batch lookups rather than individual sys_id queries significantly reduces the actual database round-trips. The subquery in the initial WHERE clause also helps ServiceNow’s query optimizer reduce unnecessary record fetches. This intelligent batching is why addJoinQuery() achieves only 11% slower performance than the single-query approach of addExtraField().