How to use virtual fields on a list page

In this video, I look at different approaches for adding fields that don’t exist in the primary table on a page. Check it out:

https://youtu.be/3tzIX7vKs5E

In this video, Erik demonstrates three different approaches to displaying “virtual fields” on a list page in AL for Business Central. These are columns that show data from a related table — in this case, displaying a vendor name on a customer list — where the vendor shares the same number as the customer. Each method has its own trade-offs in terms of performance, functionality, and coding style.

The Problem: Showing Data That Isn’t in Your Source Table

When building a list page in Business Central, you often need to display information that doesn’t exist in the page’s source table. For example, you might have a customer list but need to show a related vendor’s name. Erik refers to these as “virtual fields” — not to be confused with virtual tables, which are something completely different.

The question is: how do we make these extra columns appear to the end user as if they’re real fields? Erik walks through three distinct methods, each with its own pros and cons.

Setting Up the Base Page

Erik starts by creating a fresh list page for the Customer table using the AL Dev Tools wizard in VS Code. The page includes basic fields: No., Name, Address, and City. From this starting point, he layers on three different techniques for adding a vendor name column.

Method 1: Calling a Function Directly in the Field Expression

The first approach is the simplest: call a function directly as the source expression of a field on the page.

field(VendorName; GetVendorName(Rec))
{
}

The function itself looks up the vendor record by the customer’s number and returns the vendor name:

local procedure GetVendorName(CustRec : Record Customer): Text
var
    Vendor: Record Vendor;
begin
    if Vendor.Get(CustRec."No.") then
        exit(Vendor.Name)
    else
        exit('<no vendor>');
end;

Erik makes an important design point here: he passes the customer record as a parameter rather than relying on Rec directly inside the function. This keeps the function free of side effects and independent of state, making it reusable in other contexts.

The Limitation

While this method works and displays the vendor name, the resulting column is a “lesser column.” When you click on a regular column like City, you get sorting options (ascending, descending) and filtering. But when you click on the virtual field created this way, you only get “What’s this” — no sorting, no filtering. The column is not part of the dataset, so Business Central treats it differently.

Method 2: Using a Global Variable with OnAfterGetRecord

The second approach uses a global variable that gets populated in the OnAfterGetRecord trigger:

field(VendorName2; VendorNameTxt)
{
}

The variable is declared as a global with the [InDataSet] attribute:

var
    [InDataSet]
    VendorNameTxt: Text;

The value is set in the trigger that fires for each record:

trigger OnAfterGetRecord()
var
    Vendor: Record Vendor;
begin
    if Vendor.Get(Rec."No.") then
        VendorNameTxt := Vendor.Name
    else
        VendorNameTxt := '';
end;

Important: Clear the Variable for Every Record

Erik runs into a common gotcha here. Because VendorNameTxt is a global variable, if you don’t explicitly clear it when no vendor is found, it retains the value from the previous record. This is why the else branch that sets it to an empty string is critical.

About [InDataSet]

Erik notes that the AL compiler used to require the [InDataSet] attribute to include a variable in the page’s dataset, but the modern compiler is smart enough to figure this out automatically when the variable is used as a field source expression. According to Microsoft, there’s no functional difference — but you can still add it explicitly for clarity.

The Advantage Over Method 1

If you need multiple fields from the same related record (say, vendor name and vendor address), Method 2 is more efficient. You do a single Get call on the vendor table and populate both variables in one go — two fields for the price of one lookup. With Method 1, each function call would perform its own separate Get, meaning two fields cost two lookups.

The Limitation

Unfortunately, this method still produces a column that doesn’t support sorting or filtering — the same limitation as Method 1.

Method 3: Using a FlowField via Table Extension

The third method involves creating an actual field — a FlowField — on the customer table via a table extension:

tableextension 50100 "My Customer Ext" extends Customer
{
    fields
    {
        field(50100; VendorName; Text[100])
        {
            FieldClass = FlowField;
            CalcFormula = lookup(Vendor.Name where("No." = field("No.")));
            Editable = false;
        }
    }
}

On the page, this field is referenced just like any other table field:

field(VendorName3; Rec.VendorName)
{
}

Key Concept: FlowFields Don’t Live in the Database

Even though you’re defining a field on the table, a FlowField does not store data in the database. It’s calculated on the fly based on the CalcFormula. This is what makes it “virtual” while still being a proper field in the AL object model.

The Benefits

This is where the magic happens. Because the column is now a real field on the record, Business Central treats it as a first-class citizen:

  • Filtering: You can click on the column header and filter by vendor name
  • Drill-down: Clicking on the FlowField value navigates you to the related vendor record
  • SQL optimization: Microsoft can perform fancy SQL-level optimizations because the relationship is declaratively defined in the CalcFormula, rather than being procedural AL code

The Caveat: Performance on Large Tables

Erik cautions that while FlowFields are powerful, filtering on them in high-volume tables with many users can be expensive. The calculation happens on the fly for every record that needs to be evaluated, so giving users unrestricted filtering ability on FlowFields in large datasets could hurt performance.

The Complete Source Code

Here’s the full page object showing all three methods side by side:

page 50100 "Eriks Customers"
{
    ApplicationArea = All;
    Caption = 'My Customers';
    PageType = List;
    SourceTable = Customer;
    UsageCategory = Lists;

    layout
    {
        area(content)
        {
            repeater(General)
            {
                field("No."; Rec."No.")
                {
                }
                field(Name; Rec.Name)
                {
                }
                field(Address; Rec.Address)
                {
                }
                field(City; Rec.City)
                {
                }
                field(VendorName; GetVendorName(Rec))
                {
                }
                field(VendorName2; VendorNameTxt)
                {
                }
                field(VendorName3; Rec.VendorName)
                {
                }
            }
        }
    }

    trigger OnAfterGetRecord()
    var
        Vendor: Record Vendor;
    begin
        if Vendor.Get(Rec."No.") then
            VendorNameTxt := Vendor.Name
        else
            VendorNameTxt := '';
    end;

    local procedure GetVendorName(CustRec: Record Customer): Text
    var
        Vendor: Record Vendor;
    begin
        if Vendor.Get(CustRec."No.") then
            exit(Vendor.Name)
        else
            exit('<no vendor>');
    end;

    var
        [InDataSet]
        VendorNameTxt: Text;
}

And the table extension that supports Method 3:

tableextension 50100 "My Customer Ext" extends Customer
{
    fields
    {
        field(50100; VendorName; Text[100])
        {
            FieldClass = FlowField;
            CalcFormula = lookup(Vendor.Name where("No." = field("No.")));
            Editable = false;
        }
    }
}

Summary: Choosing the Right Method

Here’s a quick comparison of the three approaches:

  1. Function call in field expression — Simplest to implement. The function is stateless and reusable. However, the column doesn’t support sorting or filtering, and each field requires its own function call (potentially multiple lookups for multiple fields).
  2. Global variable with OnAfterGetRecord — More efficient when you need multiple fields from the same related record, since you only perform one lookup. Still doesn’t support sorting or filtering. Remember to clear the variable for records where no related data exists.
  3. FlowField via table extension — The most feature-rich option. Supports filtering, drill-down, and benefits from SQL-level optimization by Microsoft. However, be cautious about performance on high-volume tables, as filtering on FlowFields can be expensive.

Erik notes that he uses all three methods in practice, depending on the circumstances. Each has its place, and the right choice depends on the volume of data, the number of related fields you need, and whether end users need sorting and filtering capabilities on those virtual columns.