Use ReadIsolation instead of LockTable

Properties
LC0031 Info Design Code Fix Ignore Obsolete

LockTable() does not acquire a lock. It marks the table so that every subsequent read of that table in the transaction, on any record variable, uses the SQL UPDLOCK hint until the transaction commits. An event subscriber, a FlowField calculation, or unrelated code further down the call stack ends up locking rows it never intended to touch.

If Record.LockTable is called on an Item record, all reads against that table will be done with the UPDLOCK hint, not just the variable it was called on.

— Performance Articles for Developers on Microsoft Learn

ReadIsolation, introduced in Business Central 2023 wave 1 (v22), sets the isolation level on the specific record variable only. Other variables of the same table remain unaffected. The two are not equivalent, and that difference is the point: ReadIsolation keeps lock scope tight and predictable.

There is a second reason to make the switch. Tri-state locking (introduced in version 23, enabled by default, and always on from version 26) performs reads after writes optimistically, and a single LockTable() switches the table back to pessimistic locking for the rest of the transaction:

Explicitly using the LockTable method in code maintains the same behavior, disabling optimistic reads.

— Tri-state locking in database on Microsoft Learn

Replacing LockTable() with ReadIsolation preserves the tri-state benefits: fewer locks, higher concurrency, and fewer lock timeouts across the system.

Example

The following procedure locks the entire G/L Entry table for the rest of the transaction just to read the last entry number:

procedure GetNextEntryNo(): Integer
var
    GLEntry: Record "G/L Entry";
begin
    GLEntry.LockTable(); // Use ReadIsolation instead of LockTable [LC0031]
    GLEntry.FindLast();
    exit(GLEntry."Entry No." + 1);
end;

With ReadIsolation, only this specific variable acquires the lock. Any other code reading from G/L Entry later in the transaction remains unaffected:

procedure GetNextEntryNo(): Integer
var
    GLEntry: Record "G/L Entry";
begin
    GLEntry.ReadIsolation(IsolationLevel::UpdLock);
    GLEntry.FindLast();
    exit(GLEntry."Entry No." + 1);
end;

Convert or delete

Microsoft’s own coding guidance endorses LockTable for exactly one situation: a read that a directly following Insert, Modify, or Delete depends on. One question decides what to do with a flagged call:

  • A read of the same table follows, and a write depends on what it returned. Convert: put ReadIsolation(IsolationLevel::UpdLock) on the instance that performs that read.
  • No such read follows. The lock protects nothing. Delete the line.

The modern shape for “make sure nobody else took this number” is ReadIsolation on the instance that reads. The Base Application’s Customer table does this in OnInsert:

var
    Customer: Record Customer;
begin
    Customer.ReadIsolation(IsolationLevel::ReadUncommitted);
    while Customer.Get("No.") do
        "No." := NoSeries.GetNextNo("No. Series");

The same pattern appears in Vendor, Item, Contact, Employee, Bank Account, Resource, and in the Sustainability ESG tables added in 2025.

LockTable in table triggers

A bare LockTable() in OnInsert or OnDelete binds to the same built-in method as MyVar.LockTable(): the compiler models no self-receiver special case, and the runtime effect is the same transaction-wide UPDLOCK on the table. The diagnostic is therefore reported inside triggers as well.

Most trigger LockTable() calls in the Base Application come in two shapes.

No read follows. A journal table locks itself in OnInsert and then reads an unrelated template table. The lock on the journal line table serves no read:

trigger OnInsert()
begin
    LockTable(); // Use ReadIsolation instead of LockTable [LC0031]
    ItemJnlTemplate.Get("Journal Template Name");
end;

Delete the line. The Base Application removed exactly this call from Item Journal Line in version 26:

trigger OnInsert()
begin
    ItemJnlTemplate.Get("Journal Template Name");
end;

A dependent read follows. An entry table locks itself in OnInsert, then a second instance reads the last number, and the insert depends on that value:

trigger OnInsert()
var
    Attachment2: Record Attachment;
begin
    Attachment2.LockTable(); // Use ReadIsolation instead of LockTable [LC0031]
    if Attachment2.FindLast() then
        "No." := Attachment2."No." + 1;
end;

Convert the lock to ReadIsolation on the instance that reads:

trigger OnInsert()
var
    Attachment2: Record Attachment;
begin
    Attachment2.ReadIsolation(IsolationLevel::UpdLock);
    if Attachment2.FindLast() then
        "No." := Attachment2."No." + 1;
end;

The Base Application still contains about 78 LockTable() calls in table triggers. Since version 23.5 it has removed two and added none, and the newer Microsoft apps (Business Foundation, E-Document Core, Subscription Billing, Excise Taxes) contain no LockTable at all.

Code fix

The ALCops: Replace LockTable() with ReadIsolation code fix rewrites the call to ReadIsolation(IsolationLevel::UpdLock) on the same receiver: MyVar.LockTable() becomes MyVar.ReadIsolation(...), Rec.LockTable() becomes Rec.ReadIsolation(...), and a bare LockTable() inside a table or tableextension becomes a bare ReadIsolation(...). It drops the Wait and VersionCheck arguments of LockTable(true, true), because ReadIsolation has no equivalent.

The fix always converts and never deletes. Apply it when a dependent read follows; when the call protects nothing, delete the line instead.

See also