Skip to content

Singleton Setup Tables in Business Central AL: From C/AL to Modern Best Practices

May 29, 2026

If you’ve built anything in NAV or Business Central, you’ve used a setup table. The problem is—most legacy patterns break the moment you leave the UI.

This guide shows the correct modern AL pattern for singleton setup tables, including concurrency handling, naming conventions, and reusable design.

What Is a Singleton Setup Table?

In Microsoft Dynamics 365 Business Central, a singleton setup table is a table designed to hold exactly one record — your extension’s configuration. Think of General Ledger Setup, Sales & Receivables Setup, or Inventory Setup. One row. Always there. Always accessible.

When you build a custom extension, you’ll almost always need one of these. Maybe you need to store a default dimension, a posting group override, or a number series code your extension uses. That’s your singleton setup table.


The Classic C/AL Pattern (NAV Days)

If you’ve been around since NAV 2013–2017, this pattern lives rent-free in your head:

OnOpenPage()
    RESET;
    IF NOT GET THEN BEGIN
        INIT;
        INSERT;
    END;

Simple. Effective. Gets the job done. The page opens, the record is created if it doesn’t exist, and life goes on.

But here’s the problem — the logic lives entirely in the page. The moment any other object (a report, a codeunit, a job queue) needs that same setup record, you either duplicate the logic or pray the user opened the setup page first.

In real projects, that prayer fails. A client calls saying a report errored out — and the root cause is a missing setup record because the setup page was never opened on that environment.


The Modern AL Pattern

Modern AL encourages you to move this responsibility into the table itself as a reusable procedure. Here’s a realistic example — a setup table for a custom extension that needs a default No. Series and a Global Dimension Code override.

The Setup Table

namespace Abinash.ExtensionName;

table 50100 "ABD My Extension Setup"
{
    Caption = 'My Extension Setup';
    DataClassification = CustomerContent;
    // DataPerCompany = false; // Uncomment only if config should be shared across all companies

    fields
    {
        field(1; "Primary Key"; Code[10])
        {
            Caption = 'Primary Key';
        }
        field(10; "ABD No. Series"; Code[20])
        {
            Caption = 'No. Series';
            ToolTip = 'Specifies the number series used to assign numbers in this extension.';
            TableRelation = "No. Series";
        }
        field(20; "ABD Default Global Dimension 1"; Code[20])
        {
            Caption = 'Default Global Dimension 1';
            ToolTip = 'Specifies the default global dimension 1 code applied on extension transactions.';
            TableRelation = "Dimension Value".Code
                where("Global Dimension No." = const(1));
        }
    }

    keys
    {
        key(PK; "Primary Key") { Clustered = true; }
    }

    procedure GetSetup()
    begin
        if not Get('') then begin
            Init();
            "Primary Key" := '';

            // Handle concurrency — two sessions may try to insert at the same time
            if not Insert(true) then
                Get('');
        end;
    end;
}

PRO TIP: Insert(true) ensures OnInsert triggers fire — important if another extension later extends this table.

WARNING: Without the concurrency-safe pattern above, background sessions like job queues or APIs can intermittently fail in high-load environments when two sessions hit GetSetup() at the exact same time.


The Setup Page

namespace Abinash.ExtensionName;

page 50100 "ABD My Extension Setup"
{
    Caption = 'My Extension Setup';
    PageType = Card;
    SourceTable = "ABD My Extension Setup";
    UsageCategory = Administration;
    ApplicationArea = All;
    DeleteAllowed = false;
    InsertAllowed = false;

    layout
    {
        area(Content)
        {
            group(General)
            {
                Caption = 'General';

                field("ABD No. Series"; Rec."ABD No. Series")
                {
                    ApplicationArea = All;
                    ToolTip = 'Specifies the number series used by this extension.';
                }
                field("ABD Default Global Dimension 1"; Rec."ABD Default Global Dimension 1")
                {
                    ApplicationArea = All;
                    ToolTip = 'Specifies the default global dimension 1 code.';
                }
            }
        }
    }

    trigger OnOpenPage()
    begin
        Rec.GetSetup(); // Rec. prefix is correct here — this is a page trigger, not a table procedure
    end;
}

Calling It From a Report or Codeunit

This is where the pattern pays off. Any object in your extension reads setup the same clean way:

