In this video, I show, not one, not two, but three different ways to process XML data without using an XMLPort. AL now has native XML data types and all three methods use the new data types.

In this video, Erik demonstrates how to process large XML files in Business Central AL without using an XMLPort. He walks through three different approaches to parsing and importing 30,000 XML records, comparing their readability, correctness, and performance. The video covers AL’s native XML data types, helper functions for type conversion, and even a dynamic approach using RecordRef and FieldRef.
Why Not Use an XMLPort?
XMLPorts are great when your data structure maps directly to your tables — they’re straightforward and easy to set up. But in many real-world scenarios, your XML data doesn’t neatly match your table structure, or you need more control over the parsing logic. In those cases, working directly with AL’s native XML data types gives you far more flexibility.
Compared to the old NAV days where you had to wrestle with XML DOM via OLE Automation controllers or .NET interop, Business Central provides native data types like XmlDocument, XmlElement, XmlNode, and XmlNodeList that make XML processing much more approachable.
Setting Up the Table and Page
Before diving into the import logic, Erik sets up a table to receive the XML data. The table mirrors the fields found in the XML file:
table 50145 "XML Data"
{
Caption = 'XML Data';
DataClassification = ToBeClassified;
fields
{
field(1; FirstName; Text[50])
{
Caption = 'FirstName';
DataClassification = ToBeClassified;
}
field(2; LastName; Text[50])
{
Caption = 'LastName';
DataClassification = ToBeClassified;
}
field(3; Company; Text[100])
{
Caption = 'Company';
DataClassification = ToBeClassified;
}
field(4; StreetNumber; Integer)
{
Caption = 'StreetNumber';
DataClassification = ToBeClassified;
}
field(5; StreetName; Text[50])
{
Caption = 'StreetName';
DataClassification = ToBeClassified;
}
field(6; Suffix; Text[10])
{
Caption = 'Suffix';
DataClassification = ToBeClassified;
}
field(7; City; Text[50])
{
Caption = 'City';
DataClassification = ToBeClassified;
}
field(8; State; Code[10])
{
Caption = 'State';
DataClassification = ToBeClassified;
}
field(9; Zip; Integer)
{
Caption = 'Zip';
DataClassification = ToBeClassified;
}
field(10; AreaCode; Code[3])
{
Caption = 'AreaCode';
DataClassification = ToBeClassified;
}
field(11; Phone; Code[10])
{
Caption = 'Phone';
DataClassification = ToBeClassified;
}
field(12; RowID; Integer)
{
Caption = 'RowID';
DataClassification = ToBeClassified;
}
}
keys
{
key(PK; RowID)
{
Clustered = true;
}
}
}
A list page provides visibility into the imported data, along with actions to trigger the import and clear data between test runs:
page 50145 "XML Data View"
{
ApplicationArea = All;
Caption = 'XML Data View';
PageType = List;
SourceTable = "XML Data";
UsageCategory = Lists;
layout
{
area(content)
{
repeater(General)
{
field(AreaCode; Rec.AreaCode) { ApplicationArea = All; }
field(City; Rec.City) { ApplicationArea = All; }
field(Company; Rec.Company) { ApplicationArea = All; }
field(FirstName; Rec.FirstName) { ApplicationArea = All; }
field(LastName; Rec.LastName) { ApplicationArea = All; }
field(Phone; Rec.Phone) { ApplicationArea = All; }
field(RowID; Rec.RowID) { ApplicationArea = All; }
field(State; Rec.State) { ApplicationArea = All; }
field(StreetName; Rec.StreetName) { ApplicationArea = All; }
field(StreetNumber; Rec.StreetNumber) { ApplicationArea = All; }
field(Suffix; Rec.Suffix) { ApplicationArea = All; }
field(Zip; Rec.Zip) { ApplicationArea = All; }
}
}
}
actions
{
area(Processing)
{
action(Clear)
{
Caption = 'Clear Data';
ApplicationArea = all;
Promoted = true;
PromotedCategory = Process;
PromotedIsBig = true;
PromotedOnly = true;
trigger OnAction()
begin
DeleteAll();
end;
}
action(Import)
{
Caption = 'Import';
ApplicationArea = all;
Promoted = true;
PromotedCategory = Process;
PromotedIsBig = true;
PromotedOnly = true;
trigger OnAction()
var
Import: Codeunit "XML Data";
begin
Import.Import();
end;
}
}
}
}
Loading the XML Document
The first step in all three approaches is the same: upload the file and parse it into an XmlDocument. The UploadIntoStream function prompts the user to select a file and, if successful, provides an InStream. From there, XmlDocument.ReadFrom — a static function on the type itself — parses the stream into a document object:
if UploadIntoStream('Upload XML', '', '', FileName, InS) then
if XmlDocument.ReadFrom(InS, XmlDoc) then begin
XmlDoc.GetRoot(Root);
Records := Root.GetChildElements('Record');
// ... process records
end else
error('Cannot parse XML');
A quick XML primer: every XML document has a single root element. In this case it’s called something like xml_data. Under that root, there are many child elements all called Record. We use GetRoot to grab the root element, then GetChildElements('Record') to get an XmlNodeList of all the record entries.
Helper Functions for Type Conversion
Before looking at the three import methods, Erik creates reusable helper functions to extract typed values from XML elements. These form the “plumbing code” that you’ll use repeatedly across XML processing tasks:
local procedure GetText(e: XmlElement; Name: Text): Text
var
FieldNode: XmlNode;
begin
foreach FieldNode in e.GetChildElements(Name) do
exit(FieldNode.AsXmlElement().InnerText);
end;
local procedure GetInteger(e: XmlElement; Name: Text): Integer
var
FieldNode: XmlNode;
value: Integer;
begin
foreach FieldNode in e.GetChildElements(Name) do
if evaluate(Value, FieldNode.AsXmlElement().InnerText, 9) then
exit(value);
end;
local procedure GetDateTime(e: XmlElement; Name: Text): DateTime
var
FieldNode: XmlNode;
value: DateTime;
begin
foreach FieldNode in e.GetChildElements(Name) do
if evaluate(Value, FieldNode.AsXmlElement().InnerText, 9) then
exit(value);
end;
The Magic Number 9
The third parameter in Evaluate is the number 9. Normally, Evaluate takes localization into account — decimal separators, date formats, etc. Passing 9 tells it to ignore all localization rules and use XML/invariant formatting rules instead. This is critical when parsing XML data, which follows standardized formatting.
The ForEach Pattern
Notice the clever use of foreach in these helpers. Since we know there’s only one child element with a given name, the foreach loop effectively acts as a “find first and return” pattern. If no child element exists with that name, the loop simply doesn’t execute, and AL’s implicit behavior returns a default value (blank string, zero, etc.) — no error handling needed.
Method 1: Classic Field-by-Field Assignment
The first approach is the most traditional and arguably the most correct from a validation perspective. For each XML record, you explicitly map fields using the helper functions, calling Insert and then Validate/Modify:
foreach Node in Records do begin
e := Node.AsXmlElement();
Data.Init();
Data.RowID := GetInteger(e, 'RowID');
Data.Insert(true);
Data.Validate(Company, GetText(e, 'Company'));
Data.Validate(FirstName, GetText(e, 'FirstName'));
Data.Validate(LastName, GetText(e, 'LastName'));
Data.Modify(true);
end;
This pattern mirrors the data validation approach Erik covered in a previous video. Each field is validated individually, triggers fire, and the record is properly modified. The downside is that each call to GetText or GetInteger performs a separate GetChildElements lookup on the XML element, which adds up across thousands of records.
Result: ~28 seconds for 30,000 records. Acceptable, but the slowest of the three methods.
Method 2: Loop Over XML Child Elements
The second approach flips the logic: instead of looking up each field individually, you iterate over all child elements of the current record once and use a case statement to assign values:
foreach Node in Records do begin
e := Node.AsXmlElement();
Data.Init();
foreach fieldnode in e.GetChildElements() do begin
field := fieldnode.AsXmlElement();
case field.Name of
'Company':
Data.Company := field.InnerText;
'RowID':
Evaluate(Data.RowID, field.InnerText, 9);
'FirstName':
Data.FirstName := field.InnerText;
'LastName':
Data.LastName := field.InnerText;
end;
end;
Data.Insert(true);
end;
The key insight is that you only call GetChildElements() once per record (without a name filter, to get all children), then distribute values based on element names. This dramatically reduces the number of XML lookups.
Result: ~8 seconds for 30,000 records — more than 3x faster than Method 1. The tradeoff is that you skip the Validate/Modify pattern, so field validation triggers won’t fire.
Method 3: Dynamic Assignment with RecordRef and FieldRef
The third approach is the most dynamic. Instead of hard-coding field mappings, it uses RecordRef and FieldRef to loop through all fields in the table and automatically match them to XML elements by name:
foreach Node in Records do begin
e := Node.AsXmlElement();
Ref.Open(DATABASE::"XML Data");
Ref.Init();
for i := 1 to Ref.FieldCount do begin
FieldRef := Ref.FieldIndex(i);
case FieldRef.Type of
FieldType::Integer:
FieldRef.Value := GetInteger(e, FieldRef.Name);
FieldType::Text,
FieldType::Code:
FieldRef.Value := GetText(e, FieldRef.Name);
end;
end;
Ref.Insert();
Ref.Close();
end;
This works because the XML element names exactly match the table field names. The code loops through every field in the table, checks its type, and calls the appropriate helper function using the field’s name as the XML element name to look up. When you add a new field to the table with a matching XML element name, the import code automatically picks it up with zero changes.
A critical note about FieldRef.Value: it is extremely type-sensitive. You must assign the correct data type, or it will throw an error. That’s why the case on FieldRef.Type is essential.
Result: ~9 seconds for 30,000 records — just one second behind Method 2, but it automatically handled all 12 fields with minimal code.
The Complete Codeunit
Here’s the full codeunit with all three methods included (you’d choose one in practice):
codeunit 50145 "XML Data"
{
procedure Import()
var
InS: InStream;
FileName: Text;
XmlDoc: XmlDocument;
Root: XmlElement;
Records: XmlNodeList;
Node: XmlNode;
e: XmlElement;
Data: record "XML Data";
fieldnode: XmlNode;
field: XmlElement;
Ref: RecordRef;
FieldRef: FieldRef;
i: Integer;
begin
if UploadIntoStream('Upload XML', '', '', FileName, InS) then
if XmlDocument.ReadFrom(InS, XmlDoc) then begin
XmlDoc.GetRoot(Root);
Records := Root.GetChildElements('Record');
foreach Node in Records do begin
e := Node.AsXmlElement();
// Method 1: Classic field-by-field with validation
Data.Init();
Data.RowID := GetInteger(e, 'RowID');
Data.Insert(true);
Data.Validate(Company, GetText(e, 'Company'));
Data.Validate(FirstName, GetText(e, 'FirstName'));
Data.Validate(LastName, GetText(e, 'LastName'));
Data.Modify(true);
// Method 2: Loop over XML child elements
Data.Init();
foreach fieldnode in e.GetChildElements() do begin
field := fieldnode.AsXmlElement();
case field.Name of
'Company':
Data.Company := field.InnerText;
'RowID':
Evaluate(Data.RowID, field.InnerText, 9);
'FirstName':
Data.FirstName := field.InnerText;
'LastName':
Data.LastName := field.InnerText;
end;
end;
Data.Insert(true);
// Method 3: Dynamic with RecordRef/FieldRef
Ref.Open(DATABASE::"XML Data");
Ref.Init();
for i := 1 to Ref.FieldCount do begin
FieldRef := Ref.FieldIndex(i);
case FieldRef.Type of
FieldType::Integer:
FieldRef.Value := GetInteger(e, FieldRef.Name);
FieldType::Text,
FieldType::Code:
FieldRef.Value := GetText(e, FieldRef.Name);
end;
end;
Ref.Insert();
Ref.Close();
end;
end else
error('Cannot parse XML');
end;
local procedure GetText(e: XmlElement; Name: Text): Text
var
FieldNode: XmlNode;
begin
foreach FieldNode in e.GetChildElements(Name) do
exit(FieldNode.AsXmlElement().InnerText);
end;
local procedure GetInteger(e: XmlElement; Name: Text): Integer
var
FieldNode: XmlNode;
value: Integer;
begin
foreach FieldNode in e.GetChildElements(Name) do
if evaluate(Value, FieldNode.AsXmlElement().InnerText, 9) then
exit(value);
end;
local procedure GetDateTime(e: XmlElement; Name: Text): DateTime
var
FieldNode: XmlNode;
value: DateTime;
begin
foreach FieldNode in e.GetChildElements(Name) do
if evaluate(Value, FieldNode.AsXmlElement().InnerText, 9) then
exit(value);
end;
}
Performance Comparison
All three methods were tested with the same 30,000 record XML file (~432,000 lines of XML):
- Method 1 (Classic with Validate/Modify): ~28 seconds — most correct with full validation triggers, but slowest due to repeated XML lookups and database round-trips (Insert + Modify per record)
- Method 2 (Loop over XML children): ~8 seconds — fastest, as child elements are retrieved only once per record
- Method 3 (Dynamic RecordRef/FieldRef): ~9 seconds — nearly as fast as Method 2, but with the bonus of automatically mapping all fields without explicit code per field
Key Takeaways
- AL’s native XML types (
XmlDocument,XmlElement,XmlNode,XmlNodeList) make XML processing in Business Central straightforward compared to the old NAV approach with OLE Automation. - Static functions on types (like
XmlDocument.ReadFrom) are an AL feature that mirrors C#/.NET patterns. - The Evaluate magic number 9 tells the system to use XML/invariant formatting rules, ignoring localization — essential for reliable XML data conversion.
- XmlElement vs. XmlNode: XmlElement inherits from XmlNode. In practice, use whichever the API returns —
GetRootreturns an element,GetChildElementsreturns a node list, and you can convert between them withAsXmlElement(). - Helper functions like
GetText,GetInteger, andGetDateTimeare generic plumbing code that you’ll reuse across projects — potentially good candidates for a shared library or even a contribution to the System Application. - Choose your method based on your needs: Method 1 when validation matters, Method 2 for performance with explicit mappings, Method 3 when your XML and table structures align and you want minimal maintenance overhead.