Turns a .bacpac into a .dacpac without going anywhere near a SQL Server.
Warning
The bacpac and dacpac layouts are Microsoft's internal packaging details. Everything here was worked out by unzipping packages and diffing them against dacpacs SqlPackage produced from the same database - there is no specification behind it, and a DacFx update can break it silently.
Needs .NET 10.
dotnet tool install --global dotnet-bac2dacdotnet bac2dac AdventureWorks.bacpacUsage:
dotnet-bac2dac <input.bacpac> [output.dacpac] [options]
Options:
-o, --output <path> Same as the positional output argument.
-f, --force Overwrite the output file if it already exists.
-q, --quiet Print nothing unless something goes wrong.
-v, --version Print the version and exit.
-h, --help Print this help and exit.
With no output given it writes next to the input with the extension swapped. It refuses to overwrite unless you pass
--force, and removes the output it was writing if the conversion fails, so a failed run never leaves a dacpac that
looks finished but isn't. Exits 0 on success, 1 on a failed conversion, 2 on a bad command line.
dotnet add package bac2dacawait using var input = File.OpenRead("AdventureWorks.bacpac");
await using var output = File.Create("AdventureWorks.dacpac");
await Bac2DacConverter.ConvertAsync(input, output);The input has to be seekable - that's what reading a zip central directory needs. Neither stream is closed for you, and
entries are streamed one at a time, so memory doesn't track the size of the bacpac. Nothing outside the BCL is pulled
in - System.IO.Compression and System.Xml.Linq.
dotnet testTwo suites: the converter against real packages, and the command line parser.
The test data isn't in the repository - the samples are tens of megabytes each and artifacts/
is gitignored, so on a fresh clone the converter tests have nothing to run against. Generate it first:
./generate-test-data.shThat needs Docker, ~240 MB of downloads and a few minutes on the first run. It fetches the Microsoft sample backups into
a volume, restores them into a SQL Server 2025 container, verifies each database is online, then runs SqlPackage twice
per database - Extract for the dacpac, Export for the bacpac - into bac2dac.tests/artifacts/<Database>/.
Containers are torn down at the end and the volumes kept, so later runs are quick. --force regenerates everything.
On Apple Silicon the SQL Server image is linux/amd64 and runs emulated, which is slow but works.
| Database | Size (bacpac) | What it drags in |
|---|---|---|
AdventureWorksLT2025 |
370 KB | 12 tables. The one to iterate on - converts in ~50 ms. |
AdventureWorks2025 |
17 MB | 71 tables, XML schema collections, sequences, triggers, 538 extended properties. |
AdventureWorksDW2025 |
14 MB | Star schema, wide fact tables. |
WideWorldImporters |
59 MB | The hard one: memory-optimized and temporal tables, columnstore, partition schemes. |
WideWorldImportersDW |
20 MB | Columnstore warehouse, memory-optimized staging, partitioning. |
Microsoft's own samples, which is the point - the closest thing to a neutral corpus of SQL Server features, and nobody has to trust a schema I wrote myself.
Each pair is converted and compared to the SqlPackage dacpac using DacFx's SchemaComparison. The test requires the
comparison to be valid (DacFx could open the generated package at all - this is where a bad checksum or stray BOM
fails), equal (zero differences either way), and the model to hold at least one user-defined object, since an empty
package would otherwise compare equal to nothing and pass. Schema equivalence, not byte equality.
Test data is discovered from the filesystem, so there's no code to change - drop a matched pair in and dotnet test
picks it up:
bac2dac.tests/artifacts/
└── MyDatabase/
├── MyDatabase.bacpac
└── MyDatabase.dacpac
Both files must come from the same database in the same state. Export at lunchtime and extract after the afternoon's migration and you're testing schema drift, not the converter.
To produce the pair:
sqlpackage /Action:Extract \
/SourceConnectionString:"Server=myserver;Database=MyDatabase;Integrated Security=true;TrustServerCertificate=true" \
/TargetFile:bac2dac.tests/artifacts/MyDatabase/MyDatabase.dacpac \
/p:ExtractAllTableData=falsesqlpackage /Action:Export \
/SourceConnectionString:"Server=myserver;Database=MyDatabase;Integrated Security=true;TrustServerCertificate=true" \
/TargetFile:bac2dac.tests/artifacts/MyDatabase/MyDatabase.bacpac \
/p:VerifyFullTextDocumentTypesSupported=falseBoth flags matter. ExtractAllTableData=false keeps data out of the dacpac you're comparing against - one with data in
it will never match. VerifyFullTextDocumentTypesSupported=false skips an export-side check that fails where full-text
filters aren't registered, which is most containers.
To put your database in the Docker generator instead, add it to DATABASE_SPECS in
docker/shared/databases.sh and give
docker/backups/download-backups.sh somewhere to fetch it from. Everything
downstream is driven off that list.
MIT.