PRAGMA explain_query Turbocharge Bentley iTwin ApplicationsAnother blog on the background of where our investigations took us this week.

When building applications on the Bentley iTwin platform, user experience lives and dies by query responsiveness. Whether an engineer is navigating a 3D model of a high-speed rail line, clicking on a structural column, or running automated asset compliance checks across a hospital complex, every interaction triggers ECSQL queries against the underlying iModel.
In small models with 50,000 elements, almost any query feels instant. But real-world infrastructure models regularly exceed 5,000,000 to 10,000,000+ elements with millions of associated aspects, relationships, and property definitions. I have seen a trend towards modelling of work-packs, but there are still generally approaching 1 million elements in these iModels.
An unoptimized query scanning polymorphic relationship tables across the entire model can easily take 1.5 to 4+ seconds. In an interactive web viewer, that means frozen dashboards, sluggish element selection, and server quota warnings. Multiply that by a hundred ... ouch I just lost my ability to generate a dashboard.
Here is what we learned optimizing ECSQL queries in production, how to uncover bottlenecks using PRAGMA explain_query, and the techniques that slashed query runtimes by over 85%.
To optimize ECSQL, you have to understand what happens when a query executes:
bis.Element or bis.GeometricElement3d, ECSQL automatically resolves polymorphic class hierarchies.SCAN TABLE) in SQLite.PRAGMA explain_queryYou cannot optimize what you cannot measure. While standard database engines support EXPLAIN QUERY PLAN, Bentley ECSQL exposes its internal planner through a dedicated pragma:
PRAGMA explain_query('SELECT * FROM bis.GeometricElement3d')
While developing the Class Index we noticed a serious degradation of performance when querying aspects.
We have a Query API client, which is the funnel for all iTwin ecSQL queries, so we added explainQuery to allow one-line query inspection with automatic quote escaping and ES2017 compatibility for long running queries:
public async explainQuery(query: string): Promise<any[]> {
const cleanQuery = query.trim().replace(/^PRAGMA\s+explain_query\('([\s\S]*)'\)$/i, '$1');
const escapedQuery = cleanQuery.replace(/'/g, "''");
return this.executeQuery(`PRAGMA explain_query('${escapedQuery}')`, false);
}
SCAN vs SEARCHWhen you run PRAGMA explain_query, the iModel engine returns plan records:
id |
parent |
detail |
Meaning |
|---|---|---|---|
0 |
0 |
SCAN TABLE bis_Element |
⚠️ Full Table Scan: Scans every row in the iModel sequentially. As the model grows, latency scales linearly. |
0 |
1 |
SEARCH TABLE bis_Element USING INDEX ix_bis_Element_ECClassId |
✅ Indexed Lookup: B-tree index seek. Executes in sub-milliseconds regardless of model size. |
0 |
2 |
USE TEMP B-TREE FOR GROUP BY |
⚠️ Memory Spilling: Creates an in-memory temporary B-tree for sorting or unindexed grouping. |
In our class metadata inspector, we needed to find all aspect classes and their properties.
The initial query scanned the polymorphic relationship tables bis.ElementOwnsMultiAspects and bis.ElementOwnsUniqueAspect:
-- ❌ BEFORE: Naive Query
SELECT sourceecclassid, targetecclassid, ...
FROM (
SELECT DISTINCT sourceecclassid, targetecclassid FROM bis.ElementOwnsMultiAspects
UNION
SELECT DISTINCT sourceecclassid, targetecclassid FROM bis.ElementOwnsUniqueAspect
)
JOIN meta.ECClassDef ecs ON ecs.ECInstanceId = sourceecclassid
WHERE LOWER(ec_className(ecs.ECInstanceId, 's.c')) LIKE 'ifcwall'
PRAGMA explain_query:SCAN TABLE bis_ElementOwnsMultiAspectsUSE TEMP B-TREE FOR DISTINCTSCAN TABLE bis_ElementOwnsUniqueAspectUSE TEMP B-TREE FOR DISTINCTCOMPOUND SUBQUERIES 2 AND 3 (UNION)Because the filter WHERE ... LIKE 'ifcwall' was in the outer query, the engine performed a full scan of every aspect relationship across the entire iModel (~600,000 rows) and built temporary distinct tables before applying the filter!
We pushed the class predicate directly into the subqueries:
-- ✅ AFTER: Pushed Down Subquery
SELECT sourceecclassid, targetecclassid, ...
FROM (
SELECT DISTINCT SourceECClassId, TargetECClassId
FROM bis.ElementOwnsMultiAspects
WHERE SourceECClassId IN (
SELECT ECInstanceId FROM meta.ECClassDef WHERE LOWER(ec_className(ECInstanceId, 's.c')) LIKE 'ifcwall'
)
UNION
SELECT DISTINCT SourceECClassId, TargetECClassId
FROM bis.ElementOwnsUniqueAspect
WHERE SourceECClassId IN (
SELECT ECInstanceId FROM meta.ECClassDef WHERE LOWER(ec_className(ECInstanceId, 's.c')) LIKE 'ifcwall'
)
)
The Result: SQLite used the index on SourceECClassId, skipping 99.8% of the relationship table.
WHERE Clauses Breaking IndexingIn our property aggregation dashboards, we queried instance property statistics for selected classes:
-- ❌ BEFORE: Function on indexed column
SELECT ec_className(ECClassId, 's.c'), COUNT(*)
FROM bis.PhysicalElement
WHERE ec_className(ECClassId, 's.c') IN ('IFCDynamic.ifcdoor', 'IFCDynamic.ifcwindow')
GROUP BY ECClassId;
The Catch: Wrapping ECClassId in ec_className() prevents SQLite from using the B-Tree index on ECClassId. Every single row in bis.PhysicalElement was scanned and formatted as a string.
Filter by integer class IDs resolved via metadata subquery:
-- ✅ AFTER: Indexed integer comparison
WHERE ECClassId IN (
SELECT ECInstanceId FROM meta.ECClassDef
WHERE ec_className(ECInstanceId, 's.c') IN ('IFCDynamic.ifcdoor', 'IFCDynamic.ifcwindow')
)
Benchmarking against our test iModel (IFC Hospital iTwin Testing iModels):
| Query Scenario | Before (Unoptimized) | After (Optimized) | Speedup | Plan Change |
|---|---|---|---|---|
| Class Aspect Mapping | 1,420 ms |
115 ms |
12.3x | SCAN TABLE ➔ SEARCH USING INDEX |
| Filtered Physical Class Counts | 890 ms |
95 ms |
9.4x | Full table scan ➔ Index seek |
| Classes & Properties Discovery | 2,150 ms |
340 ms |
6.3x | Cartesian join ➔ Pre-aggregated subquery |
| Root Spatial Parent Resolution | 50 sequential HTTP calls (3.2s) |
1 batch CTE (140 ms) |
22.8x | Network N+1 ➔ Batch query |
To ensure queries stay fast as the application evolves, we introduced:
[ECSQL SLOW QUERY 1420.5ms, 2500 rows]:
SELECT ...
npx tsx scripts/explain-ecsql.ts "SELECT * FROM bis.GeometricElement3d" --token "Bearer ..."
PRAGMA explain_query('...'): A 10-second inspection tells you whether your query will scale to 1M elements or stall your application.WHERE clauses: Compare raw ECClassId values whenever possible.for loops querying individual element hierarchies with recursive Common Table Expressions (WITH RECURSIVE).Remember you do not need a CLI tool, I only used it to feed into an optimisation tool, but you can you iModelConsole to run the pragam explain tool. Give it a go on your longest running queries ... if you don't understand the output ... there are plenty of helpers out there that will.
Have you encountered unexpected performance bottlenecks in ECSQL? What query optimization patterns have made the biggest difference in your digital twins?