I recently had to decode the content of a RecordID field. That turned into an adventure. Check out the video:

In this video, Erik takes us on a deep dive into the internal binary format of the RecordID field type in Business Central. When given a raw data dump from a defunct NAV database, the only piece that wasn’t easily readable was the RecordID. Rather than spinning up an old NAV instance, Erik reverse-engineers the hexadecimal encoding to extract the table number and primary key values directly.
What Is a RecordID?
Business Central has a field type called RecordID — a special value that holds the complete identifier of a record, including which table it belongs to. With a RecordID, you can locate any record in the system. It’s less commonly used today because the SystemId field (a GUID) is more accessible and uses an open format. However, SystemId has limitations:
- It’s only guaranteed to be unique within a table, not across the entire system
- You cannot determine which table a record belongs to from the SystemId alone
RecordID solves both of these problems — but it uses a closed binary format that isn’t well-documented anywhere. Erik found some clues on old, deleted blog posts via the Wayback Machine, but ultimately had to work it out by examining the raw data.
Examining the Raw Data
Erik starts by looking at the Record Link table in SQL Server. This table stores links and notes that users attach to records in Business Central. The RecordID field is stored as a varbinary(448) column, and SQL Server displays it with the 0x prefix — the universal indicator for hexadecimal data.
To create test data, Erik adds links to records across several tables — a Vendor, a Customer, a Contact, and a Production Order — then examines the resulting hex values in SQL to look for patterns.
Decoding the Table Number
The first observation is straightforward. For a Customer record (table 18), the hex string begins with 12000000. Using a hex calculator: 0x12 = 18 in decimal. For a Vendor record (table 23), it starts with 17000000, and 0x17 = 23. That’s promising.
But when Erik creates a link on a Contact record (table 5050), the hex starts with BA130000. Reading 0xBA13 as a straight hex number gives 47,635 — that’s not right. But reading the bytes in reverse order — 0x13BA — gives 5050. This is the Contact table!
Little-Endian Byte Order
This reveals that the data is stored in little-endian byte order. This is a fundamental concept in computer science: when a multi-byte value is stored in memory, the bytes can be ordered in two ways:
- Little-endian: The least significant byte comes first (used by x86/x64 processors and, as we can see, by Business Central’s RecordID format)
- Big-endian: The most significant byte comes first (looks more like how we write numbers in math)
So the first 8 hex characters (4 bytes) represent a 32-bit unsigned integer for the table number, stored in little-endian order.
Decoding the Primary Key Fields
After the table number, there are a few bytes that encode metadata about the primary key field. Erik identifies a 4-byte section (e.g., 027B) that represents the field type. While the exact encoding of this type indicator isn’t fully documented, it can be used to distinguish between integer fields and code fields.
Code Field Encoding
For Code fields (like Customer No.), there’s an important byte right after the type indicator:
- If the byte is
FF(255), the content contains alphanumeric characters - If the byte is a number (e.g.,
05), it indicates the content is purely numerical and the value represents the length
This is a legacy optimization from the old NAV database engine. By storing the length of numerical-only code values at the beginning, the database could sort them correctly as numbers rather than as strings. A value of length 1 (like “1”) would sort before a value of length 2 (like “10”).
Unicode Characters and Zero-Terminated Strings
Business Central uses Unicode, so each character in the primary key is represented by 2 bytes (4 hex characters) in little-endian order. For example, the character “C” (ASCII 67, hex 43) appears as 4300. The character “0” (ASCII 48, hex 30) appears as 3000.
The string is zero-terminated — a sequence of 0000 marks the end of each field value. This is because a null byte (0x00) cannot appear inside a Code field value, making it a reliable delimiter.
Putting It All Together: The Format
Here’s the overall structure of a RecordID in hex:
- Bytes 1–4 (hex positions 1–8): Table number as a 32-bit little-endian unsigned integer
- Bytes 5–8 (hex positions 9–16): Field type metadata
- Byte 9 (hex positions 17–18): Length/type indicator for Code fields (
FF= alphanumeric, otherwise = numeric length) - Remaining bytes: Primary key value(s) as Unicode characters (2 bytes each, little-endian), zero-terminated
Let’s trace through an example. The hex string for a Contact record link:
BA130000027BFF4300540030003000300030003200000000
BA130000→ Table number:0x000013BA= 5050 (Contact table)027B→ Field type metadataFF→ Alphanumeric code field4300→ ‘C’,5400→ ‘T’,3000→ ‘0’,3000→ ‘0’,3000→ ‘0’,3000→ ‘0’,3200→ ‘2’00000000→ Zero terminator
The AL Code
Erik builds up the decoder incrementally in AL. The final working code demonstrates all the key functions needed to parse the hex string:
pageextension 50100 CustomerListExt extends "Customer List"
{
trigger OnOpenPage();
var
HexStr: Text;
begin
HexStr := 'BA130000027BFF4300540030003000300030003200000000';
HexStr := '24000000008B01000000027BFF53002D004F005200440031003000310030003000310000000000';
HexStr := '12000000027B053100300030003000300000000000';
HexStr := '12000000027BFF43003000300030003100300000000000';
// tttttttt LL111122223333444455556666
// 12345678 C 0 0 0 1 0 ZZZZZZZZ
message(' Table = %1\Customer = %2', GetIntegerFromHex(HexStr.Substring(1, 8)),
GetCharFromHex(HexStr.Substring(15, 4)) +
GetCharFromHex(HexStr.Substring(19, 4)) +
GetCharFromHex(HexStr.Substring(23, 4)) +
GetCharFromHex(HexStr.Substring(27, 4)) +
GetCharFromHex(HexStr.Substring(31, 4)) +
GetCharFromHex(HexStr.Substring(35, 4))
);
end;
local procedure GetIntegerFromHex(HexInt: Text[8]): BigInteger
begin
exit(GetByteFromHexByte(HexInt.Substring(1, 2)) +
GetByteFromHexByte(HexInt.Substring(3, 2)) * 256 +
GetByteFromHexByte(HexInt.Substring(5, 2)) * 256 * 256 +
GetByteFromHexByte(HexInt.Substring(7, 2)) * 256 * 256 * 256);
end;
local procedure GetCharFromHex(HexChar: Text[4]): Text[1]
var
Out: Text[1];
begin
Out[1] := GetByteFromHexByte(HexChar.Substring(1, 2)) +
GetByteFromHexByte(HexChar.Substring(3, 2)) * 256;
exit(out);
end;
local procedure GetByteFromHexByte(HexByte: Text[2]) b: Byte
var
b1: Byte;
b2: Byte;
begin
b1 := GetValueFromHexChar(HexByte[1]);
b2 := GetValueFromHexChar(HexByte[2]);
b := (b1 * 16) + b2;
exit(b);
end;
local procedure GetValueFromHexChar(HexChar: Text[1]) HexCharValue: Integer
begin
if not Evaluate(HexCharValue, HexChar) then begin
case HexChar.ToLower() of
'a':
HexCharValue := 10;
'b':
HexCharValue := 11;
'c':
HexCharValue := 12;
'd':
HexCharValue := 13;
'e':
HexCharValue := 14;
'f':
HexCharValue := 15;
end;
end;
exit(HexCharValue);
end;
}
How the Helper Functions Work
GetValueFromHexChar converts a single hex character (‘0’–’9’, ‘A’–’F’) into its numeric value (0–15). For digits, it uses Evaluate; for letters, it uses a case statement.
GetByteFromHexByte takes two hex characters and combines them into a single byte. The first character is multiplied by 16 (since it represents the high nibble) and the second is added as the low nibble.
GetIntegerFromHex reads 8 hex characters (4 bytes) in little-endian order and reconstructs the full integer. Each successive byte is multiplied by an increasing power of 256. Note the use of BigInteger as the return type to avoid overflow issues with unsigned 32-bit values, since AL’s Integer type is signed.
GetCharFromHex reads 4 hex characters (2 bytes) in little-endian order and returns the corresponding Unicode character.
Practical Application
In a real-world decoder, you would process the hex string from left to right, slicing off pieces as you go:
- Read the first 8 hex characters to get the table number
- Read the field type metadata to determine what kind of primary key field comes next
- Based on the field type — integer fields consume 8 hex characters; code fields are read as Unicode characters until a zero terminator (
0000) is found - If the primary key has multiple fields, repeat the process for each subsequent field
Conclusion
While you’ll rarely need to decode a RecordID by hand, understanding the internal format is invaluable when working with raw data dumps from legacy NAV or Business Central databases. The key takeaways are: the table number is a 4-byte little-endian integer at the start, primary key values are encoded as zero-terminated Unicode strings (for Code fields) or as little-endian integers, and there’s a small metadata section between the table number and the key values that identifies the field type. Armed with these AL helper functions and the knowledge of the binary layout, you can decode any RecordID directly from its hex representation.