Add BC performance knowledge from OptimAL learnings

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-09-25 14:13:37 +02:00
parent 07e324ddbc
commit af94c135ad
37 changed files with 881 additions and 36 deletions

View file

@ -0,0 +1,73 @@
// Input stays stable for this run; output contains one row per group with entries.
table 50367 "Perf Posted Entry"
{
DataClassification = CustomerContent;
fields
{
field(1; "Entry No."; Integer) { }
field(2; "Item No."; Code[20]) { }
field(3; "Location Code"; Code[10]) { }
field(4; "Posting Date"; Date) { }
field(5; Quantity; Decimal) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
key(ByItemLocationDate; "Item No.", "Location Code", "Posting Date")
{
SumIndexFields = Quantity;
}
}
}
table 50368 "Perf Quantity Summary"
{
DataClassification = CustomerContent;
fields
{
field(1; "Run ID"; Guid) { }
field(2; "Item No."; Code[20]) { }
field(3; "Location Code"; Code[10]) { }
field(4; Quantity; Decimal) { }
}
keys
{
key(PK; "Run ID", "Item No.", "Location Code") { Clustered = true; }
}
}
codeunit 50369 "Perf Summary Bad"
{
procedure BuildSummary(CutoffDate: Date) RunId: Guid
var
Entry: Record "Perf Posted Entry";
Scan: Record "Perf Posted Entry";
Summary: Record "Perf Quantity Summary";
begin
RunId := CreateGuid();
Entry.SetFilter("Posting Date", '..%1', CutoffDate);
if Entry.FindSet() then
repeat
Scan.SetRange("Item No.", Entry."Item No.");
Scan.SetRange("Location Code", Entry."Location Code");
Scan.SetFilter("Posting Date", '..%1', CutoffDate);
Scan.CalcSums(Quantity);
if Summary.Get(RunId, Entry."Item No.", Entry."Location Code") then begin
Summary.Quantity := Scan.Quantity;
Summary.Modify(false);
end else begin
Summary.Init();
Summary."Run ID" := RunId;
Summary."Item No." := Entry."Item No.";
Summary."Location Code" := Entry."Location Code";
Summary.Quantity := Scan.Quantity;
Summary.Insert(false);
end;
until Entry.Next() = 0;
end;
}

View file

