High Performance Posting to the G/L

What to do if you need to post thousands of G/L entries in Business Central, how fast can it actually be done? Check out one method in this video:

https://youtu.be/wv1xM9oW6NQ

In this video, Erik explores a high-performance approach to posting General Ledger entries in Business Central — bypassing the General Journal entirely and posting directly from in-memory records. The inspiration came from a forum discussion about importing half a million G/L entries per day, and the results are impressive even on modest hardware.

The Problem: Why General Journals Are a Bottleneck

To understand the approach, it helps to first understand how posting typically works in Business Central. When you post a sales order, the system creates a posted document (tables 112, 113, etc.) along with G/L entries, customer ledger entries, inventory entries, and more. There’s a clear separation between the source document and the resulting ledger entries.

The General Journal is different. When you post a journal line, the line simply disappears — there’s no “posted journal” table because the G/L entries are the posted output. The journal is both the input mechanism and, effectively, the posted document.

This raises an important question: if you’re importing thousands or hundreds of thousands of lines, do you actually need the journal at all? The traditional approach of loading data into journal lines (via Edit in Excel or other methods) and then posting them is essentially one big detour. The data goes into journal tables, gets validated, gets posted to the G/L, and then the journal lines are deleted.

The Key Insight: Post Directly from Memory

Here’s the critical insight: when Microsoft posts a sales order, the system never creates General Journal records in the database. It creates General Journal Line records in memory and posts directly from those temporary records. We can use the exact same technique for bulk G/L posting.

The workhorse behind this is Codeunit 12 — “Gen. Jnl.-Post Line”. This codeunit runs on a General Journal Line record, but that record does not have to exist in the database. It can live entirely in memory.

Building the Solution

Erik builds a page extension on the Chart of Accounts page, adding an action that demonstrates the technique. Here’s the final source code:

pageextension 50100 CharOfA extends "Chart of Accounts"
{
    actions
    {
        addfirst(processing)
        {
            action(Test)
            {
                Caption = 'Post something';
                ApplicationArea = all;
                trigger OnAction()
                var
                    GLPost: Codeunit "Gen. Jnl.-Post Line";
                    Line: Record "Gen. Journal Line";
                begin
                    Line.Init();
                    Line."Posting Date" := TODAY();
                    Line."Document Type" := Line."Document Type"::" ";
                    Line."Document No." := 'X000004';
                    Line."Account Type" := Line."Account Type"::"G/L Account";
                    Line."Account No." := '10910';
                    Line.Description := 'Youtube Testing';
                    Line.Amount := 70;
                    GLPost.RunWithCheck(Line);

                    Line.Init();
                    Line."Posting Date" := TODAY();
                    Line."Document Type" := Line."Document Type"::" ";
                    Line."Document No." := 'X000004';
                    Line."Account Type" := Line."Account Type"::"G/L Account";
                    Line."Account No." := '10920';
                    Line.Description := 'Youtube Testing';
                    Line.Amount := -30;
                    GLPost.RunWithCheck(Line);

                    Line.Init();
                    Line."Posting Date" := TODAY();
                    Line."Document Type" := Line."Document Type"::" ";
                    Line."Document No." := 'X000004';
                    Line."Account Type" := Line."Account Type"::"G/L Account";
                    Line."Account No." := '10940';
                    Line.Description := 'Youtube Testing';
                    Line.Amount := -41;
                    GLPost.RunWithCheck(Line);
                end;
            }
        }
    }
}

Key Points in the Code

  • No database insert: The Gen. Journal Line record is initialized with Line.Init() but never inserted into the database. It lives entirely in memory.
  • No templates, batches, or line numbers: Since the record never touches the journal tables, you don’t need to worry about journal templates, batch names, or line numbering.
  • No validation calls: Fields are assigned directly rather than using VALIDATE. Validation might rely on relational integrity (other records existing), which doesn’t apply here. You just need to ensure the field values make sense and are correctly aligned.
  • Explicit defaults: Even though "Document Type"::" " (blank) is the default, Erik sets it explicitly. This is good practice — anyone reading the code can see that the blank document type was intentional, not an oversight.
  • Entries must balance: The amounts across entries sharing a document number must net to zero, just as they would in a normal journal posting. In the example, 70 + (-30) + (-41) = -1… which Erik corrects during the video by accumulating balancing amounts properly.

Scaling Up: The Loop Approach

In the video, Erik extends this concept into a loop to test performance at scale. The approach is straightforward:

  1. Create the Codeunit "Gen. Jnl.-Post Line" variable once, outside the loop
  2. Pass it into a helper procedure for each line
  3. For each iteration, initialize a new in-memory journal line, set the fields, and call RunWithCheck (or RunWithoutCheck for even more speed)
  4. Post a balancing entry for each debit entry to keep the G/L in balance

The helper procedure pattern looks like this conceptually:

procedure PostLine(var GLPost: Codeunit "Gen. Jnl.-Post Line"; 
                   AccountNo: Code[20]; 
                   DocNo: Code[20]; 
                   Amount: Decimal)
var
    Line: Record "Gen. Journal Line";
begin
    Line.Init();
    Line."Posting Date" := Today();
    Line."Document Type" := Line."Document Type"::" ";
    Line."Document No." := DocNo;
    Line."Account Type" := Line."Account Type"::"G/L Account";
    Line."Account No." := AccountNo;
    Line.Description := 'High Performance Posting';
    Line.Amount := Amount;
    GLPost.RunWithCheck(Line);
end;

Performance Results

All tests were run on Erik’s laptop — which was also running screen recording software — posting to a Docker-based Business Central instance limited to about 8 GB of RAM. This is far from a high-performance server environment, yet the results are striking:

G/L Entries Posted Time (RunWithCheck) Time (RunWithoutCheck)
~200 ~0.25 seconds
~2,000 ~1.5 seconds
~5,001 ~8 seconds ~7 seconds

Extrapolating from these numbers:

  • 50,000 entries → approximately 1 minute
  • 500,000 entries → approximately 1 hour

And remember — these estimates are based on a laptop with a Docker container. On a proper server with adequate resources, you should see significantly better performance.

RunWithCheck vs. RunWithoutCheck

Switching from RunWithCheck to RunWithoutCheck shaved about a second off the 5,000-entry test. If you’re absolutely certain your data is valid (correct account numbers, balanced entries, proper posting dates), RunWithoutCheck gives you an additional performance boost by skipping validation logic inside Codeunit 12.

Practical Application: Excel Import Scenario

For a real-world implementation where you’re importing from Excel, the workflow would be:

  1. Upload the Excel file into the Excel Buffer (a temporary table in memory)
  2. Loop through the buffer rows
  3. Post each line directly to the G/L using the in-memory journal line technique shown above

This eliminates the entire round-trip of writing journal lines to the database, posting them, and then having them deleted — a significant overhead when dealing with hundreds of thousands of records.

Summary

The key takeaway is that Codeunit 12 (“Gen. Jnl.-Post Line”) doesn’t require journal lines to exist in the database. By initializing journal line records in memory and passing them directly to this codeunit, you can skip the journal entirely and post directly to the G/L. This is the same pattern Microsoft uses internally when posting sales orders, purchase orders, and other documents. For high-volume scenarios — tens or hundreds of thousands of entries — this approach can reduce what might take hours via traditional journal posting down to minutes, even on modest hardware.