Introducing bcdb: Read a Business Central Backup Without SQL Server

Say someone hands you a customer’s BC backup and you need to check one field on one table. The normal path is: find or spin up a SQL Server, restore the file, wait for however long that takes on however large the database is, connect, query, and then decide whether to keep that SQL Server around or tear it down. All of that just to read a handful of values.

I built bcdb to skip every step after “have the file.” It reads a SQL Server native backup (.bak) or a BC cloud export (.bacpac) directly, and gives you the same tables, rows, and AL field names you’d get from a restore — without a SQL Server anywhere in the picture. I shipped the first release, v0.1.0, today.

Skipping the restore is a real benefit, but it’s not why I started this. The actual reason is AL Runner , which I’m getting close to being able to run against real data, including a customer’s .bacpac export. Today, restoring a .bacpac into SQL Server gets you the data, but you can’t start a BC service tier against it unless you have the exact set of extensions installed that produced the backup — same apps, same versions. AL Runner doesn’t have that constraint: it only cares whether the data structure matches. If a column it expects isn’t there, it fails, as it should. But it doesn’t need the exact extension versions the backup was taken with, only the same shape of data. bcdb is what gets that data out of the file so AL Runner can use it directly, with no BC service tier and no SQL Server involved at all.

What it does

bcdb parses the backup file, maps the database’s pages, walks the system catalog, and decodes rows straight from the file — including page-compressed data and off-page BLOBs. For a .bacpac it reads the zip archive, model.xml, and the native bulk-copy data streams instead. Both paths go through the same commands and produce the same output, so which file type you have only changes which path you point it at.

bcdb tables    <file>                          list tables with row counts, compression, company
bcdb companies <file>                          list the companies in the database
bcdb read      <file> --table <name>           decode rows to text or JSON
bcdb describe  <file> --table <name> --symbols <apps>   AL field ids, AL types, SQL columns
bcdb serve     <file>                          open once, answer many reads over stdin/stdout

A real example, against the demo backup that ships in every BC sandbox artifact:

bcdb read BusinessCentral-W1.bak --table "G/L Entry" --company CRONUS \
    --select "Entry No.,Posting Date,Amount" --format json \
    --symbols "Extensions/Microsoft_Base Application_28.1.49838.50621.app"

Passing --symbols (the .app packages the database was actually built from) is what turns raw SQL columns into AL field names and AL types. Without it you still get every value, just addressed by its SQL column name instead.

It also works as a library, no subprocess or JSON parsing needed:

using BusinessCentral.DbReader;

using var src = BcSource.Open("MyDatabase.bak");
var table = src.Tables.Single(t => t.Name.Contains("G_L Entry"));
foreach (var row in src.ReadRows(table, src.Columns(table)))
    Console.WriteLine(row["Entry No_"]);

The library is on nuget.org as BcDb.Core. The CLI installs as a .NET global tool (dotnet tool install -g bcdb) or as a self-contained native binary for linux-x64, linux-arm64, win-x64, osx-x64, and osx-arm64 — no .NET runtime required either way.

How I know it’s decoding correctly

Every structural fact — page layout, the system catalog, row and page compression, LOB storage, native-BCP row framing — gets checked page-for-page against a fresh SQL Server restore of the same file, on every test run and in verify.sh. On Microsoft’s shipped BC 27.5 and 28.1 demo backups, the page map reproduces a restore byte-for-byte on over 99.9% of pages; every remaining difference is SQL Server’s own backup-time bookkeeping, not table data. A 5.4 GB database built specifically to force multi-interval allocation, a type-probe database covering every supported type (including SCSU text in Cyrillic, Greek, CJK, and emoji, and LOB trees up to 180 KB), and both a .bak and a .bacpac export of that same probe database all check out the same way — 17 probe tables compared through both file formats on every test run.

Beyond the fixtures, I ran it against one independent 23 GB production BC database (BC 21 lineage upgraded to BC 24, third-party extensions, heaps) and one real 52 MB cloud export: 3,019,763 of 3,020,080 pages byte-identical to a fresh restore, and full tables up to 3.1 million rows decoding line-for-line equal to SELECT. Nothing from either file is in the repository. Anything outside what’s actually been checked fails loudly instead of guessing — a column type or storage form it can’t decode faithfully throws, naming the column, rather than returning a default value silently. Backups taken WITH COMPRESSION, encrypted backups, multi-file databases, differential and log backups, and a handful of column types (money, sql_variant, xml, sparse columns, and in a .bacpac also smalldatetime and datetimeoffset) are refused the same way rather than guessed at — if you need one of these, open an issue and I’ll look into it.

Performance: measure the gap before you chase it

Before writing the page-reading code, I estimated what a cold open should cost: walk a handful of allocation bitmap pages plus the system catalog’s own pages, 8 KB each — a few megabytes of reads for the 893 MB demo backup. That’s the process from Casey Muratori’s talk “Simple Code, High Performance” : figure out what an operation should cost before you look at what it does cost, so you know whether a number is a real problem or expected.

I wrote the first version with a plain buffered FileStream, then measured for real — evicting the page cache first so the number reflected an actual cold open. A cold open on that same 893 MB file was pulling roughly 195 MB off disk. That’s on the order of 50 times more than the few megabytes it should have needed. I ran those early numbers past Claude’s Fable model as a review, and it came back saying plainly that this was bad enough to reconsider the approach.

The cause matched the pattern from the talk exactly: a general-purpose layer doing work a narrow problem never needed. FileStream buffers reads, and its default 1 MB buffer read a full megabyte around every scattered 8 KB page request — thousands of them, each pulling in far more than the one page actually wanted. Switching to positional reads that fetch exactly the requested bytes, plus an opt-in whole-file prefetch for sessions that are going to read many tables anyway, closed the gap.

That fix, followed by seeking directly into the catalog’s own B-tree indexes instead of scanning them, is most of the difference between that first version and where it stands now: a cold single-table read on the published native binary is about 57 ms, against 174 ms from a JIT build — both a fraction of what the original 195 MB of unnecessary reads would have cost. bcdb serve opens the file once and then answers one JSON request per line over stdin/stdout, a few milliseconds per read once the file is open — the right mode if you’re reading many tables from the same file rather than one at a time.

Verifying a download

Every binary carries a build attestation you can check with gh attestation verify <file> --repo StefanMaron/BusinessCentral.DbReader, and there’s a SHA256SUMS file alongside the release. The Windows binary is Authenticode-signed, and so is the copy inside the .NET tool package. The macOS binaries aren’t notarized — that needs a paid Apple Developer Program membership this project doesn’t have — but dotnet tool install -g bcdb sidesteps that entirely, since macOS only quarantines files downloaded by a browser.

Try it

Grab a binary from the v0.1.0 release , or dotnet tool install -g bcdb, and point it at a backup you already have — the shipped BC sandbox demo backup works fine to start with. It’s MIT-licensed, and I’d rather hear about a table or type it gets wrong now, while it’s early, than have someone find out the hard way against a real customer file.