@ -0,0 +1,93 @@
// Input stays stable for this run; output contains one row per group with entries.
table 50367 "Perf Posted Entry"
{
DataClassification = CustomerContent;
fields
{
field(1; "Entry No."; Integer) { }
field(2; "Item No."; Code[20]) { }
field(3; "Location Code"; Code[10]) { }
field(4; "Posting Date"; Date) { }
field(5; Quantity; Decimal) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
key(ByItemLocationDate; "Item No.", "Location Code", "Posting Date")
{
SumIndexFields = Quantity;
}
}
}
table 50368 "Perf Quantity Summary"
{
DataClassification = CustomerContent;
fields
{
field(1; "Run ID"; Guid) { }
field(2; "Item No."; Code[20]) { }
field(3; "Location Code"; Code[10]) { }
field(4; Quantity; Decimal) { }
}
keys
{
key(PK; "Run ID", "Item No.", "Location Code") { Clustered = true; }
}
}
query 50370 "Perf Grouped Quantity"
{
QueryType = Normal;
elements
{
dataitem(Entry; "Perf Posted Entry")
{
column(ItemNo; "Item No.") { }
column(LocationCode; "Location Code") { }
column(TotalQuantity; Quantity)
{
Method = Sum;
}
filter(PostingDate; "Posting Date") { }
}
}
}
codeunit 50369 "Perf Summary Good"
{
procedure BuildSummary(CutoffDate: Date) RunId: Guid
var
Totals: Query "Perf Grouped Quantity";
TempSummary: Record "Perf Quantity Summary" temporary;
Summary: Record "Perf Quantity Summary";
begin
RunId := CreateGuid();
Totals.SetFilter(PostingDate, '..%1', CutoffDate);
Totals.Open();
while Totals.Read() do begin
TempSummary.Init();
TempSummary."Run ID" := RunId;
TempSummary."Item No." := Totals.ItemNo;
TempSummary."Location Code" := Totals.LocationCode;
TempSummary.Quantity := Totals.TotalQuantity;
TempSummary.Insert();
end;
Totals.Close();
if TempSummary.FindSet() then
repeat
Summary.Init();
Summary."Run ID" := RunId;
Summary."Item No." := TempSummary."Item No.";
Summary."Location Code" := TempSummary."Location Code";
Summary.Quantity := TempSummary.Quantity;
Summary.Insert(false);
until TempSummary.Next() = 0;
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: performance
keywords: [query, grouping, aggregate, intermediate-results, temporary-buffer, persistent-writes, calcsums, modify, method-sum]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Aggregate source rows before persisting completed results
## Description
A batch may repeatedly calculate the same group total while inserting and updating persistent working rows, even though only one completed result per group is needed. An AL Query with `Method = Sum` groups by its other output columns, so the required grain can often be computed from qualifying source rows before writing final output. This is a data-path change, not a blanket replacement for posting or for intermediate rows needed for recovery or business behavior.
## Best Practice
Specify the output grain and whether absent groups should produce zero rows. Apply source filters (including cutoff dates), aggregate at exactly that grain, and write only completed results; a small temporary buffer can separate the query from persistent output, but streaming or bounded units may be better for large grouped sets. Avoid a one-to-many join that duplicates quantities or an extra output column that silently changes the grouping. Preserve security filters, signs, units, FlowFilters, relevant master-data restrictions, transaction/trigger behavior, and acceptable read consistency; a shared date cutoff does not create a snapshot. Compare complete keyed outputs and measure source reads, repeated calculations, temporary memory, and persistent writes. See sample: [`aggregate-before-persisting-intermediate-results.good.al`](aggregate-before-persisting-intermediate-results.good.al).
## Anti Pattern
For an output that only needs one signed total per item/location with qualifying entries, summing the same source range and rewriting a persistent summary for *every* entry. Do not flag incremental persisted state that is required for locking, resumability, or downstream processing, or require an in-memory copy of all raw history to perform SQL grouping. See sample: [`aggregate-before-persisting-intermediate-results.bad.al`](aggregate-before-persisting-intermediate-results.bad.al).
## References
- [Aggregating data in query objects](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-query-totals-grouping).
- [Query performance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-query-objects-and-performance).
- [Buffered inserts](preserve-buffered-inserts-by-separating-target-reads.md).

View file

@ -15,7 +15,7 @@ A `Get` or `FindFirst` against another persistent table inside a loop can produc
## Best Practice
Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If keys repeat, a dictionary cache can reduce lookups to one per distinct key. `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so.
Use a query object to join the outer and inner tables when the relationship and filters can be expressed as one query. If complete lookup keys repeat and the result remains valid, a [scoped cache](cache-repeated-filtered-results-with-explicit-scope.md) can reduce lookups to one per distinct key; do not assume primary-key `Get` calls each hit SQL (see [transaction caching](primary-key-get-in-loop-is-transaction-cached.md)). `SetLoadFields` can reduce the columns transferred by unavoidable inner reads, but it does not eliminate the N+1 shape and must not be presented as doing so.
See sample: [`avoid-get-inside-loop-on-large-table.good.al`](avoid-get-inside-loop-on-large-table.good.al).

View file

@ -0,0 +1,39 @@
// Quantity validation depends only on Quantity and Unit Price in this demo.
table 50371 "Perf Validated Line"
{
DataClassification = CustomerContent;
fields
{
field(1; "Line No."; Integer) { }
field(2; Quantity; Decimal)
{
trigger OnValidate()
begin
Amount := Quantity * "Unit Price";
end;
}
field(3; "Unit Price"; Decimal) { }
field(4; Amount; Decimal) { }
field(5; Note; Text[100]) { }
}
keys
{
key(PK; "Line No.") { Clustered = true; }
}
}
codeunit 50372 "Perf Validation Bad"
{
procedure UpdateLine(LineNo: Integer; NewQuantity: Decimal; NewNote: Text[100])
var
Line: Record "Perf Validated Line";
begin
Line.Get(LineNo);
Line.Validate(Quantity, NewQuantity);
Line.Note := NewNote;
Line.Validate(Quantity, NewQuantity);
Line.Modify(false);
end;
}

View file

@ -0,0 +1,38 @@
// Quantity validation depends only on Quantity and Unit Price in this demo.
table 50371 "Perf Validated Line"
{
DataClassification = CustomerContent;
fields
{
field(1; "Line No."; Integer) { }
field(2; Quantity; Decimal)
{
trigger OnValidate()
begin
Amount := Quantity * "Unit Price";
end;
}
field(3; "Unit Price"; Decimal) { }
field(4; Amount; Decimal) { }
field(5; Note; Text[100]) { }
}
keys
{
key(PK; "Line No.") { Clustered = true; }
}
}
codeunit 50372 "Perf Validation Good"
{
procedure UpdateLine(LineNo: Integer; NewQuantity: Decimal; NewNote: Text[100])
var
Line: Record "Perf Validated Line";
begin
Line.Get(LineNo);
Line.Validate(Quantity, NewQuantity);
Line.Note := NewNote;
Line.Modify(false);
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: performance
keywords: [validate, onvalidate, repeated-write, sales-line, subscriber, unchanged-value]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Avoid repeating validation when its inputs have not changed
## Description
`Validate` runs field validation logic, which can invoke subscribers and additional reads or writes even if the assigned value is unchanged. When a document-building path validates the same field twice without changing any input its validation depends on, the second cascade may do the same work again. A field-value comparison alone does not prove that the dependent context or required event behavior is unchanged.
## Best Practice
Trace the table's `OnValidate`, subscribers, and dependent fields before removing a duplicate invocation. Keep the required validation and write, but skip a second `Validate` only when all its inputs, its order-dependent effects, and the business contract are demonstrably unchanged. Measure validations and writes per business unit and test pricing, reservations, error behavior, and subscriber effects on real document paths. The sample's custom field validation depends only on Quantity and Unit Price; a note assigned between calls does not affect either. See sample: [`avoid-repeating-unchanged-validation.good.al`](avoid-repeating-unchanged-validation.good.al).
## Anti Pattern
Validating Quantity, changing only an unrelated local or record note, validating the same Quantity again, then modifying the row, without any required second event. Do not replace `Validate` with direct assignment, use `Insert(false)` indiscriminately, or reorder validations on a Sales Line solely to reduce call counts: its standard logic can depend on other fields and event subscribers. See sample: [`avoid-repeating-unchanged-validation.bad.al`](avoid-repeating-unchanged-validation.bad.al).
## References
- [Record.Validate and field validation behavior](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-validate-method).
- [Equivalent bulk assignments versus per-row validation](prefer-modifyall-over-per-row-modify.md).
- [Subscriber guards](guard-event-subscribers-before-db-call.md).

View file

@ -0,0 +1,18 @@
codeunit 50363 "Perf Variant Cache Bad"
{
procedure CountLinesWithVariants(OrderNo: Code[20]) VariantLines: Integer
var
SalesLine: Record "Sales Line";
ItemVariant: Record "Item Variant";
begin
SalesLine.SetRange("Document Type", SalesLine."Document Type"::Order);
SalesLine.SetRange("Document No.", OrderNo);
SalesLine.SetRange(Type, SalesLine.Type::Item);
if SalesLine.FindSet() then
repeat
ItemVariant.SetRange("Item No.", SalesLine."No.");
if not ItemVariant.IsEmpty() then
VariantLines += 1;
until SalesLine.Next() = 0;
end;
}

View file

@ -0,0 +1,24 @@
codeunit 50363 "Perf Variant Cache Good"
{
procedure CountLinesWithVariants(OrderNo: Code[20]) VariantLines: Integer
var
SalesLine: Record "Sales Line";
ItemVariant: Record "Item Variant";
HasVariantsByItem: Dictionary of [Code[20], Boolean];
HasVariants: Boolean;
begin
SalesLine.SetRange("Document Type", SalesLine."Document Type"::Order);
SalesLine.SetRange("Document No.", OrderNo);
SalesLine.SetRange(Type, SalesLine.Type::Item);
if SalesLine.FindSet() then
repeat
if not HasVariantsByItem.Get(SalesLine."No.", HasVariants) then begin
ItemVariant.SetRange("Item No.", SalesLine."No.");
HasVariants := not ItemVariant.IsEmpty();
HasVariantsByItem.Add(SalesLine."No.", HasVariants);
end;
if HasVariants then
VariantLines += 1;
until SalesLine.Next() = 0;
end;
}

View file

@ -0,0 +1,27 @@
---
bc-version: [all]
domain: performance
keywords: [cache, dictionary, filtered-lookup, isempty, repeated-query, invalidation, scope]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Reuse repeated filtered results within a correct cache scope
## Description
A loop can ask the same filtered existence or calculation question for many rows sharing a business key. Unlike repeated primary-key `Get` calls, non-keyed filtered lookups are not automatically answered by the primary-key record cache. Memoization can remove repeated AL and data-access work, but a cache keyed by too few inputs or kept past a data change returns the wrong answer.
## Best Practice
First reuse an already-loaded result if valid. For a repeated, stable filtered lookup, keep a local dictionary for one operation, keyed by every input that affects the result (including company, filters, date, unit, currency, and quantity where applicable). Cache negative results as well as positive ones; distinguish a missing dictionary entry from an entry whose value is `false`. If underlying records can change during the run, update or invalidate the entry, or do not cache it. Bound entries or process in chunks when key cardinality is large. Check distinct complete keys, hits/misses, SQL work, AL time, and memory before adding a cache to a low-reuse workload. Use a temporary table for record-shaped values or multiple keys. See sample: [`cache-repeated-filtered-results-with-explicit-scope.good.al`](cache-repeated-filtered-results-with-explicit-scope.good.al).
## Anti Pattern
Running the same filtered `IsEmpty` for every line with a repeated item, or caching a price by item alone when customer, variant, date, and quantity affect it. Do **not** infer one SQL round trip from each `Get` inside a loop or automatically wrap a cached primary-key read in another dictionary; see [primary-key cache exceptions](primary-key-get-in-loop-is-transaction-cached.md). See sample: [`cache-repeated-filtered-results-with-explicit-scope.bad.al`](cache-repeated-filtered-results-with-explicit-scope.bad.al).
## References
- [Data access and caching](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-data-access).
- [Dictionary type and `Get`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/dictionary/dictionary-data-type).

View file

@ -1,15 +1,14 @@
codeunit 50223 "Perf Sample CalcSums Bad"
{
procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal
procedure TotalSales(CustomerNo: Code[20]) Total: Decimal
var
CustLedgerEntry: Record "Cust. Ledger Entry";
begin
CustLedgerEntry.SetCurrentKey("Customer No.");
CustLedgerEntry.SetRange("Customer No.", CustomerNo);
// One SQL query per row over a 10M-row ledger.
if CustLedgerEntry.FindSet() then
repeat
CustLedgerEntry.CalcFields("Remaining Amount");
Total += CustLedgerEntry."Remaining Amount";
Total += CustLedgerEntry."Sales (LCY)";
until CustLedgerEntry.Next() = 0;
end;
}

View file

@ -1,11 +1,12 @@
codeunit 50222 "Perf Sample CalcSums Good"
{
procedure TotalRemaining(CustomerNo: Code[20]) Total: Decimal
procedure TotalSales(CustomerNo: Code[20]) Total: Decimal
var
CustLedgerEntry: Record "Cust. Ledger Entry";
begin
CustLedgerEntry.SetCurrentKey("Customer No.");
CustLedgerEntry.SetRange("Customer No.", CustomerNo);
CustLedgerEntry.CalcSums("Remaining Amount");
Total := CustLedgerEntry."Remaining Amount";
CustLedgerEntry.CalcSums("Sales (LCY)");
Total := CustLedgerEntry."Sales (LCY)";
end;
}

View file

@ -1,26 +1,31 @@
---
bc-version: [all]
domain: performance
keywords: [calcfields, calcsums, loop, flowfield, n-plus-one, aggregation]
keywords: [calcfields, calcsums, loop, flowfield, source-field, sumindexfields, aggregation]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use CalcSums to aggregate, not CalcFields inside a loop
# Use CalcSums for stored-field totals, not as a shortcut for FlowFields
## Description
`CalcFields` materializes FlowField values for one record. Each call against a persistent table is "a separate SQL query"; running it inside a `repeat ... until Next() = 0` over a large table issues one query per row on top of the iteration itself. `CalcSums` answers the same aggregation question — "give me the sum of this FlowField over the filtered set" — as a single SQL statement. Per the upstream guidance, `CalcFields` inside loops on large persistent tables is "a performance problem"; the aggregation form is `CalcSums()`.
`CalcFields` evaluates a FlowField for one record; `CalcSums` totals stored numeric fields in a filtered source table. They do not generally answer the same question. A loop that adds a normal source field for one total can often use one `CalcSums`, possibly backed by a compatible SIFT index. A loop that adds calculated FlowFields cannot be replaced with `CalcSums` on those FlowFields: each `CalcFormula` may depend on its parent record, FlowFilters, and the selected parent set. `CalcFields` requests can also use a recent calculation cache, so source-level call counts are not SQL statement counts.
## Best Practice
When the procedure totals a FlowField (or several) across a filtered set, set the filters, then call `CalcSums("Field 1", "Field 2", ...)`. The platform issues one query; the result is read off the record's FlowField slot. Single `CalcFields` outside loops is fine, and `CalcFields` on the current row in a page's `OnAfterGetRecord` or in `OnValidate` is the standard pattern — those are per-action, not per-row over a large set.
When only one total over stored source fields is needed, set the source-table filters and call `CalcSums` on those fields; select an appropriate current key with `SumIndexFields` when relying on SIFT, and measure the read/write trade-off. If the inputs are FlowFields, derive any proposed source aggregation from their `CalcFormula`, including the selected parent set and FlowFilters, and verify equivalent results before replacing the loop. When every row needs its own FlowField value, [use `SetAutoCalcFields`](use-setautocalcfields-for-per-row-flowfields.md) where appropriate rather than replacing row values with one total. See [SIFT trade-offs](choose-maintainsiftindex-by-read-write-ratio.md).
See sample: [`calcsums-instead-of-calcfields-in-loop.good.al`](calcsums-instead-of-calcfields-in-loop.good.al).
## Anti Pattern
`if CustLedgerEntry.FindSet() then repeat CustLedgerEntry.CalcFields("Remaining Amount"); Total += CustLedgerEntry."Remaining Amount"; until CustLedgerEntry.Next() = 0;` — exactly the upstream-flagged shape. The iteration is the cheap part; the per-row `CalcFields` is what scales linearly with table size.
Looping over filtered `Cust. Ledger Entry` records and adding the stored `"Sales (LCY)"` field when the only output is its total. The opposite mistake is proposing `Customer.CalcSums(Balance)` as a generic replacement for adding selected customers' FlowField balances; that changes or fails to express the required calculation.
See sample: [`calcsums-instead-of-calcfields-in-loop.bad.al`](calcsums-instead-of-calcfields-in-loop.bad.al).
## References
- [CalcFields and CalcSums operate on different field calculations](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-calcfields-calcsums-fielderror-fieldname-init-testfield-and-validate-methods).
- [Record.CalcSums](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-calcsums-method).

View file

@ -0,0 +1,38 @@
table 50360 "Perf Read Entry"
{
DataClassification = CustomerContent;
fields
{
field(1; "Entry No."; Integer) { }
field(2; "Customer No."; Code[20]) { }
field(3; "Posting Date"; Date) { }
field(4; "Item No."; Code[20]) { }
field(5; Quantity; Decimal) { }
field(6; Description; Text[100]) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
key(ByDescription; Description, "Item No.", "Posting Date") { }
key(ByQuantity; Quantity, "Customer No.") { }
}
}
codeunit 50361 "Perf Read Entry Bad"
{
procedure SumNonblankItems(CustomerNo: Code[20]; FromDate: Date; ToDate: Date) Total: Decimal
var
Entry: Record "Perf Read Entry";
begin
Entry.SetRange("Customer No.", CustomerNo);
Entry.SetRange("Posting Date", FromDate, ToDate);
Entry.SetLoadFields("Item No.", Quantity);
if Entry.FindSet() then
repeat
if Entry."Item No." <> '' then
Total += Entry.Quantity;
until Entry.Next() = 0;
end;
}

View file

@ -0,0 +1,40 @@
table 50360 "Perf Read Entry"
{
DataClassification = CustomerContent;
fields
{
field(1; "Entry No."; Integer) { }
field(2; "Customer No."; Code[20]) { }
field(3; "Posting Date"; Date) { }
field(4; "Item No."; Code[20]) { }
field(5; Quantity; Decimal) { }
field(6; Description; Text[100]) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
key(ByCustomerDate; "Customer No.", "Posting Date")
{
IncludedFields = "Item No.", Quantity;
}
}
}
codeunit 50361 "Perf Read Entry Good"
{
procedure SumNonblankItems(CustomerNo: Code[20]; FromDate: Date; ToDate: Date) Total: Decimal
var
Entry: Record "Perf Read Entry";
begin
Entry.SetRange("Customer No.", CustomerNo);
Entry.SetRange("Posting Date", FromDate, ToDate);
Entry.SetLoadFields("Item No.", Quantity);
if Entry.FindSet() then
repeat
if Entry."Item No." <> '' then
Total += Entry.Quantity;
until Entry.Next() = 0;
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [19..]
domain: performance
keywords: [includedfields, covering-index, secondary-key, filter, projection, selectivity, key, setrange]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Design a covering key from the measured read pattern
## Description
A secondary key should serve a particular read, not a list of fields that happen to look important. The order of key fields affects which filters and sort orders it can support; `IncludedFields` supplies non-key payload columns without making them ordered key columns. `SetCurrentKey` sets an order, not an index hint (see [sort guidance](setcurrentkey-sets-sort-order-not-index-hint.md)). Coverage alone does not make a query selective.
## Best Practice
For a costly, frequent read, establish its equality and range filters, joins, required ordering, actual SQL projection, cardinality, and existing physical indexes. Test a key whose leading fields support the useful predicates; for example, customer equality followed by a posting-date range. On a nonclustered secondary key, consider `IncludedFields` for small payload fields read but not filtered or ordered. Check the generated SQL (including implicitly selected and extension fields) before calling an index covering, and measure reads, sorts, lookups, latency, and write overhead. The [Database Missing Indexes page](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/database-missing-indexes) supplies candidates, not a mandate to add every suggested key.
`IncludedFields` requires runtime 8.0 (BC 19) or later and cannot be set on a primary or clustered secondary key. An included field does not participate in `SetCurrentKey` matching or maintain a SIFT sum. If the item is also a selective predicate or required ordering column, evaluate it as a key field instead. Respect table-extension key field-ownership restrictions. See sample: [`design-covering-keys-from-read-pattern.good.al`](design-covering-keys-from-read-pattern.good.al).
## Anti Pattern
Adding a key on output-only fields or requesting an unrelated `SetCurrentKey` ordering to "force" the optimizer to use that key, without establishing the read's filters or validating its plan. Do not report every uncovered read as a defect: a small table, a low-frequency query, or a write-heavy table may be better without another maintained index. See sample: [`design-covering-keys-from-read-pattern.bad.al`](design-covering-keys-from-read-pattern.bad.al).
## References
- [Table keys, included columns, and extension restrictions](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-table-keys).
- [IncludedFields property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-includedfields-property).
- [Table keys and performance](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-table-keys-and-performance).

View file

@ -13,11 +13,11 @@ application-area: [all]
## Description
`IsEmpty` is the right API when the caller only needs existence — see `microsoft/knowledge/performance/use-isempty-for-existence-check.md`. It is not a cheap guard in front of a loop that will `FindSet` anyway. Both calls hit the database; `FindSet` already returns false when the filter matches nothing. Agents and reviewers often insert `if not Rec.IsEmpty() then` "for performance" and pay a second query for a result the iterator already provides.
`IsEmpty` is the right API when the caller only needs existence — see `microsoft/knowledge/performance/use-isempty-for-existence-check.md`. It is not a cheap guard in front of a loop that will `FindSet` anyway. `FindSet` already returns false when the filter matches nothing; an extra `IsEmpty` is unnecessary AL work and can issue a second database request, depending on caching. Agents and reviewers often insert `if not Rec.IsEmpty() then` "for performance" and duplicate a result the iterator already provides.
## Best Practice
When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set.
When the body iterates, open with `if Rec.FindSet() then repeat ... until Next() = 0`. Do not flag a bare `FindSet` loop as missing an `IsEmpty` precondition. Reserve `IsEmpty` for branches that never materialize the row set. An early check before a *bulk write* or lock is a different, workload-dependent decision: it can help when the filtered set is usually empty, but adds work when rows exist and does not lock the set against change.
See sample: [`isempty-before-findset-is-extra-round-trip.good.al`](isempty-before-findset-is-extra-round-trip.good.al).

View file

@ -20,6 +20,7 @@ codeunit 50242 "Perf Sample ModifyAll Good"
StagingEntry: Record "Perf Import Staging Entry";
begin
StagingEntry.SetRange("Batch ID", BatchId);
StagingEntry.SetRange(Processed, false);
// Processed has no OnValidate logic, and the equivalent loop uses Modify(false).
StagingEntry.ModifyAll(Processed, true, false);
end;

View file

@ -15,7 +15,7 @@ application-area: [all]
## Best Practice
Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. Check whether table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). A visible loop for progress UX is acceptable only when evidence shows the equivalent bulk call already executes as individual operations and the loop preserves trigger and business semantics.
Use `ModifyAll` when the loop directly assigns the same value, does not call `Validate`, needs no per-row calculation, and does not depend on `OnModify` unless the equivalent `RunTrigger` value is supplied. For a data-only update, exclude rows already holding the target value when that filter preserves the business outcome. Do not skip unchanged rows if the original call's per-row effects are required. Check whether table trigger code, related subscribers, security filtering, `Media`/`MediaSet`, or companion fields force row-by-row fallback (see `triggers-and-media-field-regress-modifyall.md`). A visible loop for progress UX is acceptable only when evidence shows the equivalent bulk call already executes as individual operations and the loop preserves trigger and business semantics.
See sample: [`prefer-modifyall-over-per-row-modify.good.al`](prefer-modifyall-over-per-row-modify.good.al).

View file

@ -0,0 +1,61 @@
// Each invocation exclusively owns a new run; these tables have no write logic.
table 50364 "Perf Input"
{
DataClassification = CustomerContent;
fields
{
field(1; "Row No."; Integer) { }
field(2; "Item No."; Code[20]) { }
field(3; Quantity; Decimal) { }
}
keys
{
key(PK; "Row No.") { Clustered = true; }
}
}
table 50365 "Perf Output"
{
DataClassification = CustomerContent;
fields
{
field(1; "Run ID"; Guid) { }
field(2; "Line No."; Integer) { }
field(3; "Item No."; Code[20]) { }
field(4; Quantity; Decimal) { }
}
keys
{
key(PK; "Run ID", "Line No.") { Clustered = true; }
}
}
codeunit 50366 "Perf Buffered Insert Bad"
{
procedure CopyRun(var TempInput: Record "Perf Input" temporary) RunId: Guid
var
Output: Record "Perf Output";
NextLineNo: Integer;
begin
RunId := CreateGuid();
Output.SetRange("Run ID", RunId);
if TempInput.FindSet() then
repeat
if Output.FindLast() then
NextLineNo := Output."Line No." + 1
else
NextLineNo := 1;
Output.Init();
Output."Run ID" := RunId;
Output."Line No." := NextLineNo;
Output."Item No." := TempInput."Item No.";
Output.Insert(false);
Output.Quantity := TempInput.Quantity;
Output.Modify(false);
until TempInput.Next() = 0;
end;
}

View file

@ -0,0 +1,56 @@
// Each invocation exclusively owns a new run; these tables have no write logic.
table 50364 "Perf Input"
{
DataClassification = CustomerContent;
fields
{
field(1; "Row No."; Integer) { }
field(2; "Item No."; Code[20]) { }
field(3; Quantity; Decimal) { }
}
keys
{
key(PK; "Row No.") { Clustered = true; }
}
}
table 50365 "Perf Output"
{
DataClassification = CustomerContent;
fields
{
field(1; "Run ID"; Guid) { }
field(2; "Line No."; Integer) { }
field(3; "Item No."; Code[20]) { }
field(4; Quantity; Decimal) { }
}
keys
{
key(PK; "Run ID", "Line No.") { Clustered = true; }
}
}
codeunit 50366 "Perf Buffered Insert Good"
{
procedure CopyRun(var TempInput: Record "Perf Input" temporary) RunId: Guid
var
Output: Record "Perf Output";
NextLineNo: Integer;
begin
RunId := CreateGuid();
if TempInput.FindSet() then
repeat
NextLineNo += 1;
Output.Init();
Output."Run ID" := RunId;
Output."Line No." := NextLineNo;
Output."Item No." := TempInput."Item No.";
Output.Quantity := TempInput.Quantity;
Output.Insert(false);
until TempInput.Next() = 0;
end;
}

View file

@ -0,0 +1,27 @@
---
bc-version: [all]
domain: performance
keywords: [buffered-inserts, bulk-inserts, findlast, insert, target-table, commit, staging]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep eligible inserts together instead of re-reading their target
## Description
Business Central can automatically buffer eligible `Insert` calls. `Find`/`Calc` on the **target** table, `Modify`/`Delete` on it, or `Commit` flushes pending inserts; consuming the `Insert` return value, or BLOB/AutoIncrement fields on the target, prevents buffering. A source-table read is not itself a target-table flush. There is no general `InsertAll` replacement for an AL loop.
## Best Practice
When writing completed rows to an application-owned data-only table, allocate a collision-free run or range once, prepare every field before each `Insert`, and keep the insert sequence free of intervening target-table reads and writes. Let the owning business transaction determine the commit point. Trace called procedures and events as well as the visible loop; inspect actual SQL batches and writes, then test failures, retries, and any concurrent writers. The sample assumes an exclusive new run ID and no required trigger, validation, number-series, or subscriber effects. See sample: [`preserve-buffered-inserts-by-separating-target-reads.good.al`](preserve-buffered-inserts-by-separating-target-reads.good.al).
## Anti Pattern
Calling `FindLast` on the target for every row to allocate its next line number, inserting an incomplete row and immediately modifying it, or committing each iteration of an otherwise eligible insert sequence. Moving a shared `FindLast` out of the loop without concurrency-safe allocation is **not** a valid fix. Do not recommend skipping required triggers or persistence steps in sales documents, journals, or posting ledgers just to obtain buffering; repeated `Modify` calls do not automatically batch like eligible inserts. See sample: [`preserve-buffered-inserts-by-separating-target-reads.bad.al`](preserve-buffered-inserts-by-separating-target-reads.bad.al).
## References
- [Bulk inserts and flush/non-buffering conditions](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-bulk-inserts).
- [Transaction checkpoints and restart safety](avoid-commit-inside-loops.md).

View file

@ -13,12 +13,12 @@ application-area: [all]
The Business Central server caches primary-key reads within a transaction. Repeated `Record.Get(<primary key>)` calls for the same key are served from that cache rather than re-queried, so a guarded `if not Rec.Get(...) then exit;` inside a per-row helper is not a genuine N+1 pattern. When each row legitimately carries a distinct key — for example one `Bin Content` row per bin, so `Bin.Get` and `BinType.Get` see a different bin each iteration — the `Get` must run per row regardless, and there is nothing to hoist.
Reviewers sometimes see two `Get` calls inside a routine that runs once per row and recommend wrapping them in a `Dictionary` cache. That is over-engineering: it duplicates the server's built-in record cache, adds state that must be invalidated, and breaks the surrounding extension's established pattern of direct guarded `Get` calls.
Reviewers sometimes see two `Get` calls inside a routine that runs once per row and recommend wrapping them in a `Dictionary` cache. Without evidence of a material additional cost, that duplicates the server's built-in record cache and adds state that must be invalidated. Filtered, non-keyed reads are a different case (see [repeated filtered results](cache-repeated-filtered-results-with-explicit-scope.md)).
## Best Practice
Treat a primary-key `Get()` — especially a guarded `if not Rec.Get(...) then exit;` — as a cheap, transaction-cached read. Do not recommend a manual `Dictionary` cache around per-row primary-key `Get` calls. Reserve N+1 concerns for genuinely repeated non-keyed queries (`FindSet`/`FindFirst` with filters, `Count`) that re-hit the database each iteration.
Treat a primary-key `Get()` — especially a guarded `if not Rec.Get(...) then exit;` — as a transaction-cached read, not as proof of N+1 SQL. Do not recommend a manual cache solely from source-level call counts. If profiling shows repeated AL work or cache misses are material and the complete result can be reused safely, assess an explicitly scoped cache on its own merits. Investigate genuinely repeated non-keyed queries (`FindSet`/`FindFirst` with filters, `Count`) separately.
## Anti Pattern
Reporting repeated primary-key `Get` calls (such as `Bin.Get` and `BinType.Get`) inside a per-row helper as a performance defect, or recommending they be cached in a `Dictionary`. The reads are already cached by the server within the transaction, and per-row keys often differ so the calls cannot be hoisted.
Reporting repeated primary-key `Get` calls (such as `Bin.Get` and `BinType.Get`) inside a per-row helper as one SQL round trip per call, or recommending a `Dictionary` without measuring reuse and cost. Per-row keys often differ, and the server can satisfy repeated keys from its transaction cache.

View file

@ -13,7 +13,7 @@ application-area: [all]
## Description
The Business Central server caches primary-key `Get` calls within a transaction. Query objects do not use that cache: every `Open`/`Read` goes to SQL. `avoid-get-inside-loop-on-large-table.md` is right when an unbounded inner `Get`/`FindFirst` joins two large sets. It is wrong as a blanket rewrite of repeated `Get` on the same keys. Replacing a cached `Get` with a Query that re-executes per call can be slower. This file exists so reviewers stop treating every `Get` inside a loop as a Query candidate.
The Business Central server caches primary-key `Get` calls within a transaction. Query objects do not use that primary-key cache: reopening a Query per lookup executes the query again; `Read` consumes rows from an open query, not a new query for each row. `avoid-get-inside-loop-on-large-table.md` is right when an unbounded inner `Get`/`FindFirst` joins two large sets. It is wrong as a blanket rewrite of repeated `Get` on the same keys. Replacing a cached `Get` with a Query reopened per call can be slower.
## Best Practice

View file

@ -0,0 +1,43 @@
// The only supported read filters customer/date and displays item, quantity, and amount.
// No consumer needs ordering on the displayed fields or a SIFT aggregate.
table 50362 "Perf Document Entry"
{
DataClassification = CustomerContent;
fields
{
field(1; "Entry No."; Integer) { }
field(2; "Customer No."; Code[20]) { }
field(3; "Posting Date"; Date) { }
field(4; "Item No."; Code[20]) { }
field(5; Quantity; Decimal) { }
field(6; Amount; Decimal) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
key(CustomerDate; "Customer No.", "Posting Date") { }
key(CustomerDateItem; "Customer No.", "Posting Date", "Item No.") { }
key(CustomerDateQuantity; "Customer No.", "Posting Date", Quantity) { }
key(CustomerDateAmount; "Customer No.", "Posting Date", Amount) { }
}
}
codeunit 50375 "Perf Document Reader Bad"
{
procedure CustomerDateTotals(CustomerNo: Code[20]; FromDate: Date; ToDate: Date; var ItemNos: List of [Code[20]]; var TotalAmount: Decimal; var TotalQuantity: Decimal)
var
Entry: Record "Perf Document Entry";
begin
Entry.SetRange("Customer No.", CustomerNo);
Entry.SetRange("Posting Date", FromDate, ToDate);
Entry.SetLoadFields("Item No.", Quantity, Amount);
if Entry.FindSet() then
repeat
ItemNos.Add(Entry."Item No.");
TotalQuantity += Entry.Quantity;
TotalAmount += Entry.Amount;
until Entry.Next() = 0;
end;
}

View file

@ -0,0 +1,43 @@
// The only supported read filters customer/date and displays item, quantity, and amount.
// No consumer needs ordering on the displayed fields or a SIFT aggregate.
table 50362 "Perf Document Entry"
{
DataClassification = CustomerContent;
fields
{
field(1; "Entry No."; Integer) { }
field(2; "Customer No."; Code[20]) { }
field(3; "Posting Date"; Date) { }
field(4; "Item No."; Code[20]) { }
field(5; Quantity; Decimal) { }
field(6; Amount; Decimal) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
key(CustomerDatePayload; "Customer No.", "Posting Date")
{
IncludedFields = "Item No.", Quantity, Amount;
}
}
}
codeunit 50375 "Perf Document Reader Good"
{
procedure CustomerDateTotals(CustomerNo: Code[20]; FromDate: Date; ToDate: Date; var ItemNos: List of [Code[20]]; var TotalAmount: Decimal; var TotalQuantity: Decimal)
var
Entry: Record "Perf Document Entry";
begin
Entry.SetRange("Customer No.", CustomerNo);
Entry.SetRange("Posting Date", FromDate, ToDate);
Entry.SetLoadFields("Item No.", Quantity, Amount);
if Entry.FindSet() then
repeat
ItemNos.Add(Entry."Item No.");
TotalQuantity += Entry.Quantity;
TotalAmount += Entry.Amount;
until Entry.Next() = 0;
end;
}

View file

@ -0,0 +1,29 @@
---
bc-version: [all]
domain: performance
keywords: [overlapping-keys, redundant-index, includedfields, sift, write-amplification, index-portfolio, key, setrange]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Review overlapping keys as a portfolio
## Description
Adding a secondary key to a frequently written table maintains another SQL index for every affected write. Several keys with the same leading fields might be serving distinct filters, sort orders, unique constraints, or SIFT aggregates; they might instead be redundant payload variants. A shared prefix, absent `SetCurrentKey` calls, or low usage in a short window is not proof that a key is unused.
## Best Practice
Before adding or removing a key, inventory keys from the table and installed extensions and identify each key's consumers and purpose: seek, ordering, uniqueness, or aggregation. Check `SQLIndex`, `MaintainSQLIndex`, `SumIndexFields`, and `MaintainSIFTIndex`, not only the AL key name. For *confirmed* payload-only variants on BC 19 or later, a single nonclustered key with `IncludedFields` can be a consolidation candidate (see [covering keys](design-covering-keys-from-read-pattern.md)); an included field cannot replace a key column used for ordering or a maintained SIFT sum. Measure representative read and write workloads, including periodic reports, integrations, and other companies, before and after a supported extension/schema change. Retain a rollback path for a critical reader that regresses. See sample: [`review-overlapping-keys-before-adding-an-index.good.al`](review-overlapping-keys-before-adding-an-index.good.al).
## Anti Pattern
Keeping another customer/date key for each displayed field without checking whether the trailing fields support distinct operations. Conversely, merging `(Customer, Date)` with `(Customer, Item, Date)` merely because they share a prefix can regress item-selective queries; removing a unique key or a SIFT aggregate changes more than write cost. Do not report a key as unused solely because AL never calls `SetCurrentKey` on it. See sample: [`review-overlapping-keys-before-adding-an-index.bad.al`](review-overlapping-keys-before-adding-an-index.bad.al).
## References
- [Table keys: benefits, costs, and constraints](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-table-keys).
- [IncludedFields property](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-includedfields-property).
- [Manage index usage (BC 2026 release wave 1 and later)](https://learn.microsoft.com/en-us/dynamics365/business-central/manage-indexes).
- [SIFT read/write trade-off](choose-maintainsiftindex-by-read-write-ratio.md).

View file

@ -7,11 +7,11 @@ countries: [w1]
application-area: [all]
---
# SetCurrentKey only sets sort order — it is not an index hint
# SetCurrentKey is not a SQL index hint for record reads
## Description
A common misconception is that `SetCurrentKey` tells SQL Server which index to use for a query. It does not. In Business Central, `SetCurrentKey` only changes the `ORDER BY` clause of the generated SQL statement. It does not add an index hint, and the SQL Server query optimizer is free to ignore the named key entirely.
A common misconception is that `SetCurrentKey` tells SQL Server which index to use for a filtered `FindSet`/`FindFirst` query. It does not. For record iteration, `SetCurrentKey` changes the `ORDER BY` clause of the generated SQL statement; it does not add an index hint, and the SQL Server query optimizer is free to ignore the named key entirely. A distinct use is selecting an appropriate key with `SumIndexFields` before `CalcSums` so the platform can use a compatible SIFT aggregate (see [SIFT and SQL Server](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-sift-and-sql-server)).
The optimizer picks the index from the `WHERE` clause (your `SetRange`/`SetFilter`) together with table statistics and estimated cost. In practice it almost never chooses an index just because that key appears in `ORDER BY`. So calling `SetCurrentKey` to "steer" the plan toward an index is a no-op for index selection — and can make things worse: an `ORDER BY` that the query does not otherwise need can push the optimizer toward a less selective index or add a Sort operator to the plan.
@ -19,13 +19,15 @@ Selectivity comes from having the right index available (a key on the table whos
## Best Practice
Decide `SetCurrentKey` on one question only: **do I need the result set in a specific order?**
For a record-iteration read, decide `SetCurrentKey` on one question: **do I need the result set in a specific order?**
- If yes — you iterate rows in a defined sequence, or rely on `FindFirst`/`FindLast`/`Next` returning a particular row — call `SetCurrentKey` for that sort. The order is a functional requirement, and the `ORDER BY` is justified.
- If no — omit `SetCurrentKey`. Let the optimizer choose the cheapest plan for your filters; it may pick a better index and skip a sort.
To make a filtered read fast, ensure a key (index) exists on the table whose leading fields cover the filter, and filter on those fields with `SetRange`/`SetFilter`. That is what lets the optimizer seek. Defining the key creates the index; `SetCurrentKey` is not required to make the optimizer use it.
For a stored-field `CalcSums` instead of iteration, consider a matching current key with the summed field in `SumIndexFields`, as documented for SIFT; do not classify that call as an unnecessary `ORDER BY` on a row iterator (see [stored-field totals](calcsums-instead-of-calcfields-in-loop.md)).
See sample: [`setcurrentkey-sets-sort-order-not-index-hint.good.al`](setcurrentkey-sets-sort-order-not-index-hint.good.al).
## Anti Pattern

View file

@ -0,0 +1,39 @@
// Caller supplies an unfiltered temporary buffer that does not change during this call.
table 50373 "Perf Cell"
{
DataClassification = CustomerContent;
fields
{
field(1; "Cell No."; Integer) { }
field(2; "Item No."; Code[20]) { }
field(3; Quantity; Decimal) { }
}
keys
{
key(PK; "Cell No.") { Clustered = true; }
}
}
codeunit 50374 "Perf Temp Totals Bad"
{
procedure SumDisplayedItemTotals(var TempCells: Record "Perf Cell" temporary) DisplayedTotal: Decimal
var
TempScan: Record "Perf Cell" temporary;
ItemTotal: Decimal;
begin
TempScan.Copy(TempCells, true);
if TempCells.FindSet() then
repeat
TempScan.Reset();
TempScan.SetRange("Item No.", TempCells."Item No.");
ItemTotal := 0;
if TempScan.FindSet() then
repeat
ItemTotal += TempScan.Quantity;
until TempScan.Next() = 0;
DisplayedTotal += ItemTotal;
until TempCells.Next() = 0;
end;
}

View file

@ -0,0 +1,39 @@
// Caller supplies an unfiltered temporary buffer that does not change during this call.
table 50373 "Perf Cell"
{
DataClassification = CustomerContent;
fields
{
field(1; "Cell No."; Integer) { }
field(2; "Item No."; Code[20]) { }
field(3; Quantity; Decimal) { }
}
keys
{
key(PK; "Cell No.") { Clustered = true; }
}
}
codeunit 50374 "Perf Temp Totals Good"
{
procedure SumDisplayedItemTotals(var TempCells: Record "Perf Cell" temporary) DisplayedTotal: Decimal
var
TotalsByItem: Dictionary of [Code[20], Decimal];
ItemTotal: Decimal;
begin
if TempCells.FindSet() then
repeat
if TotalsByItem.Get(TempCells."Item No.", ItemTotal) then
TotalsByItem.Set(TempCells."Item No.", ItemTotal + TempCells.Quantity)
else
TotalsByItem.Add(TempCells."Item No.", TempCells.Quantity);
until TempCells.Next() = 0;
if TempCells.FindSet() then
repeat
DisplayedTotal += TotalsByItem.Get(TempCells."Item No.");
until TempCells.Next() = 0;
end;
}

View file

@ -15,8 +15,12 @@ A temporary table stores its rows in Business Central Server memory instead of a
## Best Practice
Do not apply SQL-specific findings such as missing `SetLoadFields`, lock contention, or N+1 database round-trips to a temporary record. Still assess memory volume and repeated scans or lookups. For a pure key-to-value collection, consider an AL `Dictionary`; keep a temporary table when record fields, keys, filtering, or ordered iteration are required.
Do not apply SQL-specific findings such as missing `SetLoadFields`, lock contention, or N+1 database round-trips to a temporary record. Still assess memory volume and repeated scans or lookups. In a matrix, if each cell re-sums the same group, calculate the totals once at the complete group key and reuse them; invalidate or adjust totals if cells change. For a pure key-to-value collection, consider an AL `Dictionary`; keep a temporary table when record fields, keys, filtering, or ordered iteration are required. Measure AL time and peak memory, not just SQL time.
See sample: [`temporary-tables-have-no-database-cost.good.al`](temporary-tables-have-no-database-cost.good.al).
## Anti Pattern
Claiming that every temporary-table access pattern is free because no SQL is involved. A nested scan over a large in-memory buffer can still dominate service-tier CPU, while adding `SetLoadFields` to that buffer addresses a database cost that does not exist.
See sample: [`temporary-tables-have-no-database-cost.bad.al`](temporary-tables-have-no-database-cost.bad.al).

View file

@ -15,12 +15,12 @@ application-area: [all]
## Best Practice
Call `SetAutoCalcFields` before `FindSet` when every returned row needs the same FlowField for a comparison, branch, or per-record action. Use `CalcSums` instead when the required result is one aggregate over the filtered set (see `calcsums-instead-of-calcfields-in-loop.md`).
Call `SetAutoCalcFields` before `FindSet` when every returned row needs the same FlowField for a comparison, branch, or per-record action. For one total of a **stored source field**, consider `CalcSums` instead (see [stored-field totals versus FlowFields](calcsums-instead-of-calcfields-in-loop.md)). If the required result is a total of selected FlowField values, derive the underlying source filters from `CalcFormula`; do not sum the FlowField directly with `CalcSums`.
See sample: [`use-setautocalcfields-for-per-row-flowfields.good.al`](use-setautocalcfields-for-per-row-flowfields.good.al).
## Anti Pattern
Calling `CalcFields` inside the loop when every iteration reads the same FlowField. Each `CalcFields` request requires a separate SQL statement unless a compatible recent result is cached. Do not replace row-specific decisions with `CalcSums`; an aggregate cannot preserve which rows met the condition.
Calling `CalcFields` inside the loop when every iteration reads the same FlowField. A compatible recent result can be cached, so do not equate each call with a SQL statement. Do not replace row-specific decisions with `CalcSums`; an aggregate cannot preserve which rows met the condition.
See sample: [`use-setautocalcfields-for-per-row-flowfields.bad.al`](use-setautocalcfields-for-per-row-flowfields.bad.al).

View file

@ -1,14 +1,18 @@
codeunit 50219 "Perf Sample LoadFields Bad"
{
procedure ListUSCustomerNames()
procedure CollectUSCustomerNamesAndCities(var DisplayNames: List of [Text])
var
Customer: Record Customer;
begin
// Loads every Customer column on every row, when only Name is read.
Customer.SetRange("Country/Region Code", 'US');
if Customer.FindSet() then
repeat
Message(Customer.Name);
DisplayNames.Add(CustomerDisplayText(Customer));
until Customer.Next() = 0;
end;
local procedure CustomerDisplayText(Customer: Record Customer): Text
begin
exit(Customer.Name + ' ' + Customer.City);
end;
}

View file

@ -1,17 +1,22 @@
codeunit 50218 "Perf Sample LoadFields Good"
{
procedure ListUSCustomerNames()
procedure CollectUSCustomerNamesAndCities(var DisplayNames: List of [Text])
var
Customer: Record Customer;
begin
Customer.SetRange("Country/Region Code", 'US');
Customer.SetLoadFields(Name);
Customer.SetLoadFields(Name, City);
if Customer.FindSet() then
repeat
Message(Customer.Name);
DisplayNames.Add(CustomerDisplayText(Customer));
until Customer.Next() = 0;
end;
local procedure CustomerDisplayText(Customer: Record Customer): Text
begin
exit(Customer.Name + ' ' + Customer.City);
end;
procedure LookupSkuPolicy(LocationCode: Code[10]) Policy: Enum "SKU Creation Method"
var
Location: Record Location;

View file

@ -17,7 +17,7 @@ Its position relative to `SetRange`/`SetFilter` does not change the projection:
## Best Practice
Before a `Get`, `FindSet`, or `FindFirst` that the procedure follows by reading only a handful of the table's fields, call `SetLoadFields` listing exactly those fields. For example, `SetLoadFields(...); if Record.Get(...) then ...` selects fields before the read. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons ([singleton setup tables](singleton-setup-tables-need-no-access-optimization.md), [temporary tables](temporary-tables-have-no-database-cost.md)). The numeric cutoffs are BCQuality review heuristics, not Microsoft platform thresholds. For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see [report partial loads](addloadfields-in-report-onpredataitem.md)).
Before a `Get`, `FindSet`, or `FindFirst` that the complete read path follows by reading only a handful of the table's fields, call `SetLoadFields` listing the normal fields used by the caller **and its helpers**. For example, `SetLoadFields(...); if Record.Get(...) then ...` selects fields before the read. Place the call immediately before the read, after any `SetRange`/`SetFilter`, so a reader can see at a glance which read the selection governs and any projection-changing operation is easy to spot. Check for later `Reset` and for unloaded fields read by a [by-value helper](pass-var-record-to-preserve-partial-load-enumerator.md); a JIT load can be served from cache, so it is not automatically a SQL round trip. Skip `SetLoadFields` when the table has few fields (under ten), when the code reads most of them (above 60 %), when the loop runs ten or fewer iterations, or when the table is exempt for other reasons ([singleton setup tables](singleton-setup-tables-need-no-access-optimization.md), [temporary tables](temporary-tables-have-no-database-cost.md)). The numeric cutoffs are BCQuality review heuristics, not Microsoft platform thresholds. For report dataitems, use `AddLoadFields` in `OnPreDataItem` instead (see [report partial loads](addloadfields-in-report-onpredataitem.md)).
See sample: [`use-setloadfields-for-partial-records.good.al`](use-setloadfields-for-partial-records.good.al).