namespace Abinash.ExtensionName;

codeunit 50100 "ABD Some Process"
{
    var
        MyExtSetup: Record "ABD My Extension Setup";
        NoSeriesCode: Code[20];
        DimCode: Code[20];

    procedure RunProcess()
    begin
        MyExtSetup.GetSetup();

        // Now safely use setup values anywhere
        NoSeriesCode := MyExtSetup."ABD No. Series";
        DimCode      := MyExtSetup."ABD Default Global Dimension 1";
    end;
}

No duplicate GET logic scattered across reports, codeunits, and job queues. One procedure. Called from everywhere.


Three Common Questions

1. Blank key ” or named key like ‘SETUP’?

Use blank ''. Every Microsoft setup table does it this way — General Ledger Setup, Sales & Receivables Setup, all of them. Using 'SETUP' works but it’s non-standard and adds zero value.

Here’s what both look like so you can spot the difference in the wild:

// ❌ Non-standard — works but unnecessary
procedure GetSetup()
begin
    if Rec.Get('SETUP') then
        exit;

    Rec.Init();
    Rec."Primary Key" := 'SETUP';
    Rec.Insert(true);
end;

// ✅ Microsoft standard — clean, concurrency-safe, recognized instantly
procedure GetSetup()
begin
    if not Get('') then begin
        Init();
        "Primary Key" := '';

        if not Insert(true) then
            Get('');
    end;
end;

Notice also that inside a table’s own procedure, you don’t need the Rec. prefix — Get(), Init(), and Insert() implicitly refer to the table itself. Rec. is page-level syntax.


2. Should I use a prefix/suffix on field names?

Short answer — yes, if you’re targeting AppSource. Microsoft requires a registered affix (exactly 3 characters) on all custom object and field names to avoid collisions with other extensions. For internal extensions, a namespace alone is sufficient, but using an affix is a good habit regardless.

// Internal only — namespace is enough
namespace Abinash.ExtensionName;
field(10; "No. Series"; Code[20]) { }

// AppSource / multi-extension safe — namespace + affix
namespace Abinash.ExtensionName;
field(10; "ABD No. Series"; Code[20]) { }

To register your affix, email [email protected] with your MPN ID, publisher name, and at least five 3-character suggestions. See Microsoft’s prefix and suffix requirements for full details. Pick something short and unique to your company.


3. Why gap-based field numbering (10, 20, 30)?

field(1;  "Primary Key";                  Code[10]) { }  // Always 1
field(10; "ABD No. Series";               Code[20]) { }  // First real field
field(20; "ABD Default Global Dim 1";     Code[20]) { }  // Room to insert between
field(30; "ABD Something Added Later";    Boolean)  { }

Field gaps let you insert new fields between existing ones later without breaking logical grouping. It’s a small habit that saves real pain on growing extensions.


The Key Takeaway

AspectOld C/AL PatternModern AL Pattern
Where logic livesPage OnOpenPageTable GetSetup procedure
Reusable by reports / codeunits
Works with job queues / APIs
Concurrency safe
Setup guaranteedOnly if page is openedAnywhere it’s called
Microsoft standard key
Namespace support

The old pattern isn’t wrong — it’s just incomplete for modern BC development where reports, APIs, and job queues all need setup data independently of the UI.

Put the logic in the table. Call it from everywhere. One source of truth.


Technical FAQ

Q: Should I always call GetSetup() before using the record?
Yes. Never assume the record exists — especially in job queues or API calls that run outside the UI.

Q: Can I cache the setup record for performance?
Start without caching — it’s rarely needed. If you’re calling setup hundreds of times per transaction, consider a SingleInstance codeunit as a thin cache layer.

Q: Should the setup table be DataPerCompany?
Usually yes — that’s the default. Only set DataPerCompany = false if you intentionally want the config shared across all companies in the environment.

Q: Why not just insert the record in the OnInstall codeunit?
You can and should — but you still need runtime safety. OnInstall won’t cover every edge case (environment restores, database migrations, etc.).

Q: Singleton setup table vs SingleInstance codeunit — what’s the difference?
Completely different concepts. A singleton setup table means one record in the database. A SingleInstance codeunit means one instance in memory per session. Don’t mix them up.


Built this pattern differently? Found an edge case I missed? Let’s hear it below.