Optimizing ORM queries for D7 1C-Bitrix

Our company is engaged in the development, support and maintenance of Bitrix and Bitrix24 solutions of any complexity. From simple one-page sites to complex online stores, CRM systems with 1C and telephony integration. The experience of developers is confirmed by certificates from the vendor.
Our competencies:
Development stages

D7 ORM Query Optimization in 1C-Bitrix

D7 ORM is the object-relational mapper of new Bitrix core. Convenient for development, but can generate inefficient queries if you don't understand its behavior. A query written in 5 minutes might do SELECT * with three unnecessary JOINs — on large catalog this is 500 ms instead of 10 ms.

How D7 ORM Builds SQL

ORM reads table descriptions from the entity class's getMap() method. Relations (references) are described there too. When you write:

\Bitrix\Iblock\ElementTable::getList([
    'select' => ['ID', 'NAME', 'IBLOCK.NAME'],
]);

ORM automatically adds LEFT JOIN b_iblock ON b_iblock.ID = b_iblock_element.IBLOCK_ID — because you requested field via dot notation. Convenient, but if you don't know IBLOCK_ID and it's always the same — JOIN is pointless.

View the resulting SQL via:

$query = \Bitrix\Iblock\ElementTable::query();
$query->setSelect(['ID', 'NAME']);
$query->setFilter(['IBLOCK_ID' => 5]);
echo $query->getQuery();  // outputs formed SQL

Main Problem Sources

Extra fields in select. If you don't pass select, ORM selects all fields from getMap(). For ElementTable this is 20+ fields including DETAIL_TEXT (type Text — potentially megabytes). On 100 elements, megabytes of data PHP receives from MySQL then discards.

Rule: always explicitly specify select:

$result = \Bitrix\Iblock\ElementTable::getList([
    'select' => ['ID', 'NAME', 'PREVIEW_PICTURE_ID', 'DETAIL_PAGE_URL'],
    'filter' => ['=IBLOCK_ID' => 5, '=ACTIVE' => 'Y'],
    'order'  => ['SORT' => 'ASC'],
    'limit'  => 20,
]);

Automatic JOINs via references. When select contains field via dot notation (SECTION.NAME, IBLOCK.SORT) — ORM adds JOIN. Check via getQuery() — possibly needed data already in main table or can get via separate query.

N+1 via fetchObject(). D7 supports object model (fetchObject()). On lazy relation access, object makes additional query on each access:

// This is N+1 — each ->getSection() makes new query
foreach ($elements->getIterator() as $element) {
    echo $element->getSection()->getName();  // query per element!
}

// Right — load sections immediately via select
$result = \Bitrix\Iblock\ElementTable::getList([
    'select' => ['ID', 'NAME', 'IBLOCK_SECTION_ID', 'SECTION_' => 'IBLOCK_SECTION.NAME'],
    'filter' => ['=IBLOCK_ID' => 5],
]);

ORM Query-Level Caching

D7 supports built-in cache via cache parameter:

$result = \Bitrix\Iblock\ElementTable::getList([
    'select' => ['ID', 'NAME', 'PREVIEW_PICTURE_ID'],
    'filter' => ['=IBLOCK_ID' => 5, '=ACTIVE' => 'Y'],
    'cache'  => [
        'ttl'   => 3600,   // lifetime in seconds
        'cache_joins' => true,  // cache with JOINs
    ],
]);

Cache stored in file system (or memcache/redis if configured). Auto-invalidates on data change via ORM — if data changes via direct SQL, cache doesn't reset.

Important: cache_joins => true needed if select has fields from related tables. Without it, cache works incorrectly with JOINs.

Runtime Fields and Subqueries

D7 allows adding computed fields to query via runtime:

use Bitrix\Main\Entity;

$result = \Bitrix\Iblock\ElementTable::getList([
    'select' => ['ID', 'NAME', 'PRICE_VALUE'],
    'runtime' => [
        new Entity\ReferenceField(
            'PRICE',
            \Bitrix\Catalog\PriceTable::class,
            ['=this.ID' => 'ref.PRODUCT_ID', '=ref.CATALOG_GROUP_ID' => new Entity\ExpressionField('PTYPE', '1')],
            ['join_type' => 'LEFT']
        ),
        new Entity\ExpressionField('PRICE_VALUE', '%s', ['PRICE.PRICE']),
    ],
    'filter' => ['=IBLOCK_ID' => 5],
]);

Via ExpressionField can compute values on MySQL side without PHP processing: ROUND(%s * 1.2, 2) for price with margin calculation.

Working with Large Selections

When processing many records (import, export, bulk update), don't load everything in memory:

// Bad: all 100,000 rows in PHP memory
$result = SomeTable::getList(['select' => ['ID', 'NAME']]);
$all = $result->fetchAll();

// Good: process in batches of 500
$offset = 0;
$limit  = 500;
do {
    $result = SomeTable::getList([
        'select' => ['ID', 'NAME'],
        'limit'  => $limit,
        'offset' => $offset,
        'order'  => ['ID' => 'ASC'],  // stable sort for pagination
    ]);
    $rows = $result->fetchAll();
    foreach ($rows as $row) {
        // processing
    }
    $offset += $limit;
} while (count($rows) === $limit);

Alternative — cursor pagination by ID: filter => ['>ID' => $lastId], more efficient than OFFSET at large values.

Diagnostics via SqlTracker

For analyzing ORM queries in real context:

$tracker = \Bitrix\Main\Diag\SqlTracker::getInstance();
$tracker->start();

// ... your code with ORM queries ...

$tracker->stop();
$queries = $tracker->getQueries();
usort($queries, fn($a, $b) => $b->getTime() <=> $a->getTime());

foreach (array_slice($queries, 0, 10) as $q) {
    echo round($q->getTime() * 1000, 1) . ' ms: ' . substr($q->getSql(), 0, 200) . PHP_EOL;
}

Work Timeline

Task Timeline Effect
Audit ORM queries, identify excess select/JOIN 1–2 days Picture understanding
Optimize select, eliminate N+1 2–4 days 30–60% load reduction
Add cache to slow queries 1–2 days Eliminate repeated queries
Refactor complex queries (runtime, subqueries) 3–5 days Shift computation to DB side
Complete ORM optimization 1.5–2 weeks Stable work under load

D7 ORM is tool balancing development convenience with performance. For readable queries without critical speed requirements — good. For hot paths with thousands calls per minute — need to control every generated SQL.